import { access, constants, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";

import { findTaskPathById } from "../lifecycle/taskPathUtils.ts";
import { invokeGeminiWebResearchTool } from "./geminiWebResearchTools.ts";
import { invokeGeminiSkillEvolutionTool } from "./geminiSkillEvolutionTools.ts";

/** Max bytes read in one call (512 KiB). */
export const GEMINI_READ_FILE_MAX_BYTES = 512 * 1024;

/** Max bytes written in one call (256 KiB). */
export const GEMINI_WRITE_FILE_MAX_BYTES = 256 * 1024;

/** Max lines returned when offset/limit omitted (safety). */
export const GEMINI_READ_FILE_MAX_LINES = 2_000;

/** Max grep matches returned in one call. */
export const GEMINI_GREP_MAX_MATCHES = 100;

/** Max bytes per file scanned by grep_files (512 KiB). */
export const GEMINI_GREP_MAX_FILE_BYTES = GEMINI_READ_FILE_MAX_BYTES;

/** Directory names skipped during grep_files walk. */
export const GEMINI_GREP_SKIP_DIR_NAMES = new Set([
  "node_modules",
  ".git",
  "__pycache__",
  "dist",
  "build",
  ".turbo",
  "coverage",
]);

/** Relative path prefixes allowed for write_file (governance / PM config only). */
export const GEMINI_WRITE_ALLOWED_REL_PREFIXES = [
  ".codeflowmu/",
  "docs/skills/",
  "fcop/internal/product-briefs/",
] as const;

export const GEMINI_WORKSPACE_TOOL_NAMES = [
  "read_file",
  "grep_files",
  "list_dir",
  "write_file",
  "web_search",
  "web_extract",
  "web_research",
  "skill_search",
  "skill_learn",
  "skill_publish",
] as const;

export type GeminiWorkspaceToolName = (typeof GEMINI_WORKSPACE_TOOL_NAMES)[number];

export function isGeminiWorkspaceTool(name: string): name is GeminiWorkspaceToolName {
  return (GEMINI_WORKSPACE_TOOL_NAMES as readonly string[]).includes(name);
}

/** Resolve user path under projectRoot; reject directory escape. */
export function resolveWorkspacePath(projectRoot: string, inputPath: string): string {
  const root = path.resolve(projectRoot);
  const raw = (inputPath ?? "").trim() || ".";
  const target = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(root, raw);
  const rel = path.relative(root, target);
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
    throw new Error(`Path escapes project root: ${inputPath}`);
  }
  return target;
}

/** Normalize to forward-slash relative path under project root. */
export function workspaceRelPath(projectRoot: string, absPath: string): string {
  const root = path.resolve(projectRoot);
  const rel = path.relative(root, path.resolve(absPath)).replace(/\\/g, "/");
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
    throw new Error(`Path escapes project root: ${absPath}`);
  }
  return rel;
}

/** FCoP IPC envelope paths — must not be written via workspace write_file. */
export const GEMINI_FCOP_ENVELOPE_REL_PREFIXES = [
  "fcop/_lifecycle/",
  "fcop/reports/",
  "fcop/issues/",
  "fcop/tasks/",
  "fcop/log/",
] as const;

export function isFcopEnvelopeRelPath(relPath: string): boolean {
  const norm = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
  return GEMINI_FCOP_ENVELOPE_REL_PREFIXES.some(
    (prefix) => norm === prefix.slice(0, -1) || norm.startsWith(prefix),
  );
}

export function isWriteAllowedRelPath(relPath: string): boolean {
  const norm = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
  if (isFcopEnvelopeRelPath(norm)) {
    return false;
  }
  return GEMINI_WRITE_ALLOWED_REL_PREFIXES.some(
    (prefix) => norm === prefix.slice(0, -1) || norm.startsWith(prefix),
  );
}

