// Shared helpers for the exhaustive tool QA sweep. Discovery shards import these
// to upload files, run tools, assert previews render (the user's #1 concern),
// verify downloads, and capture console/network issues. Designed for the
// isolated QA container (auth off) on http://localhost:13499.

import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expect, type Page } from "@playwright/test";

export const REPO_ROOT = path.join(__dirname, "..", "..");
export const FIXTURES = path.join(REPO_ROOT, "tests", "fixtures");

/** Absolute path to a fixture, e.g. fixture("formats", "sample.png"). */
export function fixture(...parts: string[]): string {
  return path.join(FIXTURES, ...parts);
}

// ---------------------------------------------------------------------------
// Tool metadata (generated by extract-tools-meta.mts). Tools route as
// /:modality/:toolId, so navigation needs each tool's modality.
// ---------------------------------------------------------------------------
export interface ToolMeta {
  id: string;
  name: string;
  modality: "image" | "video" | "audio" | "document" | "file";
  acceptedInputs: string[];
  executionHint: "fast" | "long";
  isAI: boolean;
}

export const TOOLS_META: ToolMeta[] = JSON.parse(
  fs.readFileSync(path.join(__dirname, "tools-meta.json"), "utf8"),
);

const TOOL_BY_ID = new Map<string, ToolMeta>(TOOLS_META.map((t) => [t.id, t]));

export function toolMeta(toolId: string): ToolMeta {
  const m = TOOL_BY_ID.get(toolId);
  if (!m) throw new Error(`Unknown toolId "${toolId}" (not in tools-meta.json)`);
  return m;
}

/** The /:modality/:toolId URL path for a tool. */
export function toolPath(toolId: string): string {
  const m = toolMeta(toolId);
  return `/${m.modality}/${m.id}`;
}

export type Hint = "fast" | "long" | "ai";
const HINT_TIMEOUT: Record<Hint, number> = {
  fast: 30_000,
  long: 180_000,
  ai: 600_000,
};

// ---------------------------------------------------------------------------
// Console / network instrument: attach to every page; assert clean at the end.
// ---------------------------------------------------------------------------
export interface PageIssues {
  consoleErrors: string[];
  pageErrors: string[];
  failedRequests: string[];
  serverErrors: string[];
}

/** Returns true if no console error / page error / network failure / 5xx seen. */
export function isClean(i: PageIssues): boolean {
  return (
    i.consoleErrors.length === 0 &&
    i.pageErrors.length === 0 &&
    i.failedRequests.length === 0 &&
    i.serverErrors.length === 0
  );
}

export function issuesSummary(i: PageIssues): string {
  const parts: string[] = [];
  if (i.consoleErrors.length) parts.push(`console: ${i.consoleErrors.join(" | ")}`);
  if (i.pageErrors.length) parts.push(`pageerror: ${i.pageErrors.join(" | ")}`);
  if (i.failedRequests.length) parts.push(`netfail: ${i.failedRequests.join(" | ")}`);
  if (i.serverErrors.length) parts.push(`5xx: ${i.serverErrors.join(" | ")}`);
  return parts.join("\n");
}

// Benign console noise that is not a product bug.
const CONSOLE_IGNORE = [
  /Download the React DevTools/i,
  /\[vite\]/i,
  /favicon/i,
  /ResizeObserver loop/i,
];

/** Attach listeners that collect issues for the lifetime of the page. */
export function instrument(page: Page): PageIssues {
  const issues: PageIssues = {
    consoleErrors: [],
    pageErrors: [],
    failedRequests: [],
    serverErrors: [],
  };
  page.on("console", (msg) => {
    if (msg.type() !== "error") return;
    const text = msg.text();
    if (CONSOLE_IGNORE.some((re) => re.test(text))) return;
    issues.consoleErrors.push(text);
  });
  page.on("pageerror", (err) => {
    issues.pageErrors.push(String(err?.message ?? err));
  });
  page.on("requestfailed", (req) => {
    const f = req.failure();
    if (!f) return;
    // ERR_ABORTED is normal for cancelled navigations / superseded requests.
    if (/ERR_ABORTED|net::ERR_ABORTED/.test(f.errorText)) return;
    issues.failedRequests.push(`${req.method()} ${req.url()} ${f.errorText}`);
  });
  page.on("response", (resp) => {
    if (resp.status() >= 500) issues.serverErrors.push(`${resp.status()} ${resp.url()}`);
  });
  return issues;
}

