import type { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import {
  existsSync,
  readFileSync,
  writeFileSync,
  mkdirSync,
  unlinkSync,
  rmSync,
  statSync,
  renameSync,
  readdirSync,
} from "node:fs";
import { resolve, dirname, join } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import { isAudioFile } from "../helpers/mime.js";
import { generateWaveformCache } from "../helpers/waveform.js";
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
import { isSafePath } from "../helpers/safePath.js";
import {
  removeElementFromHtml,
  patchElementInHtml,
  type PatchOperation,
} from "../helpers/sourceMutation.js";

// ── Shared helpers ──────────────────────────────────────────────────────────

/**
 * Resolve the project and file path from the request, validating safety.
 * Returns null (and sends an error response) if anything is invalid.
 */
interface RouteContext {
  req: {
    param: (name: string) => string;
    path: string;
    query: (name: string) => string | undefined;
  };
  json: (data: unknown, status?: number) => Response;
}

async function resolveProjectFile(
  c: RouteContext,
  adapter: StudioApiAdapter,
  opts?: { mustExist?: boolean },
) {
  const id = c.req.param("id");
  const project = await adapter.resolveProject(id);
  if (!project) {
    return { error: c.json({ error: "not found" }, 404) } as const;
  }

  const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
  if (filePath.includes("\0")) {
    return { error: c.json({ error: "forbidden" }, 403) } as const;
  }

  const absPath = resolve(project.dir, filePath);
  if (!isSafePath(project.dir, absPath)) {
    return { error: c.json({ error: "forbidden" }, 403) } as const;
  }

  if (opts?.mustExist && !existsSync(absPath)) {
    return { error: c.json({ error: "not found" }, 404) } as const;
  }

  return { project, filePath, absPath } as const;
}

/** Ensure the parent directory of a path exists. */
function ensureDir(filePath: string) {
  const dir = dirname(filePath);
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}

/**
 * Generate a copy name: foo.html → foo (copy).html → foo (copy 2).html
 */
function generateCopyPath(projectDir: string, originalPath: string): string {
  const ext = originalPath.includes(".") ? "." + originalPath.split(".").pop() : "";
  const base = ext ? originalPath.slice(0, -ext.length) : originalPath;

  // If already a copy, increment the number
  const copyMatch = base.match(/ \(copy(?: (\d+))?\)$/);
  const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base;
  let num = copyMatch ? (copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2) : 1;

  let candidate = num === 1 ? `${cleanBase} (copy)${ext}` : `${cleanBase} (copy ${num})${ext}`;
  while (existsSync(resolve(projectDir, candidate))) {
    num++;
    candidate = `${cleanBase} (copy ${num})${ext}`;
  }

  return candidate;
}

/**
 * Walk a directory recursively and return all file paths matching a filter.
 */
function walkFiles(dir: string, filter: (name: string) => boolean): string[] {
  const results: string[] = [];
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const full = join(dir, entry.name);
    if (entry.isDirectory()) {
      if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders")
        continue;
      results.push(...walkFiles(full, filter));
    } else if (filter(entry.name)) {
      results.push(full);
    }
  }
  return results;
}

/**
 * After a rename, update all references to the old path in project files.
 * Scans HTML, CSS, JS, and JSON files for the old filename/path and replaces.
 */
function updateReferences(projectDir: string, oldPath: string, newPath: string): number {
  const textFiles = walkFiles(projectDir, (name) =>
    /\.(html|css|js|jsx|ts|tsx|json|mjs|cjs|md|mdx)$/i.test(name),
  );

  let updatedCount = 0;
  for (const file of textFiles) {
    const content = readFileSync(file, "utf-8");

    // Only replace full relative paths — never bare filenames, which can
    // corrupt unrelated content (e.g. "logo.png" inside "my-logo.png").
    if (!content.includes(oldPath)) continue;

    const updated = content.split(oldPath).join(newPath);
    if (updated !== content) {
      writeFileSync(file, updated, "utf-8");
      updatedCount++;
    }
  }
  return updatedCount;
}

// ── Route registration ──────────────────────────────────────────────────────

