import { BestAgentError } from "@best-agent/foundation";
import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { z } from "zod";
import type {
  ToolDefinition,
  ToolEvidenceRecord,
  ToolExecutionContext,
  ToolInputAdapter,
} from "../contracts.js";
import { toolInputSchema, typedToolInputSchema } from "../schema.js";
import {
  type NamedContentInput,
  type PathInput,
  READ_FILE_LINE_PREVIEW_CHARS,
  READ_FILE_PREVIEW_HEAD_LINES,
  READ_FILE_PREVIEW_MAX_BYTES,
  READ_FILE_PREVIEW_TAIL_LINES,
  formatNamedContentInput,
  normalizeNamedContentInput,
  normalizePathInput,
} from "./shared.js";
import { formatReadFileOutput, formatLargeReadFileOutput } from "./read-tools.js";

type WorkspaceWriteInput = {
  content: string;
  file_path: string;
};
type EditInput = {
  file_path: string;
  new_string: string;
  old_string: string;
  replace_all: boolean;
};
type ApplyPatchInput = {
  patch: string;
};

const NAMED_CONTENT_INPUT_SCHEMA = toolInputSchema(
  z.strictObject({
    content: z.string().describe("Full text content to write to the target file."),
    path: z.string().describe("Path of the target file to write."),
  }),
);
const PATH_INPUT_SCHEMA = toolInputSchema(
  z.strictObject({
    path: z.string(),
  }),
);
const WORKSPACE_WRITE_INPUT_SCHEMA = typedToolInputSchema<WorkspaceWriteInput>(
  z.strictObject({
    content: z.string().describe("Full file content for create or overwrite."),
    file_path: z.string().describe("Workspace-relative path of the target file."),
  }),
);
const EDIT_INPUT_SCHEMA = typedToolInputSchema<EditInput>(
  z.strictObject({
    file_path: z.string().describe("Workspace-relative path of the existing file to edit."),
    old_string: z.string().min(1).describe("Exact current text to replace."),
    new_string: z.string().describe("Replacement text."),
    replace_all: z.boolean().default(false).describe("Replace every match instead of one match."),
  }),
);
const APPLY_PATCH_INPUT_SCHEMA = typedToolInputSchema<ApplyPatchInput>(
  z.strictObject({
    patch: z.string().min(1).describe("Unified diff patch to apply inside the workspace."),
  }),
);

export function createWriteScratchTool(): ToolDefinition<NamedContentInput> {
  return {
    description:
      'Write a text file inside the thread scratch directory. Input must include both path and content, for example {"path":"relative/path","content":"full file content"}.',
    effect: "write",
    executionClass: "workspace-mutation",
    idempotent: false,
    inputSchema: NAMED_CONTENT_INPUT_SCHEMA,
    name: "write-scratch",
    source: "built-in",
    capabilities(input, context) {
      const resolved = resolve(context.threadPaths.scratchDir, input.path);
      return [
        {
          group: "filesystem",
          action: "write",
          path: resolved,
          roots: [context.threadPaths.scratchDir],
        },
      ];
    },
    async execute(input, context) {
      const resolved = resolve(context.threadPaths.scratchDir, input.path);
      mkdirSync(dirname(resolved), { recursive: true });
      await context.sandbox.writeTextFile(resolved, input.content);
      return resolved;
    },
    formatInput(input) {
      return formatNamedContentInput(input);
    },
    legacyAdapter: {
      adapt(input) {
        return normalizeNamedContentInput("write-scratch", input);
      },
    } satisfies ToolInputAdapter<NamedContentInput>,
  };
}