// ---------------------------------------------------------------------------
// Navigation / upload / run
// ---------------------------------------------------------------------------
export async function gotoTool(page: Page, toolId: string): Promise<void> {
  await page.goto(toolPath(toolId), { waitUntil: "domcontentloaded" });
}

/** Upload one or more files via the dropzone file chooser. */
export async function uploadFiles(page: Page, files: string | string[]): Promise<void> {
  const arr = Array.isArray(files) ? files : [files];
  const chooserPromise = page.waitForEvent("filechooser");
  const uploadBtn = page.getByRole("button", { name: /upload from computer/i }).first();
  if (await uploadBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
    await uploadBtn.click();
  } else {
    await page.locator("[class*='border-dashed']").first().click();
  }
  const chooser = await chooserPromise;
  await chooser.setFiles(arr);
}

/** Set a secondary file input by selector (multi-input tools, e.g. "#compose-overlay-image"). */
export async function setSecondaryInput(page: Page, selector: string, file: string): Promise<void> {
  await page.locator(selector).setInputFiles(file);
}

/** Click the tool's submit/process button. */
export async function runTool(page: Page, toolId: string): Promise<void> {
  const submit = page.getByTestId(`${toolId}-submit`);
  await submit.first().waitFor({ state: "visible", timeout: 15_000 });
  await submit.first().click();
}

export interface ResultState {
  ok: boolean;
  error?: string;
}

/**
 * Wait for processing to resolve to either success (a download/result control
 * appears) or a surfaced error. Distinguishes a real error from a slow tool via
 * per-hint timeouts. Never hangs forever.
 */
export async function waitForResult(
  page: Page,
  toolId: string,
  hint: Hint = "fast",
): Promise<ResultState> {
  const timeout = HINT_TIMEOUT[hint];
  const download = page.getByTestId(`${toolId}-download`).first();
  const genericDownload = page.locator("[data-download-button]").first();
  const errorRegion = page
    .locator("[role='alert'], [aria-live='assertive']")
    .filter({ hasText: /error|failed|invalid|unsupported|not supported|too large/i })
    .first();

  const deadline = Date.now() + timeout;
  while (Date.now() < deadline) {
    if (await download.isVisible().catch(() => false)) return { ok: true };
    if (await genericDownload.isVisible().catch(() => false)) return { ok: true };
    if (await errorRegion.isVisible().catch(() => false)) {
      const error = await errorRegion.innerText().catch(() => "error surfaced");
      return { ok: false, error: error.trim() };
    }
    await page.waitForTimeout(500);
  }
  return { ok: false, error: `timeout after ${timeout}ms (hint=${hint})` };
}

/** Convenience: run + wait. */
export async function processTool(
  page: Page,
  toolId: string,
  hint: Hint = "fast",
): Promise<ResultState> {
  await runTool(page, toolId);
  return waitForResult(page, toolId, hint);
}

// ---------------------------------------------------------------------------
// Preview oracles (per modality). "Rendered" means actually decoded, not a
// broken-image placeholder / dead player / "Load failed".
// ---------------------------------------------------------------------------
export interface ImgDims {
  w: number;
  h: number;
}

/** Assert an <img> matching alt actually decoded (naturalWidth/Height > 0). */
export async function assertImageRendered(page: Page, alt: string, label = alt): Promise<ImgDims> {
  const img = page.locator(`img[alt="${alt}"]`).first();
  await expect(img, `${label} image visible`).toBeVisible({ timeout: 15_000 });
  const dims = await img.evaluate((el: HTMLImageElement) => ({
    w: el.naturalWidth,
    h: el.naturalHeight,
  }));
  expect(dims.w, `${label} naturalWidth > 0 (0 means broken image)`).toBeGreaterThan(0);
  expect(dims.h, `${label} naturalHeight > 0`).toBeGreaterThan(0);
  return dims;
}