export function assertWriteAllowedPath(projectRoot: string, inputPath: string): string {
  const abs = resolveWorkspacePath(projectRoot, inputPath);
  const rel = workspaceRelPath(projectRoot, abs);
  if (!isWriteAllowedRelPath(rel)) {
    throw new Error(
      `write_file path not allowed: ${rel}. Allowed prefixes: ${GEMINI_WRITE_ALLOWED_REL_PREFIXES.join(", ")}`,
    );
  }
  return abs;
}

function looksBinary(buf: Buffer): boolean {
  const sample = buf.subarray(0, Math.min(buf.length, 8192));
  return sample.includes(0);
}

function formatLineNumbered(content: string, offset: number, limit?: number): string {
  const lines = content.split(/\r?\n/);
  const startIdx = Math.max(0, offset - 1);
  const endIdx =
    limit != null && limit > 0
      ? Math.min(lines.length, startIdx + limit)
      : Math.min(lines.length, startIdx + GEMINI_READ_FILE_MAX_LINES);
  const slice = lines.slice(startIdx, endIdx);
  if (startIdx >= lines.length) {
    return `(empty: offset ${offset} beyond file length ${lines.length})`;
  }
  const width = String(endIdx).length;
  return slice
    .map((line, i) => `${String(startIdx + i + 1).padStart(width, " ")}|${line}`)
    .join("\n");
}

const TASK_ID_IN_PATH_RE = /TASK-\d{8}-\d+/i;

/** Resolve read path; TASK-* basenames fall back to fcop/_lifecycle search. */
export async function resolveReadFileTarget(
  projectRoot: string,
  filePath: string,
): Promise<string> {
  const root = path.resolve(projectRoot);
  let abs: string;
  try {
    abs = resolveWorkspacePath(root, filePath);
    await access(abs, constants.F_OK);
    return abs;
  } catch {
    const taskIdMatch =
      path.basename(filePath).match(TASK_ID_IN_PATH_RE) ??
      filePath.match(TASK_ID_IN_PATH_RE);
    if (!taskIdMatch) {
      throw new Error(
        `File not found: ${filePath}. TASK files live under fcop/_lifecycle/{inbox,active,review,done,archive}/ — use the full relative path or task_id prefix.`,
      );
    }
    const lifecycleRoot = path.join(root, "fcop", "_lifecycle");
    const found = await findTaskPathById(lifecycleRoot, taskIdMatch[0]);
    if (!found) {
      throw new Error(
        `File not found: ${filePath} (no TASK matching ${taskIdMatch[0]} under ${workspaceRelPath(root, lifecycleRoot)}/).`,
      );
    }
    return found.path;
  }
}

async function handleReadFile(
  projectRoot: string,
  args: Record<string, unknown>,
): Promise<string> {
  const filePath = String(args.path ?? "").trim();
  if (!filePath) {
    throw new Error("read_file requires path");
  }
  const abs = await resolveReadFileTarget(projectRoot, filePath);
  const st = await stat(abs);
  if (!st.isFile()) {
    throw new Error(`Not a file: ${filePath}`);
  }
  if (st.size > GEMINI_READ_FILE_MAX_BYTES) {
    throw new Error(
      `File too large (${st.size} bytes, max ${GEMINI_READ_FILE_MAX_BYTES}). Use offset/limit or a narrower path.`,
    );
  }
  const buf = await readFile(abs);
  if (looksBinary(buf)) {
    throw new Error(`Binary file not supported: ${filePath}`);
  }
  const text = buf.toString("utf8");
  const offset =
    args.offset != null && args.offset !== ""
      ? Math.max(1, Number(args.offset) || 1)
      : 1;
  const limit =
    args.limit != null && args.limit !== ""
      ? Math.max(1, Number(args.limit) || GEMINI_READ_FILE_MAX_LINES)
      : undefined;
  const body = formatLineNumbered(text, offset, limit);
  const rel = workspaceRelPath(projectRoot, abs);
  return `${rel}\n${body}`;
}

