import { defineCommand } from "citty";
import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveProject } from "../utils/project.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import { c } from "../ui/colors.js";
import { withMeta } from "../utils/updateCheck.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

interface ConsoleEntry {
  level: "error" | "warning";
  text: string;
  url?: string;
  line?: number;
}

interface ContrastEntry {
  time: number;
  selector: string;
  text: string;
  ratio: number;
  wcagAA: boolean;
  large: boolean;
  fg: string;
  bg: string;
}

const CONTRAST_SAMPLES = 5;
const SEEK_SETTLE_MS = 150;
const MEDIA_EXTENSIONS = /\.(aac|flac|m4a|mov|mp3|mp4|oga|ogg|wav|webm)$/i;

export function shouldIgnoreRequestFailure(
  url: string,
  errorText: string | undefined,
  resourceType?: string,
): boolean {
  if (errorText !== "net::ERR_ABORTED") return false;
  if (resourceType === "media") return true;
  try {
    return MEDIA_EXTENSIONS.test(new URL(url).pathname);
  } catch {
    return false;
  }
}

async function getCompositionDuration(page: import("puppeteer-core").Page): Promise<number> {
  return page.evaluate(() => {
    if (window.__hf?.duration && window.__hf.duration > 0) return window.__hf.duration;
    const root = document.querySelector("[data-composition-id][data-duration]");
    return root ? parseFloat(root.getAttribute("data-duration") ?? "0") : 0;
  });
}

async function seekTo(page: import("puppeteer-core").Page, time: number): Promise<void> {
  await page.evaluate((t: number) => {
    if (window.__hf && typeof window.__hf.seek === "function") {
      window.__hf.seek(t);
      return;
    }
    const timelines = (window as unknown as Record<string, unknown>).__timelines as
      | Record<string, { seek: (t: number) => void }>
      | undefined;
    if (timelines) {
      for (const tl of Object.values(timelines)) {
        if (typeof tl.seek === "function") tl.seek(t);
      }
    }
  }, time);
  await new Promise((r) => setTimeout(r, SEEK_SETTLE_MS));
}

async function runContrastAudit(page: import("puppeteer-core").Page): Promise<ContrastEntry[]> {
  const duration = await getCompositionDuration(page);
  if (duration <= 0) return [];

  await page.addScriptTag({ content: loadContrastAuditScript() });

  const results: ContrastEntry[] = [];
  for (let i = 0; i < CONTRAST_SAMPLES; i++) {
    const t = +(((i + 0.5) / CONTRAST_SAMPLES) * duration).toFixed(3);
    await seekTo(page, t);

    const screenshot = (await page.screenshot({ encoding: "base64", type: "png" })) as string;
    const entries = await page.evaluate(
      (b64: string, time: number) =>
        typeof (window as unknown as Record<string, unknown>).__contrastAudit === "function"
          ? ((window as unknown as Record<string, unknown>).__contrastAudit as Function)(b64, time)
          : [],
      screenshot,
      t,
    );
    results.push(...(entries as ContrastEntry[]));
  }

  return results;
}

function loadContrastAuditScript(): string {
  const candidates = [
    join(__dirname, "contrast-audit.browser.js"),
    join(__dirname, "commands", "contrast-audit.browser.js"),
  ];

  for (const candidate of candidates) {
    if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
  }

  throw new Error("Missing contrast audit browser script");
}