export function createReadScratchTool(): ToolDefinition<PathInput> {
  return {
    description: "Read a text file inside the thread scratch directory.",
    effect: "read",
    executionClass: "read-only",
    idempotent: true,
    inputSchema: PATH_INPUT_SCHEMA,
    name: "read-scratch",
    source: "built-in",
    capabilities(input, context) {
      const resolved = resolve(context.threadPaths.scratchDir, input.path);
      return [
        {
          group: "filesystem",
          action: "read",
          path: resolved,
          roots: [context.threadPaths.scratchDir],
        },
      ];
    },
    async execute(input, context) {
      return readScratchFile(context, input.path);
    },
    formatInput(input) {
      return input.path;
    },
    legacyAdapter: {
      adapt(input, context) {
        const normalized = normalizePathInput("read-scratch", input, [
          "file",
          "pathname",
          "target",
        ]);
        const resolved = resolve(context.threadPaths.scratchDir, normalized.path);
        if (!context.sandbox.exists(resolved)) {
          throw new BestAgentError(
            "TOOL_INPUT_NOT_FOUND",
            `Path ${resolved} does not exist in scratch.`,
          );
        }
        if (context.sandbox.isDirectory(resolved)) {
          throw new BestAgentError(
            "TOOL_INPUT_NOT_FOUND",
            `Path ${resolved} is a directory. read-scratch requires a file path.`,
          );
        }
        return normalized;
      },
    } satisfies ToolInputAdapter<PathInput>,
    summarize(input) {
      return `Tool read-scratch read ${input.path}.`;
    },
  };
}

export function createAppendScratchTool(): ToolDefinition<NamedContentInput> {
  return {
    description:
      'Append text to a file inside the thread scratch directory. Input must include both path and content, for example {"path":"relative/path","content":"text to append"}.',
    effect: "write",
    executionClass: "workspace-mutation",
    idempotent: false,
    inputSchema: NAMED_CONTENT_INPUT_SCHEMA,
    name: "append-scratch",
    source: "built-in",
    capabilities(input, context) {
      const resolved = resolve(context.threadPaths.scratchDir, input.path);
      return [
        {
          group: "filesystem",
          action: "write",
          path: resolved,
          roots: [context.threadPaths.scratchDir],
        },
      ];
    },
    async execute(input, context) {
      const resolved = resolve(context.threadPaths.scratchDir, input.path);
      mkdirSync(dirname(resolved), { recursive: true });
      await context.sandbox.appendTextFile(resolved, input.content);
      return resolved;
    },
    formatInput(input) {
      return formatNamedContentInput(input);
    },
    legacyAdapter: {
      adapt(input) {
        return normalizeNamedContentInput("append-scratch", input);
      },
    } satisfies ToolInputAdapter<NamedContentInput>,
    summarize(input) {
      return `Tool append-scratch updated ${input.path}.`;
    },
  };
}

export function createWriteWorkspaceTool(): ToolDefinition<WorkspaceWriteInput> {
  return {
    description:
      'Create or overwrite one text file inside the active workspace. Input must be {"file_path":"relative/path","content":"full file content"}. Use edit for exact replacements and apply_patch for patch files.',
    effect: "write",
    executionClass: "workspace-mutation",
    idempotent: false,
    inputSchema: WORKSPACE_WRITE_INPUT_SCHEMA,
    name: "write-workspace",
    source: "built-in",
    capabilities(input, context) {
      const resolved = resolve(context.cwd, input.file_path);
      return [
        {
          group: "filesystem",
          action: "write",
          path: resolved,
          roots: [context.cwd],
        },
      ];
    },
    async execute(input, context) {
      const resolved = resolve(context.cwd, input.file_path);
      return context.withWorkspaceWriteLocks([resolved], async () => {
        context.assertWorkspaceMutationAllowed(resolved);
        mkdirSync(dirname(resolved), { recursive: true });
        await context.sandbox.writeTextFile(resolved, input.content);
        context.promoteWorkspaceWrite(resolved);
        return resolved;
      });
    },
    evidenceExtractor(input) {
      return [
        workspaceMutationEvidence("write-workspace", [input.file_path], {
          content_bytes: Buffer.byteLength(input.content, "utf8"),
        }),
      ];
    },
    formatInput(input) {
      return formatNamedContentInput({ path: input.file_path, content: input.content });
    },
    summarize(input) {
      return `Tool write-workspace wrote ${input.file_path}.`;
    },
  };
}