async function handleWriteFile(
  projectRoot: string,
  args: Record<string, unknown>,
): Promise<string> {
  const filePath = String(args.path ?? "").trim();
  if (!filePath) {
    throw new Error("write_file requires path");
  }
  if (args.content == null) {
    throw new Error("write_file requires content");
  }
  const content = String(args.content);
  const buf = Buffer.from(content, "utf8");
  if (buf.length > GEMINI_WRITE_FILE_MAX_BYTES) {
    throw new Error(
      `Content too large (${buf.length} bytes, max ${GEMINI_WRITE_FILE_MAX_BYTES})`,
    );
  }
  if (buf.includes(0)) {
    throw new Error("Binary content not supported");
  }
  const abs = assertWriteAllowedPath(projectRoot, filePath);
  await mkdir(path.dirname(abs), { recursive: true });
  await writeFile(abs, content, "utf8");
  const rel = workspaceRelPath(projectRoot, abs);
  return `Wrote ${buf.length} bytes to ${rel}`;
}

async function handleListDir(
  projectRoot: string,
  args: Record<string, unknown>,
): Promise<string> {
  const dirPath = String(args.path ?? ".").trim() || ".";
  const abs = resolveWorkspacePath(projectRoot, dirPath);
  const st = await stat(abs);
  if (!st.isDirectory()) {
    throw new Error(`Not a directory: ${dirPath}`);
  }
  const entries = await readdir(abs, { withFileTypes: true });
  entries.sort((a, b) => a.name.localeCompare(b.name));
  const lines = entries.map((e) => {
    const suffix = e.isDirectory() ? "/" : e.isSymbolicLink() ? "@" : "";
    return `${e.name}${suffix}`;
  });
  return lines.length > 0 ? lines.join("\n") : "(empty directory)";
}

function pathMatchesGlob(relPath: string, glob?: string): boolean {
  if (!glob?.trim()) {
    return true;
  }
  const g = glob.trim().replace(/^\*\./, ".").replace(/^\*\*\//, "");
  if (glob.includes("*")) {
    const suffix = glob.replace(/^\*+/, "");
    return relPath.endsWith(suffix) || relPath.includes(suffix.replace(/^\./, ""));
  }
  return relPath.includes(g) || relPath.endsWith(g);
}

async function collectGrepCandidateFiles(
  projectRoot: string,
  searchAbs: string,
  glob?: string,
  maxFiles = 2_000,
): Promise<string[]> {
  const root = path.resolve(projectRoot);
  const files: string[] = [];
  const queue: string[] = [searchAbs];

  while (queue.length > 0 && files.length < maxFiles) {
    const dir = queue.pop()!;
    let entries;
    try {
      entries = await readdir(dir, { withFileTypes: true });
    } catch {
      continue;
    }
    for (const entry of entries) {
      if (files.length >= maxFiles) {
        break;
      }
      const abs = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        if (GEMINI_GREP_SKIP_DIR_NAMES.has(entry.name)) {
          continue;
        }
        queue.push(abs);
        continue;
      }
      if (!entry.isFile()) {
        continue;
      }
      const rel = workspaceRelPath(root, abs);
      if (!pathMatchesGlob(rel, glob)) {
        continue;
      }
      files.push(abs);
    }
  }
  return files;
}