/** Assert no <img> on the page is a broken placeholder. Catches silent preview failures. */
export async function assertNoBrokenImages(page: Page): Promise<void> {
  const broken = await page.evaluate(() =>
    Array.from(document.images)
      .filter((im) => im.complete && im.naturalWidth === 0 && !!im.currentSrc)
      .map((im) => im.currentSrc),
  );
  expect(broken, `broken images present: ${broken.join(", ")}`).toHaveLength(0);
}

/** Assert the video player loaded a decodable frame. */
export async function assertVideoPreview(page: Page): Promise<void> {
  const v = page.getByTestId("media-player-video").first();
  await expect(v, "video player visible").toBeVisible({ timeout: 15_000 });
  await expect
    .poll(async () => v.evaluate((el: HTMLVideoElement) => el.readyState), { timeout: 25_000 })
    .toBeGreaterThanOrEqual(1);
  const vw = await v.evaluate((el: HTMLVideoElement) => el.videoWidth);
  expect(vw, "video has decoded dimensions").toBeGreaterThan(0);
}

/** Assert the audio waveform reached ready state (play control enabled). */
export async function assertAudioPreview(page: Page): Promise<void> {
  const wf = page.getByTestId("waveform-container").first();
  await expect(wf, "waveform container visible").toBeVisible({ timeout: 15_000 });
  const play = page.getByTestId("waveform-play-pause").first();
  await expect(play, "waveform play control enabled (decoded)").toBeEnabled({ timeout: 25_000 });
}

/** Assert the pdf.js document canvas rendered a non-blank page and did not fail to load. */
export async function assertDocumentPreview(page: Page): Promise<void> {
  const canvas = page.getByTestId("document-canvas").first();
  await expect(canvas, "document canvas visible").toBeVisible({ timeout: 25_000 });
  await expect(page.locator(".text-destructive"), "no document load error").toHaveCount(0);
  const nonBlank = await canvas.evaluate((c: HTMLCanvasElement) => {
    const ctx = c.getContext("2d");
    if (!ctx || c.width === 0 || c.height === 0) return false;
    const data = ctx.getImageData(0, 0, c.width, c.height).data;
    for (let i = 0; i < data.length; i += 4) {
      if (data[i] !== 255 || data[i + 1] !== 255 || data[i + 2] !== 255) return true;
    }
    return false;
  });
  expect(nonBlank, "document canvas is non-blank").toBe(true);
}

// ---------------------------------------------------------------------------
// Download + decode verification (ground truth = the downloaded artifact).
// ---------------------------------------------------------------------------
export interface DownloadedFile {
  path: string;
  name: string;
  size: number;
  buf: Buffer;
}

export async function downloadResult(page: Page, toolId: string): Promise<DownloadedFile> {
  const dlPromise = page.waitForEvent("download", { timeout: 30_000 });
  const btn = page.getByTestId(`${toolId}-download`).first();
  if (await btn.isVisible().catch(() => false)) {
    await btn.click();
  } else {
    await page.locator("[data-download-button]").first().click();
  }
  const dl = await dlPromise;
  const name = dl.suggestedFilename();
  const out = path.join(os.tmpdir(), `qa-${toolId}-${process.hrtime.bigint()}-${name}`);
  await dl.saveAs(out);
  const buf = fs.readFileSync(out);
  return { path: out, name, size: buf.length, buf };
}

const MAGIC: Record<string, (b: Buffer) => boolean> = {
  png: (b) => b.length > 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47,
  jpg: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
  jpeg: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
  gif: (b) => b.subarray(0, 3).toString("latin1") === "GIF",
  webp: (b) =>
    b.subarray(0, 4).toString("latin1") === "RIFF" &&
    b.subarray(8, 12).toString("latin1") === "WEBP",
  bmp: (b) => b[0] === 0x42 && b[1] === 0x4d,
  tiff: (b) => (b[0] === 0x49 && b[1] === 0x49) || (b[0] === 0x4d && b[1] === 0x4d),
  pdf: (b) => b.subarray(0, 5).toString("latin1") === "%PDF-",
  zip: (b) => b[0] === 0x50 && b[1] === 0x4b,
  avif: (b) => b.subarray(4, 12).toString("latin1").includes("ftyp"),
  heic: (b) => b.subarray(4, 12).toString("latin1").includes("ftyp"),
  ico: (b) => b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && b[3] === 0x00,
  mp4: (b) => b.subarray(4, 8).toString("latin1") === "ftyp",
  mp3: (b) =>
    (b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0),
  wav: (b) =>
    b.subarray(0, 4).toString("latin1") === "RIFF" &&
    b.subarray(8, 12).toString("latin1") === "WAVE",
  flac: (b) => b.subarray(0, 4).toString("latin1") === "fLaC",
  ogg: (b) => b.subarray(0, 4).toString("latin1") === "OggS",
};