export function createEditTool(): ToolDefinition<EditInput> {
  return {
    description:
      'Edit one existing workspace text file by exact string replacement. Input must be {"file_path":"relative/path","old_string":"current text","new_string":"replacement text","replace_all":false}.',
    effect: "write",
    executionClass: "workspace-mutation",
    idempotent: false,
    inputSchema: EDIT_INPUT_SCHEMA,
    name: "edit",
    source: "built-in",
    capabilities(input, context) {
      const resolved = resolve(context.cwd, input.file_path);
      return [
        {
          group: "filesystem",
          action: "write",
          path: resolved,
          roots: [context.cwd],
        },
      ];
    },
    async execute(input, context) {
      const resolved = resolve(context.cwd, input.file_path);
      return context.withWorkspaceWriteLocks([resolved], async () => {
        context.assertWorkspaceMutationAllowed(resolved);
        const content = await context.sandbox.readTextFile(resolved);
        const nextContent = applyWorkspaceTextEdit(content, input.file_path, input);
        await context.sandbox.writeTextFile(resolved, nextContent);
        context.promoteWorkspaceWrite(resolved);
        return resolved;
      });
    },
    evidenceExtractor(input) {
      return [
        workspaceMutationEvidence("edit", [input.file_path], {
          new_string_bytes: Buffer.byteLength(input.new_string, "utf8"),
          old_string_bytes: Buffer.byteLength(input.old_string, "utf8"),
          replace_all: input.replace_all,
        }),
      ];
    },
    formatInput(input) {
      return `${input.file_path}|edit`;
    },
    summarize(input) {
      return `Tool edit updated ${input.file_path}.`;
    },
  };
}

export function createApplyPatchTool(): ToolDefinition<ApplyPatchInput> {
  return {
    description:
      'Apply one unified diff patch inside the active workspace. Input must be {"patch":"diff --git ..."} and the patch may add, update, move, or delete files.',
    effect: "write",
    executionClass: "workspace-mutation",
    idempotent: false,
    inputSchema: APPLY_PATCH_INPUT_SCHEMA,
    name: "apply_patch",
    source: "built-in",
    capabilities(input, context) {
      return parsePatchWorkspacePaths(input.patch).map((file_path) => ({
        group: "filesystem" as const,
        action: "write" as const,
        path: resolve(context.cwd, file_path),
        roots: [context.cwd],
      }));
    },
    async execute(input, context) {
      const file_paths = parsePatchWorkspacePaths(input.patch);
      const resolved_paths = file_paths.map((file_path) => resolve(context.cwd, file_path));
      return context.withWorkspaceWriteLocks(resolved_paths, async () => {
        for (const path of resolved_paths) {
          context.assertWorkspaceMutationAllowed(path);
        }
        const patch_path = resolve(
          context.threadPaths.scratchDir,
          `apply-patch-${randomUUID()}.patch`,
        );
        mkdirSync(dirname(patch_path), { recursive: true });
        await context.sandbox.writeTextFile(patch_path, input.patch);
        await context.sandbox.runMutableCommand(
          ["git", "apply", "--whitespace=nowarn", patch_path],
          context.cwd,
        );
        for (const path of resolved_paths) {
          context.promoteWorkspaceWrite(path);
        }
        return file_paths.join("\n");
      });
    },
    evidenceExtractor(input) {
      const file_paths = parsePatchWorkspacePaths(input.patch);
      return [
        workspaceMutationEvidence("apply_patch", file_paths, {
          patch_bytes: Buffer.byteLength(input.patch, "utf8"),
        }),
      ];
    },
    formatInput(input) {
      const file_paths = parsePatchWorkspacePaths(input.patch);
      return file_paths.length === 1
        ? `${file_paths[0]}|patch`
        : `${file_paths.length} files|patch`;
    },
    summarize(input) {
      const count = parsePatchWorkspacePaths(input.patch).length;
      return `Tool apply_patch updated ${count} file${count === 1 ? "" : "s"}.`;
    },
  };
}

// ── Helpers ─────────────────────────────────────────────────────────────

function workspaceMutationEvidence(
  operation: string,
  paths: string[],
  extraPayload: Record<string, unknown> = {},
): ToolEvidenceRecord {
  const uniquePaths = Array.from(new Set(paths));
  return {
    confidence: 0.9,
    kind: "artifact-fact",
    payload: {
      operation,
      paths: uniquePaths,
      ...extraPayload,
    },
    relatedFilePaths: uniquePaths,
    summary:
      uniquePaths.length === 1
        ? `${operation} updated ${uniquePaths[0]}.`
        : `${operation} updated ${uniquePaths.length} workspace files.`,
  };
}