async function handleGrepFiles(
  projectRoot: string,
  args: Record<string, unknown>,
): Promise<string> {
  const pattern = String(args.pattern ?? "").trim();
  if (!pattern) {
    throw new Error("grep_files requires pattern");
  }
  const searchPath = String(args.path ?? ".").trim() || ".";
  const glob =
    args.glob != null && String(args.glob).trim()
      ? String(args.glob).trim()
      : undefined;
  const caseInsensitive = args.case_insensitive === true || args.case_insensitive === "true";
  const maxResults = Math.min(
    Math.max(1, Number(args.max_results) || 50),
    GEMINI_GREP_MAX_MATCHES,
  );

  let re: RegExp;
  try {
    re = new RegExp(pattern, caseInsensitive ? "i" : "");
  } catch {
    throw new Error(`Invalid regex pattern: ${pattern}`);
  }

  const root = path.resolve(projectRoot);
  const searchAbs = resolveWorkspacePath(root, searchPath);
  const searchSt = await stat(searchAbs);

  const candidateFiles: string[] = [];
  if (searchSt.isFile()) {
    candidateFiles.push(searchAbs);
  } else if (searchSt.isDirectory()) {
    candidateFiles.push(...(await collectGrepCandidateFiles(root, searchAbs, glob)));
  } else {
    throw new Error(`Not a file or directory: ${searchPath}`);
  }

  const matches: string[] = [];
  for (const abs of candidateFiles) {
    if (matches.length >= maxResults) {
      break;
    }
    let st;
    try {
      st = await stat(abs);
    } catch {
      continue;
    }
    if (!st.isFile() || st.size > GEMINI_GREP_MAX_FILE_BYTES) {
      continue;
    }
    let buf: Buffer;
    try {
      buf = await readFile(abs);
    } catch {
      continue;
    }
    if (looksBinary(buf)) {
      continue;
    }
    const rel = workspaceRelPath(root, abs);
    const lines = buf.toString("utf8").split(/\r?\n/);
    for (let i = 0; i < lines.length && matches.length < maxResults; i++) {
      const line = lines[i] ?? "";
      if (re.test(line)) {
        matches.push(`${rel}:${i + 1}:${line}`);
      }
    }
  }

  if (matches.length === 0) {
    return `(no matches for /${pattern}/${glob ? ` glob=${glob}` : ""} under ${searchPath})`;
  }
  const truncated = matches.length >= maxResults;
  const header = truncated
    ? `Showing first ${maxResults} matches (limit reached):\n`
    : `${matches.length} match(es):\n`;
  return header + matches.join("\n");
}

export async function invokeGeminiWorkspaceTool(
  projectRoot: string,
  toolName: GeminiWorkspaceToolName,
  args: Record<string, unknown>,
): Promise<string> {
  const root = path.resolve(projectRoot);
  switch (toolName) {
    case "read_file":
      return handleReadFile(root, args);
    case "grep_files":
      return handleGrepFiles(root, args);
    case "list_dir":
      return handleListDir(root, args);
    case "write_file":
      return handleWriteFile(root, args);
    case "web_search":
    case "web_extract":
    case "web_research":
      return invokeGeminiWebResearchTool(root, toolName, args);
    case "skill_search":
    case "skill_learn":
    case "skill_publish":
      return invokeGeminiSkillEvolutionTool(root, toolName, args);
    default:
      throw new Error(`Unknown workspace tool: ${toolName}`);
  }
}