async function validateInBrowser(
  projectDir: string,
  opts: { timeout?: number; contrast?: boolean },
): Promise<{ errors: ConsoleEntry[]; warnings: ConsoleEntry[]; contrast?: ContrastEntry[] }> {
  const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
  const { ensureBrowser } = await import("../browser/manager.js");
  const { serveStaticProjectHtml } = await import("../utils/staticProjectServer.js");

  // `bundleToSingleHtml` now inlines the runtime IIFE by default, so the
  // previous post-bundle regex substitution (which matched `src="..."` on the
  // runtime tag) is no longer needed — there's no `src` attribute to match.
  const html = await bundleToSingleHtml(projectDir);

  const server = await serveStaticProjectHtml(projectDir, html);

  const errors: ConsoleEntry[] = [];
  const warnings: ConsoleEntry[] = [];
  let contrast: ContrastEntry[] | undefined;
  const viewport = resolveCompositionViewportFromHtml(html);

  try {
    const browser = await ensureBrowser();
    const puppeteer = await import("puppeteer-core");
    const { buildChromeArgs } = await import("@hyperframes/engine");
    const browserGpuMode =
      process.env.PRODUCER_BROWSER_GPU_MODE === "software" ? "software" : "hardware";
    const chromeBrowser = await puppeteer.default.launch({
      headless: true,
      executablePath: browser.executablePath,
      args: buildChromeArgs({ ...viewport, captureMode: "screenshot" }, { browserGpuMode }),
    });

    const page = await chromeBrowser.newPage();
    await page.setViewport(viewport);

    page.on("console", (msg) => {
      const type = msg.type();
      const loc = msg.location();
      const text = msg.text();
      if (type === "error") {
        if (text.startsWith("Failed to load resource")) return;
        errors.push({ level: "error", text, url: loc.url, line: loc.lineNumber });
      } else if (type === "warn") {
        warnings.push({ level: "warning", text, url: loc.url, line: loc.lineNumber });
      }
    });

    page.on("pageerror", (err) => {
      const text = err instanceof Error ? err.message : String(err);
      // CDN scripts (e.g. GSAP from jsdelivr) returning HTML error pages
      // instead of JS produce "Unexpected token '<'" SyntaxErrors. These
      // are network failures, not composition authoring errors.
      if (text.includes("Unexpected token '<'") || text.includes("Unexpected token '&lt;'")) return;
      errors.push({ level: "error", text });
    });

    page.on("requestfailed", (req) => {
      const url = req.url();
      if (url.includes("favicon") || url.startsWith("data:")) return;
      const failureText = req.failure()?.errorText;
      if (shouldIgnoreRequestFailure(url, failureText, req.resourceType())) return;
      const path = decodeURIComponent(new URL(url).pathname).replace(/^\//, "");
      errors.push({
        level: "error",
        text: `Failed to load ${path}: ${failureText ?? "net::ERR_FAILED"}`,
        url,
      });
    });

    page.on("response", (res) => {
      if (res.status() >= 400) {
        const url = res.url();
        if (url.includes("favicon")) return;
        const path = decodeURIComponent(new URL(url).pathname).replace(/^\//, "");
        errors.push({ level: "error", text: `${res.status()} loading ${path}`, url });
      }
    });

    await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 10000 });
    await new Promise((r) => setTimeout(r, opts.timeout ?? 3000));

    if (opts.contrast) {
      contrast = await runContrastAudit(page);
    }

    await chromeBrowser.close();
  } finally {
    await server.close();
  }

  return { errors, warnings, contrast };
}

function printContrastFailures(failures: ContrastEntry[]) {
  console.log();
  console.log(`  ${c.warn("⚠")} WCAG AA contrast warnings (${failures.length}):`);
  for (const cf of failures) {
    const threshold = cf.large ? "3" : "4.5";
    console.log(
      `    ${c.warn("·")} ${cf.selector} ${c.dim(`"${cf.text}"`)} — ${c.warn(cf.ratio + ":1")} ${c.dim(`(need ${threshold}:1, t=${cf.time}s)`)}`,
    );
  }
}

function emitJsonReport(
  errors: ConsoleEntry[],
  warnings: ConsoleEntry[],
  contrast: ContrastEntry[] | undefined,
  contrastFailures: ContrastEntry[],
): void {
  console.log(
    JSON.stringify(
      withMeta({
        ok: errors.length === 0,
        errors,
        warnings,
        contrast,
        contrastFailures: contrastFailures.length,
      }),
      null,
      2,
    ),
  );
}