/** Best-effort magic-byte check for a downloaded artifact's format. Unknown ext => true. */
export function magicMatches(buf: Buffer, ext: string): boolean {
  const key = ext.replace(/^\./, "").toLowerCase();
  const fn = MAGIC[key];
  return fn ? fn(buf) : true;
}

// ---------------------------------------------------------------------------
// Decode oracles on downloaded bytes (semantic correctness, not DOM).
// ---------------------------------------------------------------------------
export interface ImageInfo {
  width: number;
  height: number;
  codec: string;
  pixFmt: string;
  hasAlpha: boolean;
}

/** Image dimensions/format/alpha via ffprobe (png/jpg/webp/gif/tiff/bmp/avif/heic/...). */
export function imageInfo(file: string): ImageInfo {
  const out = execFileSync(
    "ffprobe",
    [
      "-v",
      "quiet",
      "-select_streams",
      "v:0",
      "-show_entries",
      "stream=width,height,codec_name,pix_fmt",
      "-print_format",
      "json",
      file,
    ],
    { encoding: "utf8", timeout: 20_000 },
  );
  const s = (JSON.parse(out).streams ?? [])[0] ?? {};
  const pixFmt: string = s.pix_fmt ?? "";
  return {
    width: s.width ?? 0,
    height: s.height ?? 0,
    codec: s.codec_name ?? "",
    pixFmt,
    hasAlpha: /(rgba|bgra|argb|abgr|ya\d|yuva|gbrap)/i.test(pixFmt),
  };
}

/** First-frame luma (YAVG 0-255) and saturation (SATAVG 0-255) via ffmpeg signalstats. */
export function signalStats(file: string): { luma: number; saturation: number } {
  const esc = file.replace(/\\/g, "\\\\").replace(/:/g, "\\:").replace(/'/g, "\\'");
  const out = execFileSync(
    "ffprobe",
    [
      "-v",
      "quiet",
      "-f",
      "lavfi",
      "-i",
      `movie=${esc},signalstats`,
      "-show_entries",
      "frame_tags=lavfi.signalstats.YAVG,lavfi.signalstats.SATAVG",
      "-read_intervals",
      "%+#1",
      "-print_format",
      "json",
    ],
    { encoding: "utf8", timeout: 20_000 },
  );
  const tags = (JSON.parse(out).frames ?? [])[0]?.tags ?? {};
  return {
    luma: Number(tags["lavfi.signalstats.YAVG"] ?? 0),
    saturation: Number(tags["lavfi.signalstats.SATAVG"] ?? 0),
  };
}

/** Mean luminance (0-255); for brightness/contrast oracles. */
export function imageLuma(file: string): number {
  return signalStats(file).luma;
}

/** Mean saturation (0-255); ~0 for grayscale, for desaturate/colorize oracles. */
export function imageSaturation(file: string): number {
  return signalStats(file).saturation;
}

/** ffprobe a media file on disk: returns parsed JSON (format + streams). */
export function probeMedia(file: string): {
  format?: { duration?: string; tags?: Record<string, string> };
  streams?: Array<{
    codec_type?: string;
    width?: number;
    height?: number;
    channels?: number;
    tags?: Record<string, string>;
  }>;
} {
  const out = execFileSync(
    "ffprobe",
    ["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", file],
    { encoding: "utf8", timeout: 20_000 },
  );
  return JSON.parse(out);
}

/** Media duration in seconds (for trim/speed/fade oracles). */
export function mediaDuration(file: string): number {
  const info = probeMedia(file);
  return Number(info.format?.duration ?? 0);
}
