import { createHash } from "node:crypto";
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
import { parse, serialize } from "parse5";

const MAX_ARTIFACT_BYTES = 25 * 1024 * 1024;
const MIME_TYPES = new Map([
  [".css", "text/css"],
  [".csv", "text/csv"],
  [".gif", "image/gif"],
  [".htm", "text/html"],
  [".html", "text/html"],
  [".jpeg", "image/jpeg"],
  [".jpg", "image/jpeg"],
  [".js", "text/javascript"],
  [".jsx", "text/plain"],
  [".json", "application/json"],
  [".md", "text/markdown"],
  [".pdf", "application/pdf"],
  [".png", "image/png"],
  [".svg", "image/svg+xml"],
  [".txt", "text/plain"],
  [".webp", "image/webp"],
  [".zip", "application/zip"]
]);

export function projectWorkspaceArtifacts({ text, session, workspacePath, dataDir, registrationCache }) {
  if (typeof text !== "string" || !session?.id || !workspacePath || !dataDir) return text;
  return transformOutsideMarkdownCode(text, (chunk) => {
    let projected = chunk.replace(/::codex-inline-vis\{\s*file=(?:"([^"]+)"|'([^']+)')\s*\}/g, (directive, doubleQuoted, singleQuoted) => {
      const artifact = registerArtifact(doubleQuoted ?? singleQuoted, { session, workspacePath, dataDir, htmlOnly: true, registrationCache });
      const url = artifact ? artifactResourceUrl(session.id, artifact.id, artifact.resourcePath) : undefined;
      return artifact ? `::codex-inline-artifact{id="${artifact.id}" name="${escapeDirectiveValue(artifact.name)}" url="${url}"}` : directive;
    });
    projected = projected.replace(/(!?)\[([^\]]*)\]\((?:<([^>]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\)/g, (markdown, imageMarker, label, wrappedTarget, plainTarget) => {
      const target = wrappedTarget ?? plainTarget;
      const { path, line } = splitCodeLocation(target);
      const artifact = registerArtifact(path, { session, workspacePath, dataDir, imageOnly: Boolean(imageMarker), registrationCache });
      if (!artifact) return markdown;
      const query = artifactQuery(artifact, line);
      return `${imageMarker}[${label}](/api/sessions/${encodeURIComponent(session.id)}/artifacts/${artifact.id}?${query})`;
    });
    return projected;
  });
}