function formatConsoleEntry(prefix: string, e: ConsoleEntry): string {
  return `  ${prefix} ${e.text}${e.line ? c.dim(` (line ${e.line})`) : ""}`;
}

function formatTotals(
  errors: ConsoleEntry[],
  warnings: ConsoleEntry[],
  contrastFailures: ContrastEntry[],
): string {
  const parts = [`${errors.length} error(s)`, `${warnings.length} warning(s)`];
  if (contrastFailures.length > 0) parts.push(`${contrastFailures.length} contrast warning(s)`);
  return parts.join(", ");
}

function emitTextReport(
  errors: ConsoleEntry[],
  warnings: ConsoleEntry[],
  contrastFailures: ContrastEntry[],
  contrastPassed: ContrastEntry[],
): void {
  const hasIssues = errors.length > 0 || warnings.length > 0 || contrastFailures.length > 0;
  if (!hasIssues) {
    const suffix =
      contrastPassed.length > 0 ? ` · ${contrastPassed.length} text elements pass WCAG AA` : "";
    console.log(`${c.success("◇")}  No console errors${suffix}`);
    return;
  }

  console.log();
  for (const e of errors) console.log(formatConsoleEntry(c.error("✗"), e));
  for (const w of warnings) console.log(formatConsoleEntry(c.warn("⚠"), w));
  if (contrastFailures.length > 0) printContrastFailures(contrastFailures);

  console.log();
  console.log(`${c.accent("◇")}  ${formatTotals(errors, warnings, contrastFailures)}`);
}

function emitFailureReport(message: string, asJson: boolean): void {
  if (asJson) {
    console.log(
      JSON.stringify(withMeta({ ok: false, error: message, errors: [], warnings: [] }), null, 2),
    );
    return;
  }
  console.error(`${c.error("✗")} ${message}`);
}

export default defineCommand({
  meta: {
    name: "validate",
    description: `Load a composition in headless Chrome and report console errors

Examples:
  hyperframes validate
  hyperframes validate ./my-project
  hyperframes validate --json
  hyperframes validate --timeout 5000`,
  },
  args: {
    dir: { type: "positional", description: "Project directory", required: false },
    json: { type: "boolean", description: "Output as JSON", default: false },
    contrast: {
      type: "boolean",
      description: "WCAG contrast audit (enabled by default)",
      default: true,
    },
    timeout: {
      type: "string",
      description: "Ms to wait for scripts to settle (default: 3000)",
      default: "3000",
    },
  },
  async run({ args }) {
    const project = resolveProject(args.dir);
    const timeout = parseInt(args.timeout as string, 10) || 3000;
    const useContrast = args.contrast ?? true;
    const asJson = Boolean(args.json);

    if (!asJson) {
      console.log(`${c.accent("◆")}  Validating ${c.accent(project.name)} in headless Chrome`);
    }

    try {
      const result = await validateInBrowser(project.dir, { timeout, contrast: useContrast });
      const exitCode = printValidationResult(result, asJson);
      process.exit(exitCode);
    } catch (err: unknown) {
      const message = err instanceof Error ? err.message : String(err);
      emitFailureReport(message, asJson);
      process.exit(1);
    }
  },
});

function printValidationResult(
  result: { errors: ConsoleEntry[]; warnings: ConsoleEntry[]; contrast?: ContrastEntry[] },
  asJson: boolean,
): number {
  const { errors, warnings, contrast } = result;
  const contrastFailures = (contrast ?? []).filter((e) => !e.wcagAA);
  const contrastPassed = (contrast ?? []).filter((e) => e.wcagAA);

  if (asJson) {
    emitJsonReport(errors, warnings, contrast, contrastFailures);
  } else {
    emitTextReport(errors, warnings, contrastFailures, contrastPassed);
  }
  return errors.length > 0 ? 1 : 0;
}