export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
  // ── Read ──

  api.get("/projects/:id/files/*", async (c) => {
    const res = await resolveProjectFile(c, adapter);
    if ("error" in res) return res.error;

    if (!existsSync(res.absPath)) {
      if (c.req.query("optional") === "1") {
        return c.json({ filename: res.filePath, content: "" });
      }
      return c.json({ error: "not found" }, 404);
    }

    const content = readFileSync(res.absPath, "utf-8");
    return c.json({ filename: res.filePath, content });
  });

  // ── Write (overwrite) ──

  api.put("/projects/:id/files/*", async (c) => {
    const res = await resolveProjectFile(c, adapter);
    if ("error" in res) return res.error;

    ensureDir(res.absPath);
    const body = await c.req.text();
    writeFileSync(res.absPath, body, "utf-8");

    return c.json({ ok: true });
  });

  // ── Create (fail if exists) ──

  api.post("/projects/:id/files/*", async (c) => {
    const res = await resolveProjectFile(c, adapter);
    if ("error" in res) return res.error;

    if (existsSync(res.absPath)) {
      return c.json({ error: "already exists" }, 409);
    }

    ensureDir(res.absPath);
    const body = await c.req.text().catch(() => "");
    writeFileSync(res.absPath, body, "utf-8");

    return c.json({ ok: true, path: res.filePath }, 201);
  });

  // ── Delete ──

  api.delete("/projects/:id/files/*", async (c) => {
    const res = await resolveProjectFile(c, adapter, { mustExist: true });
    if ("error" in res) return res.error;

    const stat = statSync(res.absPath);
    if (stat.isDirectory()) {
      rmSync(res.absPath, { recursive: true });
    } else {
      unlinkSync(res.absPath);
    }

    return c.json({ ok: true });
  });

  api.post("/projects/:id/file-mutations/remove-element/*", async (c) => {
    const id = c.req.param("id");
    const project = await adapter.resolveProject(id);
    if (!project) return c.json({ error: "not found" }, 404);

    const filePath = decodeURIComponent(
      c.req.path.replace(`/projects/${project.id}/file-mutations/remove-element/`, ""),
    );
    if (filePath.includes("\0")) {
      return c.json({ error: "forbidden" }, 403);
    }

    const absPath = resolve(project.dir, filePath);
    if (!isSafePath(project.dir, absPath)) {
      return c.json({ error: "forbidden" }, 403);
    }
    if (!existsSync(absPath)) {
      return c.json({ error: "not found" }, 404);
    }

    const body = (await c.req.json().catch(() => null)) as {
      target?: { id?: string | null; selector?: string; selectorIndex?: number };
    } | null;
    if (!body?.target) {
      return c.json({ error: "target required" }, 400);
    }

    const originalContent = readFileSync(absPath, "utf-8");
    const patchedContent = removeElementFromHtml(originalContent, body.target);
    if (patchedContent === originalContent) {
      return c.json({ ok: true, changed: false, content: originalContent });
    }

    writeFileSync(absPath, patchedContent, "utf-8");
    return c.json({ ok: true, changed: true, content: patchedContent });
  });

  api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
    const id = c.req.param("id");
    const project = await adapter.resolveProject(id);
    if (!project) return c.json({ error: "not found" }, 404);

    const filePath = decodeURIComponent(
      c.req.path.replace(`/projects/${project.id}/file-mutations/patch-element/`, ""),
    );
    if (filePath.includes("\0")) {
      return c.json({ error: "forbidden" }, 403);
    }

    const absPath = resolve(project.dir, filePath);
    if (!isSafePath(project.dir, absPath)) {
      return c.json({ error: "forbidden" }, 403);
    }
    const body = (await c.req.json().catch(() => null)) as {
      target?: { id?: string | null; selector?: string; selectorIndex?: number };
      operations?: PatchOperation[];
    } | null;
    if (!body?.target || !Array.isArray(body.operations) || body.operations.length === 0) {
      return c.json({ error: "target and operations required" }, 400);
    }

    let originalContent: string;
    try {
      originalContent = readFileSync(absPath, "utf-8");
    } catch {
      return c.json({ error: "not found" }, 404);
    }
    const patchedContent = patchElementInHtml(originalContent, body.target, body.operations);
    if (patchedContent === originalContent) {
      return c.json({ ok: true, changed: false, content: originalContent });
    }

    writeFileSync(absPath, patchedContent, "utf-8");
    return c.json({ ok: true, changed: true, content: patchedContent });
  });

  // ── Rename / Move ──

  api.patch("/projects/:id/files/*", async (c) => {
    const res = await resolveProjectFile(c, adapter, { mustExist: true });
    if ("error" in res) return res.error;

    const body = (await c.req.json()) as { newPath?: string };
    if (!body.newPath || body.newPath.includes("\0")) {
      return c.json({ error: "newPath required" }, 400);
    }

    const newAbs = resolve(res.project.dir, body.newPath);
    if (!isSafePath(res.project.dir, newAbs)) {
      return c.json({ error: "forbidden" }, 403);
    }
    if (existsSync(newAbs)) {
      return c.json({ error: "already exists" }, 409);
    }

    ensureDir(newAbs);
    renameSync(res.absPath, newAbs);

    // Update references to the old path across all project files
    const updatedFiles = updateReferences(res.project.dir, res.filePath, body.newPath);

    return c.json({ ok: true, path: body.newPath, updatedReferences: updatedFiles });
  });

  // ── Duplicate ──

  api.post("/projects/:id/duplicate-file", async (c) => {
    const project = await adapter.resolveProject(c.req.param("id"));
    if (!project) return c.json({ error: "not found" }, 404);

    const body = (await c.req.json()) as { path: string };
    if (!body.path || body.path.includes("\0")) {
      return c.json({ error: "path required" }, 400);
    }

    const srcAbs = resolve(project.dir, body.path);
    if (!isSafePath(project.dir, srcAbs) || !existsSync(srcAbs)) {
      return c.json({ error: "not found" }, 404);
    }

    const copyPath = generateCopyPath(project.dir, body.path);
    const destAbs = resolve(project.dir, copyPath);
    if (!isSafePath(project.dir, destAbs)) {
      return c.json({ error: "forbidden" }, 403);
    }

    ensureDir(destAbs);
    writeFileSync(destAbs, readFileSync(srcAbs));

    return c.json({ ok: true, path: copyPath }, 201);
  });

  // ── Upload (binary assets via multipart form) ──

  const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB per file

  api.post(
    "/projects/:id/upload",
    bodyLimit({
      maxSize: MAX_UPLOAD_BYTES,
      onError: (c) => c.json({ error: "payload too large" }, 413),
    }),
    async (c) => {
      const project = await adapter.resolveProject(c.req.param("id"));
      if (!project) return c.json({ error: "not found" }, 404);

      // Optional subdirectory within the project (e.g. "assets/audio")
      const subDir = c.req.query("dir") ?? "";
      const targetDir = subDir ? resolve(project.dir, subDir) : project.dir;
      if (!isSafePath(project.dir, targetDir)) return c.json({ error: "forbidden" }, 403);
      if (subDir && !existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });

      const formData = await c.req.formData();
      const uploaded: string[] = [];
      const skipped: string[] = [];
      const invalid: Array<{ name: string; reason: string }> = [];

      // @types/node v25 narrows the ambient `FormData.entries()` to
      // `[string, string]` in workspaces where another dep declares an
      // `onmessage` global (it trips the worker branch of v25's conditional
      // File type). At runtime the value is still `File | string` — cast the
      // iterator so the rest of this block keeps type-checking on every
      // bun-install layout (hoisted on Windows surfaces this; isolated on
      // Linux happens to keep v24 in scope).
      type FileLike = {
        readonly name: string;
        readonly size: number;
        arrayBuffer(): Promise<ArrayBuffer>;
      };
      const entries = formData.entries() as unknown as Iterable<[string, FileLike | string]>;
      for (const [, value] of entries) {
        if (typeof value === "string") continue;

        // Strip path separators — browsers may include directory components
        const name = value.name.split("/").pop()?.split("\\").pop() ?? "";
        if (!name || name.includes("\0") || name.includes("..")) continue;

        // Reject individual files that exceed the size limit
        if (value.size > MAX_UPLOAD_BYTES) {
          skipped.push(name);
          continue;
        }

        const destPath = resolve(targetDir, name);
        if (!isSafePath(project.dir, destPath)) continue;

        // Don't overwrite — append (2), (3), etc.
        let finalPath = destPath;
        let finalName = name;
        if (existsSync(finalPath)) {
          // Handle dotfiles correctly: .gitignore → ext="", base=".gitignore"
          const dotIdx = name.indexOf(".", name.startsWith(".") ? 1 : 0);
          const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
          const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
          let n = 2;
          while (n < 10000 && existsSync(resolve(targetDir, `${base} (${n})${ext}`))) n++;
          if (n >= 10000) {
            skipped.push(name);
            continue;
          }
          finalName = `${base} (${n})${ext}`;
          finalPath = resolve(targetDir, finalName);
        }

        const buffer = Buffer.from(await value.arrayBuffer());
        const validation = validateUploadedMediaBuffer(finalName, buffer);
        if (!validation.ok) {
          invalid.push({ name: finalName, reason: validation.reason });
          continue;
        }
        writeFileSync(finalPath, buffer);
        const relativePath = subDir ? join(subDir, finalName) : finalName;
        uploaded.push(relativePath);
        if (isAudioFile(finalName)) {
          generateWaveformCache(project.dir, relativePath).catch(() => {});
        }
      }

      return c.json({ ok: true, files: uploaded, skipped, invalid }, 201);
    },
  );
}