/** Gemini function declarations — always available on Google channel (not FCoP MCP). */
export function buildGeminiWorkspaceToolDeclarations(options?: {
  /** PM/leader only — governance paths are allowlisted at invoke time. */
  includeWriteFile?: boolean;
  includeListDir?: boolean;
}): Array<{
  name: string;
  description: string;
  parameters: {
    type: string;
    properties: Record<string, unknown>;
    required?: string[];
  };
}> {
  const decls: Array<{
    name: string;
    description: string;
    parameters: {
      type: string;
      properties: Record<string, unknown>;
      required?: string[];
    };
  }> = [
    {
      name: "read_file",
      description:
        "Read a UTF-8 text file under the project workspace (relative or absolute path under project root). " +
        "Use for skills/*/SKILL.md, agent-skills manifest, fcop ledger files, source, and docs. " +
        "Returns line-numbered content.",
      parameters: {
        type: "OBJECT",
        properties: {
          path: {
            type: "STRING",
            description: "File path relative to project root, or absolute path within project",
          },
          offset: {
            type: "INTEGER",
            description: "Optional 1-based start line (default 1)",
          },
          limit: {
            type: "INTEGER",
            description: "Optional max lines to return",
          },
        },
        required: ["path"],
      },
    },
    {
      name: "grep_files",
      description:
        "Search UTF-8 text files under the project workspace with a JavaScript regex pattern. " +
        "Returns path:line:content lines (like ripgrep). Skips node_modules/.git and files over 512 KiB. " +
        "Use before read_file to locate symbols, TASK ids, or config keys.",
      parameters: {
        type: "OBJECT",
        properties: {
          pattern: {
            type: "STRING",
            description: "JavaScript regex pattern (not wrapped in slashes)",
          },
          path: {
            type: "STRING",
            description: "File or directory relative to project root (default .)",
          },
          glob: {
            type: "STRING",
            description: "Optional path filter, e.g. *.ts or .md",
          },
          case_insensitive: {
            type: "BOOLEAN",
            description: "When true, regex uses flag i",
          },
          max_results: {
            type: "INTEGER",
            description: `Max matches to return (default 50, max ${GEMINI_GREP_MAX_MATCHES})`,
          },
        },
        required: ["pattern"],
      },
    },
    ...(options?.includeListDir === false ? [] : [{
      name: "list_dir",
      description:
        "List entries in a directory under the project workspace. Directories are suffixed with /. " +
        "Use to explore skills/, fcop/, docs/, and repo layout before read_file.",
      parameters: {
        type: "OBJECT",
        properties: {
          path: {
            type: "STRING",
            description: "Directory path relative to project root (default .)",
          },
        },
      },
    }]),
    {
      name: "web_search",
      description:
        "Search the public web with a real search engine. Returns ranked results with title, snippet, and source_url. Use it to discover and select sources before extraction.",
      parameters: {
        type: "OBJECT",
        properties: {
          query: { type: "STRING", description: "Focused web search query" },
          limit: { type: "INTEGER", description: "Result count, default 5 and max 10" },
          domains: {
            type: "ARRAY",
            items: { type: "STRING" },
            description: "Optional preferred domains, converted to site: filters",
          },
        },
        required: ["query"],
      },
    },
    {
      name: "web_extract",
      description:
        "Open a public webpage and extract title, readable body text, HTML tables, and source_url. Set dynamic=true only for JavaScript-rendered pages; Playwright is used underneath.",
      parameters: {
        type: "OBJECT",
        properties: {
          url: { type: "STRING", description: "Public http/https source URL" },
          dynamic: { type: "BOOLEAN", description: "Use Playwright for JavaScript-rendered pages" },
          wait_for_selector: { type: "STRING", description: "Wait until this CSS selector is visible" },
          wait_for_text: { type: "STRING", description: "Wait until matching page text is visible" },
          click_texts: { type: "ARRAY", items: { type: "STRING" }, description: "Visible texts to click before extraction" },
          auto_scroll: { type: "BOOLEAN", description: "Scroll through the page to trigger lazy loading" },
          settle_ms: { type: "INTEGER", description: "Short post-interaction settle delay, default 500ms" },
          item_selector: { type: "STRING", description: "Optional repeated-item CSS selector for structured_items" },
          field_selectors: { type: "OBJECT", description: "Map output field names to CSS selectors relative to each item" },
          screenshot_path: { type: "STRING", description: "Optional screenshot path under project root" },
          required_texts: { type: "ARRAY", items: { type: "STRING" }, description: "Texts that must exist for quality acceptance" },
          min_content_chars: { type: "INTEGER", description: "Minimum extracted text length, default 200" },
          min_tables: { type: "INTEGER", description: "Minimum extracted HTML table count" },
          table_contains: { type: "ARRAY", items: { type: "STRING" }, description: "Keep only tables containing all listed terms" },
          max_tables: { type: "INTEGER", description: "Maximum tables returned, max 100" },
          max_table_rows: { type: "INTEGER", description: "Maximum rows returned per table, max 2000" },
          min_structured_items: { type: "INTEGER", description: "Minimum repeated structured item count" },
          fail_on_quality: { type: "BOOLEAN", description: "Fail the tool call when any quality requirement is unmet" },
        },
        required: ["url"],
      },
    },
    {
      name: "web_research",
      description:
        "Run an end-to-end sourced research pass: search, deduplicate sources, extract readable text/tables, preserve source_url, and return a Markdown evidence report for model synthesis.",
      parameters: {
        type: "OBJECT",
        properties: {
          question: { type: "STRING", description: "Research question to answer" },
          queries: {
            type: "ARRAY",
            items: { type: "STRING" },
            description: "Optional focused queries; defaults to the question",
          },
          max_sources: { type: "INTEGER", description: "Maximum sources to extract, default 5 and max 8" },
          dynamic: { type: "BOOLEAN", description: "Use Playwright for every selected source" },
        },
        required: ["question"],
      },
    },
    {
      name: "skill_search",
      description: "Discover reusable skills and capability sources across the local skill library, GitHub repositories, and the public web.",
      parameters: {
        type: "OBJECT",
        properties: {
          query: { type: "STRING", description: "Capability or workflow to discover" },
          providers: { type: "ARRAY", items: { type: "STRING", enum: ["local", "github", "web"] }, description: "Providers to search; defaults to all" },
          limit: { type: "INTEGER", description: "Maximum results per remote provider, default 5 and max 10" },
        },
        required: ["query"],
      },
    },
    {
      name: "skill_learn",
      description: "Read selected local skills or web/GitHub sources and return an evidence pack plus a mandatory minimal-validation contract.",
      parameters: {
        type: "OBJECT",
        properties: {
          capability: { type: "STRING", description: "Capability being learned" },
          local_skill_ids: { type: "ARRAY", items: { type: "STRING" }, description: "Existing local skill ids to inspect" },
          source_urls: { type: "ARRAY", items: { type: "STRING" }, description: "Official, GitHub, or web sources to inspect" },
        },
        required: ["capability"],
      },
    },
    {
      name: "skill_publish",
      description: "Publish a validated reusable skill to skills/{id}/SKILL.md and both skill manifests. Requires validation evidence and source URLs; existing skills are protected by default.",
      parameters: {
        type: "OBJECT",
        properties: {
          skill_id: { type: "STRING", description: "Lowercase kebab-case skill id" },
          display_name: { type: "STRING", description: "Human-readable skill name" },
          description: { type: "STRING", description: "Precise trigger-oriented skill description" },
          body_markdown: { type: "STRING", description: "Reusable workflow, output contract, and guardrails" },
          validated: { type: "BOOLEAN", description: "Must be true after a real minimal experiment" },
          validation_evidence: { type: "ARRAY", items: { type: "STRING" }, description: "Commands, outputs, tests, or artifacts proving the skill works" },
          source_urls: { type: "ARRAY", items: { type: "STRING" }, description: "Sources used to learn the skill" },
          update_existing: { type: "BOOLEAN", description: "Explicitly allow replacing an existing skill" },
        },
        required: ["skill_id", "description", "body_markdown", "validated", "validation_evidence", "source_urls"],
      },
    },
  ];

  if (options?.includeWriteFile) {
    decls.push({
      name: "write_file",
      description:
        "Write UTF-8 text to an allowlisted governance path under the project root. " +
        "Allowed: .codeflowmu/**, docs/skills/**, and fcop/internal/product-briefs/**. " +
        "Use for PM/runtime config updates — not for source code or fcop TASK/REPORT envelopes.",
      parameters: {
        type: "OBJECT",
        properties: {
          path: {
            type: "STRING",
            description: "Relative path under project root (must match allowlist)",
          },
          content: {
            type: "STRING",
            description: "Full file contents to write (UTF-8)",
          },
        },
        required: ["path", "content"],
      },
    });
  }

  return decls;
}
