import { z } from "zod";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { Tool } from "./tool";
import { FileTimes } from "./util/file-times";

const DESCRIPTION = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.

The patch text must follow this format:
*** Begin Patch
*** Update File: /path/to/file
@@ Context line (unique within the file)
 Line to keep
-Line to remove
+Line to add
 Line to keep
*** Add File: /path/to/new/file
+Content of the new file
+More content
*** Delete File: /path/to/file/to/delete
*** End Patch

Before using this tool:
1. Use the FileRead tool to understand the files' contents and context
2. Verify all file paths are correct (use the LS tool)

CRITICAL REQUIREMENTS FOR USING THIS TOOL:

1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
2. PRECISION: All whitespace, indentation, and surrounding code must match exactly
3. VALIDATION: Ensure edits result in idiomatic, correct code
4. PATHS: Always use absolute file paths (starting with /)

The tool will apply all changes in a single atomic operation.`;

const PatchParams = z.object({
  patchText: z
    .string()
    .describe("The full patch text that describes all changes to be made"),
});

interface PatchResponseMetadata {
  changed: string[];
  additions: number;
  removals: number;
}

interface Change {
  type: "add" | "update" | "delete";
  old_content?: string;
  new_content?: string;
}

interface Commit {
  changes: Record<string, Change>;
}

interface PatchOperation {
  type: "update" | "add" | "delete";
  filePath: string;
  hunks?: PatchHunk[];
  content?: string;
}

interface PatchHunk {
  contextLine: string;
  changes: PatchChange[];
}

interface PatchChange {
  type: "keep" | "remove" | "add";
  content: string;
}

function identifyFilesNeeded(patchText: string): string[] {
  const files: string[] = [];
  const lines = patchText.split("\n");
  for (const line of lines) {
    if (
      line.startsWith("*** Update File:") ||
      line.startsWith("*** Delete File:")
    ) {
      const filePath = line.split(":", 2)[1]?.trim();
      if (filePath) files.push(filePath);
    }
  }
  return files;
}

function identifyFilesAdded(patchText: string): string[] {
  const files: string[] = [];
  const lines = patchText.split("\n");
  for (const line of lines) {
    if (line.startsWith("*** Add File:")) {
      const filePath = line.split(":", 2)[1]?.trim();
      if (filePath) files.push(filePath);
    }
  }
  return files;
}

function textToPatch(
  patchText: string,
  _currentFiles: Record<string, string>,
): [PatchOperation[], number] {
  const operations: PatchOperation[] = [];
  const lines = patchText.split("\n");
  let i = 0;
  let fuzz = 0;

  while (i < lines.length) {
    const line = lines[i];

    if (line.startsWith("*** Update File:")) {
      const filePath = line.split(":", 2)[1]?.trim();
      if (!filePath) {
        i++;
        continue;
      }

      const hunks: PatchHunk[] = [];
      i++;

      while (i < lines.length && !lines[i].startsWith("***")) {
        if (lines[i].startsWith("@@")) {
          const contextLine = lines[i].substring(2).trim();
          const changes: PatchChange[] = [];
          i++;

          while (
            i < lines.length &&
            !lines[i].startsWith("@@") &&
            !lines[i].startsWith("***")
          ) {
            const changeLine = lines[i];
            if (changeLine.startsWith(" ")) {
              changes.push({ type: "keep", content: changeLine.substring(1) });
            } else if (changeLine.startsWith("-")) {
              changes.push({
                type: "remove",
                content: changeLine.substring(1),
              });
            } else if (changeLine.startsWith("+")) {
              changes.push({ type: "add", content: changeLine.substring(1) });
            }
            i++;
          }

          hunks.push({ contextLine, changes });
        } else {
          i++;
        }
      }

      operations.push({ type: "update", filePath, hunks });
    } else if (line.startsWith("*** Add File:")) {
      const filePath = line.split(":", 2)[1]?.trim();
      if (!filePath) {
        i++;
        continue;
      }

      let content = "";
      i++;

      while (i < lines.length && !lines[i].startsWith("***")) {
        if (lines[i].startsWith("+")) {
          content += lines[i].substring(1) + "\n";
        }
        i++;
      }

      operations.push({ type: "add", filePath, content: content.slice(0, -1) });
    } else if (line.startsWith("*** Delete File:")) {
      const filePath = line.split(":", 2)[1]?.trim();
      if (filePath) {
        operations.push({ type: "delete", filePath });
      }
      i++;
    } else {
      i++;
    }
  }

  return [operations, fuzz];
}

function patchToCommit(
  operations: PatchOperation[],
  currentFiles: Record<string, string>,
): Commit {
  const changes: Record<string, Change> = {};

  for (const op of operations) {
    if (op.type === "delete") {
      changes[op.filePath] = {
        type: "delete",
        old_content: currentFiles[op.filePath] || "",
      };
    } else if (op.type === "add") {
      changes[op.filePath] = {
        type: "add",
        new_content: op.content || "",
      };
    } else if (op.type === "update" && op.hunks) {
      const originalContent = currentFiles[op.filePath] || "";
      const lines = originalContent.split("\n");

      for (const hunk of op.hunks) {
        const contextIndex = lines.findIndex((line) =>
          line.includes(hunk.contextLine),
        );
        if (contextIndex === -1) {
          throw new Error(`Context line not found: ${hunk.contextLine}`);
        }

        let currentIndex = contextIndex;
        for (const change of hunk.changes) {
          if (change.type === "keep") {
            currentIndex++;
          } else if (change.type === "remove") {
            lines.splice(currentIndex, 1);
          } else if (change.type === "add") {
            lines.splice(currentIndex, 0, change.content);
            currentIndex++;
          }
        }
      }

      changes[op.filePath] = {
        type: "update",
        old_content: originalContent,
        new_content: lines.join("\n"),
      };
    }
  }

  return { changes };
}

function generateDiff(
  oldContent: string,
  newContent: string,
  filePath: string,
): [string, number, number] {
  // Mock implementation - would need actual diff generation
  const lines1 = oldContent.split("\n");
  const lines2 = newContent.split("\n");
  const additions = Math.max(0, lines2.length - lines1.length);
  const removals = Math.max(0, lines1.length - lines2.length);
  return [`--- ${filePath}\n+++ ${filePath}\n`, additions, removals];
}

async function applyCommit(
  commit: Commit,
  writeFile: (path: string, content: string) => Promise<void>,
  deleteFile: (path: string) => Promise<void>,
): Promise<void> {
  for (const [filePath, change] of Object.entries(commit.changes)) {
    if (change.type === "delete") {
      await deleteFile(filePath);
    } else if (change.new_content !== undefined) {
      await writeFile(filePath, change.new_content);
    }
  }
}

export const patch = Tool.define({
  name: "patch",
  description: DESCRIPTION,
  parameters: PatchParams,
  execute: async (params) => {
    if (!params.patchText) {
      throw new Error("patchText is required");
    }

    // Identify all files needed for the patch and verify they've been read
    const filesToRead = identifyFilesNeeded(params.patchText);
    for (const filePath of filesToRead) {
      let absPath = filePath;
      if (!path.isAbsolute(absPath)) {
        absPath = path.resolve(process.cwd(), absPath);
      }

      if (!FileTimes.get(absPath)) {
        throw new Error(
          `you must read the file ${filePath} before patching it. Use the FileRead tool first`,
        );
      }

      try {
        const stats = await fs.stat(absPath);
        if (stats.isDirectory()) {
          throw new Error(`path is a directory, not a file: ${absPath}`);
        }

        const lastRead = FileTimes.get(absPath);
        if (lastRead && stats.mtime > lastRead) {
          throw new Error(
            `file ${absPath} has been modified since it was last read (mod time: ${stats.mtime.toISOString()}, last read: ${lastRead.toISOString()})`,
          );
        }
      } catch (error: any) {
        if (error.code === "ENOENT") {
          throw new Error(`file not found: ${absPath}`);
        }
        throw new Error(`failed to access file: ${error.message}`);
      }
    }

    // Check for new files to ensure they don't already exist
    const filesToAdd = identifyFilesAdded(params.patchText);
    for (const filePath of filesToAdd) {
      let absPath = filePath;
      if (!path.isAbsolute(absPath)) {
        absPath = path.resolve(process.cwd(), absPath);
      }

      try {
        await fs.stat(absPath);
        throw new Error(`file already exists and cannot be added: ${absPath}`);
      } catch (error: any) {
        if (error.code !== "ENOENT") {
          throw new Error(`failed to check file: ${error.message}`);
        }
      }
    }

    // Load all required files
    const currentFiles: Record<string, string> = {};
    for (const filePath of filesToRead) {
      let absPath = filePath;
      if (!path.isAbsolute(absPath)) {
        absPath = path.resolve(process.cwd(), absPath);
      }

      try {
        const content = await fs.readFile(absPath, "utf-8");
        currentFiles[filePath] = content;
      } catch (error: any) {
        throw new Error(`failed to read file ${absPath}: ${error.message}`);
      }
    }

    // Process the patch
    const [patch, fuzz] = textToPatch(params.patchText, currentFiles);
    if (fuzz > 3) {
      throw new Error(
        `patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
      );
    }

    // Convert patch to commit
    const commit = patchToCommit(patch, currentFiles);

    // Apply the changes to the filesystem
    await applyCommit(
      commit,
      async (filePath: string, content: string) => {
        let absPath = filePath;
        if (!path.isAbsolute(absPath)) {
          absPath = path.resolve(process.cwd(), absPath);
        }

        // Create parent directories if needed
        const dir = path.dirname(absPath);
        await fs.mkdir(dir, { recursive: true });
        await fs.writeFile(absPath, content, "utf-8");
      },
      async (filePath: string) => {
        let absPath = filePath;
        if (!path.isAbsolute(absPath)) {
          absPath = path.resolve(process.cwd(), absPath);
        }
        await fs.unlink(absPath);
      },
    );

    // Calculate statistics
    const changedFiles: string[] = [];
    let totalAdditions = 0;
    let totalRemovals = 0;

    for (const [filePath, change] of Object.entries(commit.changes)) {
      let absPath = filePath;
      if (!path.isAbsolute(absPath)) {
        absPath = path.resolve(process.cwd(), absPath);
      }
      changedFiles.push(absPath);

      const oldContent = change.old_content || "";
      const newContent = change.new_content || "";

      // Calculate diff statistics
      const [, additions, removals] = generateDiff(
        oldContent,
        newContent,
        filePath,
      );
      totalAdditions += additions;
      totalRemovals += removals;

      // Record file operations
      FileTimes.write(absPath);
      FileTimes.read(absPath);
    }

    const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`;
    const output = result;

    return {
      metadata: {
        changed: changedFiles,
        additions: totalAdditions,
        removals: totalRemovals,
      } satisfies PatchResponseMetadata,
      output,
    };
  },
});