async function readScratchFile(
  context: ToolExecutionContext,
  relativePath: string,
): Promise<string> {
  const path = resolve(context.threadPaths.scratchDir, relativePath);
  if (!context.sandbox.exists(path)) {
    throw new BestAgentError("TOOL_INPUT_NOT_FOUND", `Path ${path} does not exist in scratch.`);
  }
  if (context.sandbox.isDirectory(path)) {
    throw new BestAgentError(
      "TOOL_INPUT_NOT_FOUND",
      `Path ${path} is a directory. read-scratch requires a file path.`,
    );
  }

  const stats = context.sandbox.statFile(path);
  if (context.sandbox.looksLikeBinary(path)) {
    return `File: ${relativePath}\nBytes: ${stats.bytes}\nBinary file detected — cannot display as text. Use an appropriate tool or command to inspect this file.`;
  }
  if (stats.bytes > READ_FILE_PREVIEW_MAX_BYTES) {
    const preview = await context.sandbox.inspectTextFile(path, {
      headLines: READ_FILE_PREVIEW_HEAD_LINES,
      tailLines: READ_FILE_PREVIEW_TAIL_LINES,
      maxCharsPerLine: READ_FILE_LINE_PREVIEW_CHARS,
    });
    return formatLargeReadFileOutput(relativePath, preview);
  }

  const content = await context.sandbox.readTextFile(path);
  return formatReadFileOutput(relativePath, content, stats.bytes);
}

function applyWorkspaceTextEdit(content: string, path: string, input: EditInput): string {
  const occurrences = countOccurrences(content, input.old_string);
  if (occurrences === 0) {
    throw new BestAgentError(
      "TOOL_INPUT_INVALID",
      `edit could not find the target text in ${path}. Re-read the file and retry with the current contents.`,
    );
  }
  if (!input.replace_all && occurrences > 1) {
    throw new BestAgentError(
      "TOOL_INPUT_INVALID",
      `edit found multiple matches in ${path}. Use a more specific old_string or set replace_all to true.`,
    );
  }
  if (input.replace_all) {
    return content.split(input.old_string).join(input.new_string);
  }
  return content.replace(input.old_string, input.new_string);
}

function parsePatchWorkspacePaths(patch: string): string[] {
  const paths: string[] = [];
  let latestOldPath: string | undefined;
  for (const line of patch.split(/\r?\n/u)) {
    const gitDiffMatch = line.match(/^diff --git\s+(?:"?a\/(.+?)"?|\S+)\s+(?:"?b\/(.+?)"?|\S+)$/u);
    if (gitDiffMatch?.[1]) {
      pushPatchPath(paths, gitDiffMatch[1]);
    }
    if (gitDiffMatch?.[2]) {
      pushPatchPath(paths, gitDiffMatch[2]);
    }

    const oldMatch = line.match(/^---\s+(?:(?:a\/)?(.+)|\/dev\/null)$/u);
    if (oldMatch?.[1]) {
      latestOldPath = oldMatch[1];
      pushPatchPath(paths, oldMatch[1]);
      continue;
    }
    const newMatch = line.match(/^\+\+\+\s+(?:(?:b\/)?(.+)|\/dev\/null)$/u);
    if (newMatch?.[1]) {
      pushPatchPath(paths, newMatch[1]);
      continue;
    }
    if (line === "+++ /dev/null" && latestOldPath) {
      pushPatchPath(paths, latestOldPath);
    }

    const renameFromMatch = line.match(/^rename from\s+(.+)$/u);
    if (renameFromMatch?.[1]) {
      pushPatchPath(paths, renameFromMatch[1]);
    }
    const renameToMatch = line.match(/^rename to\s+(.+)$/u);
    if (renameToMatch?.[1]) {
      pushPatchPath(paths, renameToMatch[1]);
    }
  }
  const uniquePaths = Array.from(new Set(paths));
  if (uniquePaths.length === 0) {
    throw new BestAgentError("TOOL_INPUT_INVALID", "apply_patch requires a patch with file paths.");
  }
  return uniquePaths;
}

function pushPatchPath(paths: string[], rawPath: string): void {
  const path = rawPath.trim().replace(/^"|"$/gu, "");
  if (!path || path === "/dev/null") {
    return;
  }
  paths.push(path);
}

function countOccurrences(content: string, target: string): number {
  if (target.length === 0) {
    return 0;
  }

  let count = 0;
  let index = 0;
  while (index <= content.length) {
    const matchIndex = content.indexOf(target, index);
    if (matchIndex === -1) {
      break;
    }
    count += 1;
    index = matchIndex + target.length;
  }
  return count;
}