function transformOutsideMarkdownCode(text, transform) {
  let fence;
  return text.split(/(\r?\n)/).map((line) => {
    if (/^\r?\n$/.test(line)) return line;
    const marker = line.match(/^\s*(`{3,}|~{3,})/i)?.[1];
    if (fence) {
      if (marker?.[0] === fence.character && marker.length >= fence.length) fence = undefined;
      return line;
    }
    if (marker) {
      fence = { character: marker[0], length: marker.length };
      return line;
    }
    if (/^(?: {4}|\t)/.test(line)) return line;
    return transformOutsideInlineCode(line, transform);
  }).join("");
}

function transformOutsideInlineCode(line, transform) {
  let result = "";
  let cursor = 0;
  const ticks = /`+/g;
  while (true) {
    ticks.lastIndex = cursor;
    const opening = ticks.exec(line);
    if (!opening) return result + transform(line.slice(cursor));
    const closingIndex = line.indexOf(opening[0], opening.index + opening[0].length);
    if (closingIndex < 0) return result + transform(line.slice(cursor));
    result += transform(line.slice(cursor, opening.index));
    const end = closingIndex + opening[0].length;
    result += line.slice(opening.index, end);
    cursor = end;
  }
}

export async function getWorkspaceArtifact({ session, artifactId, resourcePath, dataDir }) {
  const artifact = (session?.attachments ?? []).find((item) => item.id === artifactId && item.kind === "artifact");
  if (!artifact || !dataDir) throw artifactNotFound();
  const bundleRoot = join(dataDir, "artifacts", session.id, artifact.id);
  const requested = resourcePath ? decodeURIComponent(resourcePath) : artifact.resourcePath ?? safeName(artifact.name);
  const path = resolve(bundleRoot, requested);
  const inside = relative(bundleRoot, path);
  if (!inside || inside.startsWith("..") || isAbsolute(inside) || !existsSync(path)) throw artifactNotFound();
  const stats = statSync(path);
  if (!stats.isFile() || stats.size > MAX_ARTIFACT_BYTES) throw artifactNotFound();
  const name = safeName(basename(path));
  const mimeType = detectedMimeType(path, extname(name).toLowerCase());
  return { data: await readFile(path), path, name, mimeType, size: stats.size };
}

function registerArtifact(target, { session, workspacePath, dataDir, htmlOnly = false, imageOnly = false, registrationCache }) {
  const cacheKey = `${htmlOnly ? "html" : imageOnly ? "image" : "file"}:${target}`;
  if (registrationCache?.has(cacheKey)) return registrationCache.get(cacheKey);
  const source = workspaceFile(target, workspacePath);
  if (!source || (htmlOnly && source.mimeType !== "text/html") || (imageOnly && !source.mimeType.startsWith("image/"))) {
    registrationCache?.set(cacheKey, null);
    return null;
  }
  const entryContents = readFileSync(source.path);
  const resources = source.mimeType === "text/html"
    ? collectHtmlResources(source.path, source.relativePath, workspacePath)
    : [];
  const identity = createHash("sha256").update(source.relativePath).update("\0").update(entryContents);
  for (const resource of resources) identity.update("\0").update(resource.relativePath).update("\0").update(resource.contents);
  const contentsHash = identity.digest("hex").slice(0, 24);
  const id = `artifact-${contentsHash}`;
  const storedPath = join(dataDir, "artifacts", session.id, id, source.relativePath);
  if (!existsSync(storedPath)) {
    mkdirSync(dirname(storedPath), { recursive: true });
    if (source.mimeType === "text/html") {
      writeFileSync(storedPath, sanitizeHtmlPreview(entryContents.toString("utf8")));
      snapshotHtmlResources(resources, join(dataDir, "artifacts", session.id, id));
    } else {
      copyFileSync(source.path, storedPath);
    }
  }
  let artifact = (session.attachments ?? []).find((item) => item.id === id && item.kind === "artifact");
  if (!artifact) {
    artifact = {
      id,
      kind: "artifact",
      name: source.name,
      resourcePath: source.relativePath,
      mimeType: source.mimeType,
      size: source.size,
      path: storedPath,
      createdAt: new Date().toISOString()
    };
    session.attachments = [...(session.attachments ?? []), artifact];
  }
  registrationCache?.set(cacheKey, artifact);
  return artifact;
}

function collectHtmlResources(entryPath, entryRelativePath, workspacePath) {
  const sourceRoot = realpathSync(workspacePath);
  const entryParent = dirname(entryRelativePath);
  const queue = referencedPaths(readFileSync(entryPath, "utf8"), extname(entryPath))
    .map((item) => entryParent === "." ? item : `${entryParent}/${item}`);
  const collected = new Map();
  let totalBytes = 0;
  while (queue.length && collected.size < 64) {
    const reference = queue.shift();
    if (!reference) continue;
    const source = safeReferencedFile(sourceRoot, reference);
    if (!source || collected.has(source.relativePath)) continue;
    totalBytes += source.size;
    if (totalBytes > MAX_ARTIFACT_BYTES) break;
    source.contents = readFileSync(source.path);
    collected.set(source.relativePath, source);
    if ([".css", ".js", ".mjs"].includes(source.extension)) {
      const nested = referencedPaths(source.contents.toString("utf8"), source.extension);
      const parent = dirname(source.relativePath);
      for (const item of nested) queue.push(parent === "." ? item : `${parent}/${item}`);
    }
  }
  return [...collected.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
}

function snapshotHtmlResources(resources, bundleRoot) {
  for (const source of resources) {
    const destination = join(bundleRoot, source.relativePath);
    mkdirSync(dirname(destination), { recursive: true });
    writeFileSync(destination, source.contents);
  }
}

const PREVIEW_ELEMENTS = new Set([
  "html", "head", "title", "body", "style", "link", "main", "header", "footer", "nav", "section", "article", "aside",
  "div", "span", "p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6", "strong", "b", "em", "i", "u", "s", "small",
  "mark", "sub", "sup", "blockquote", "q", "pre", "code", "kbd", "samp", "var", "ul", "ol", "li", "dl", "dt", "dd",
  "table", "caption", "colgroup", "col", "thead", "tbody", "tfoot", "tr", "th", "td", "figure", "figcaption", "picture", "source", "img"
]);
const PREVIEW_ATTRIBUTES = new Set([
  "id", "class", "style", "title", "role", "dir", "lang", "hidden", "alt", "width", "height", "colspan", "rowspan", "scope",
  "start", "reversed", "type", "media", "sizes", "loading", "decoding"
]);
const DROP_PREVIEW_CONTENT = new Set(["script", "iframe", "object", "embed", "template", "noscript", "svg", "math"]);

function sanitizeHtmlPreview(html) {
  const document = parse(html);
  sanitizePreviewChildren(document);
  return serialize(document);
}

function sanitizePreviewChildren(parent) {
  parent.childNodes = (parent.childNodes ?? []).flatMap((node) => {
    if (node.nodeName === "#comment") return [];
    if (node.nodeName === "#text" || node.nodeName === "#documentType") return [node];
    const name = String(node.tagName ?? node.nodeName).toLowerCase();
    if (DROP_PREVIEW_CONTENT.has(name) || name === "meta" || name === "base") return [];
    sanitizePreviewChildren(node);
    if (!PREVIEW_ELEMENTS.has(name)) return node.childNodes ?? [];
    if (name === "link" && !safeStylesheetLink(node.attrs ?? [])) return [];
    node.attrs = (node.attrs ?? []).filter((attribute) => safePreviewAttribute(name, attribute));
    return [node];
  });
}

function safeStylesheetLink(attributes) {
  const rel = attributes.find((item) => item.name.toLowerCase() === "rel")?.value ?? "";
  const href = attributes.find((item) => item.name.toLowerCase() === "href")?.value;
  return rel.toLowerCase().split(/\s+/).includes("stylesheet") && safeRelativeReference(href);
}

function safePreviewAttribute(element, attribute) {
  const name = attribute.name.toLowerCase();
  if (name.startsWith("aria-") || name.startsWith("data-") || PREVIEW_ATTRIBUTES.has(name)) return true;
  if (element === "link") return name === "rel" || (name === "href" && safeRelativeReference(attribute.value));
  if (["img", "source"].includes(element) && name === "src") return safeMediaReference(attribute.value);
  if (["img", "source"].includes(element) && name === "srcset") {
    return attribute.value.split(",").every((item) => safeMediaReference(item.trim().split(/\s+/)[0]));
  }
  return false;
}

function safeRelativeReference(value) {
  return typeof value === "string" && value.length > 0 && !/^(?:[a-z][a-z0-9+.-]*:|[\\/]|#)/i.test(value);
}

function safeMediaReference(value) {
  return safeRelativeReference(value) || /^data:image\/(?:png|jpeg|gif|webp);base64,[a-z0-9+/=\r\n]+$/i.test(value ?? "");
}

function referencedPaths(text, extension) {
  const values = [];
  const patterns = extension === ".css"
    ? [/url\(\s*["']?([^"')]+)["']?\s*\)/gi, /@import\s+["']([^"']+)["']/gi]
    : extension === ".js" || extension === ".mjs"
      ? [/(?:from\s*|import\s*\(|import\s*)["']([^"']+)["']/gi]
      : [/\b(?:src|href)\s*=\s*["']([^"']+)["']/gi, /\bsrcset\s*=\s*["']([^"']+)["']/gi];
  for (const pattern of patterns) {
    for (const match of text.matchAll(pattern)) {
      const candidates = pattern.source.includes("srcset") ? match[1].split(",").map((item) => item.trim().split(/\s+/)[0]) : [match[1]];
      for (const candidate of candidates) {
        const clean = candidate?.split(/[?#]/)[0];
        if (clean && !/^(?:[a-z]+:|\/|#)/i.test(clean)) values.push(clean.replaceAll("\\", "/"));
      }
    }
  }
  return values;
}

function safeReferencedFile(sourceRoot, reference) {
  try {
    const candidate = realpathSync(resolve(sourceRoot, decodeURIComponent(reference)));
    const inside = relative(sourceRoot, candidate);
    if (!inside || inside.startsWith("..") || isAbsolute(inside)) return null;
    const stats = statSync(candidate);
    if (!stats.isFile() || stats.size <= 0 || stats.size > MAX_ARTIFACT_BYTES) return null;
    return { path: candidate, relativePath: inside, size: stats.size, extension: extname(candidate).toLowerCase() };
  } catch {
    return null;
  }
}

function workspaceFile(target, workspacePath) {
  try {
    if (typeof target !== "string" || !target || /^(?:https?:|data:|mailto:|#|\/api\/)/i.test(target)) return null;
    const decoded = decodeURIComponent(target.replace(/^file:\/\//i, ""));
    const workspace = realpathSync(workspacePath);
    if (isAbsolute(decoded)) return null;
    const candidate = realpathSync(resolve(workspace, decoded));
    const inside = relative(workspace, candidate);
    if (!inside || inside.startsWith("..") || isAbsolute(inside)) return null;
    const stats = statSync(candidate);
    if (!stats.isFile() || stats.size <= 0 || stats.size > MAX_ARTIFACT_BYTES) return null;
    const extension = extname(candidate).toLowerCase();
    const mimeType = detectedMimeType(candidate, extension);
    return {
      path: candidate,
      name: safeName(basename(candidate)),
      relativePath: inside.replaceAll("\\", "/"),
      mimeType,
      size: stats.size
    };
  } catch {
    return null;
  }
}

function artifactResourceUrl(sessionId, artifactId, resourcePath) {
  const encodedPath = String(resourcePath).split("/").map(encodeURIComponent).join("/");
  return `/api/sessions/${encodeURIComponent(sessionId)}/artifacts/${artifactId}/${encodedPath}`;
}

function detectedMimeType(path, extension) {
  const contents = readFileSync(path);
  if (contents.length >= 8 && contents.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
  if (contents.length >= 3 && contents[0] === 0xff && contents[1] === 0xd8 && contents[2] === 0xff) return "image/jpeg";
  if (contents.length >= 6 && ["GIF87a", "GIF89a"].includes(contents.subarray(0, 6).toString("ascii"))) return "image/gif";
  if (contents.length >= 12 && contents.subarray(0, 4).toString("ascii") === "RIFF" && contents.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
  if (contents.length >= 5 && contents.subarray(0, 5).toString("ascii") === "%PDF-") return "application/pdf";
  if (contents.length >= 4 && contents[0] === 0x50 && contents[1] === 0x4b && [0x03, 0x05, 0x07].includes(contents[2])) return "application/zip";
  const text = contents.toString("utf8").trimStart();
  if (extension === ".svg") return /^(?:<\?xml[^>]*>\s*)?<svg[\s>]/i.test(text) ? "image/svg+xml" : "application/octet-stream";
  if ([".html", ".htm"].includes(extension)) return /^(?:<!doctype\s+html|<(?:html|head|body|main|section|article|div|h[1-6]|a|meta|base|link|script|img|style)\b)/i.test(text) ? "text/html" : "text/plain";
  if (extension === ".json") {
    try { JSON.parse(text); return "application/json"; } catch { return "text/plain"; }
  }
  if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".zip"].includes(extension)) return "application/octet-stream";
  return MIME_TYPES.get(extension) ?? "application/octet-stream";
}

function splitCodeLocation(target) {
  if (typeof target !== "string") return { path: target };
  const match = target.match(/^(.*):(\d+)(?::\d+)?$/);
  return match ? { path: match[1], line: Number(match[2]) } : { path: target };
}

function artifactQuery(artifact, line) {
  const fields = [
    `name=${encodeURIComponent(artifact.name)}`,
    `mime=${encodeURIComponent(artifact.mimeType)}`,
    `size=${artifact.size}`
  ];
  if (Number.isInteger(line) && line > 0) fields.push(`line=${line}`);
  return fields.join("&");
}

function safeName(value) {
  return basename(String(value ?? "artifact")).replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") || "artifact";
}

function escapeDirectiveValue(value) {
  return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}

function artifactNotFound() {
  const error = new Error("artifact_not_found");
  error.status = 404;
  return error;
}
