/**
 * Config loader for `vlmkit diff-pr` — extends the existing
 * `vlmkit.config.json` shape (parsed by capture-config.ts for the
 * workflow path) with policy-layer fields:
 *
 *   - thresholds: per-viewport `maxDiffRatio` allowed
 *   - per-route threshold overrides
 *   - tokens path (default --tokens arg for compare)
 *   - approvalPath (default approval.json)
 *   - baselineDir (where pinned PNGs live)
 *
 * A vlmkit config that the workflow already uses
 * remains valid here; the new fields are all optional.
 *
 * Formats: `vlmkit.config.json` (default) and `vlmkit.config.toml` (parsed by
 * the minimal TOML reader in `toml-min.ts`). Format is chosen by the file
 * extension; JSON is preferred when both files exist.
 */

import { existsSync, readFileSync } from "node:fs";
import { dirname, isAbsolute, relative, resolve } from "node:path";
import { parseToml } from "./toml-min.ts";
import { CONFIG_CANDIDATES, STATE_DIR, resolveStatePath } from "@mizchi/vlmkit-core/project-config.ts";

export interface DiffPrRoute {
  name: string;
  /** URL or file path. */
  url: string;
  /** Optional per-route threshold override (per-viewport ratio). */
  thresholds?: Record<string, number>;
  /** Optional waitFor selector (parity with capture config). */
  waitFor?: string;
  /** Per-route override of the project-level a11y policy. */
  a11y?: A11yPolicy;
}

export interface A11yPolicy {
  /** "AA" → 24×24 touch targets / 4.5:1 contrast; "AAA" → 44×44 / 7:1. */
  level?: "AA" | "AAA";
  /** Max contrast failures allowed per route+viewport. Default 0. */
  maxContrastFailures?: number;
  /** Max touch-target failures allowed per route+viewport. Default 0. */
  maxTouchFailures?: number;
  /** Max focus-order findings (trap / reverse / skip-row) per route+viewport. Default 0. */
  maxFocusOrderFailures?: number;
  /** Max semantic findings (heading-hierarchy / form-label / image-alt). Default 0. */
  maxSemanticFailures?: number;
  /** Toggle individual checks. All default on when `a11y` is declared. */
  contrast?: boolean;
  touch?: boolean;
  /**
   * Focus-order check runs Tab cycling and is slower (~1-2s per route+
   * viewport). Default OFF to keep CI fast; opt in with `"focusOrder":
   * true`. The check mutates page focus state, so it runs after the
   * pixel screenshot capture.
   */
  focusOrder?: boolean;
  /**
   * Heading-hierarchy / form-label / image-alt static DOM checks.
   * Cheap; default ON.
   */
  semantic?: boolean;
}

export interface CrossBrowserPolicy {
  /** Which engines to compare. Omit for all (chromium, firefox, webkit). */
  engines?: Array<"chromium" | "firefox" | "webkit">;
  /** Per-engine diff-ratio threshold (vs the reference engine). Default 0.03. */
  threshold?: number;
  /**
   * Accept engines that fail to launch (often firefox/webkit on
   * minimal CI runners). Default true — most CI environments don't
   * have all three engines installed. Set to false to fail the run
   * when an engine is missing.
   */
  allowSkipped?: boolean;
  /** Max non-skipped engines that may exceed threshold. Default 0. */
  maxOver?: number;
}

export interface MediaVariantsPolicy {
  /**
   * Which media-emulation variants to run per route. Omit for the
   * default set (forced-colors / reduced-motion / print / rtl /
   * zoom-200). Each variant adds ~1s per route to the CI run.
   */
  variants?: Array<"forced-colors" | "reduced-motion" | "print" | "rtl" | "zoom-200">;
  /** Max "suspect" verdicts allowed per route. Default 0. */
  maxSuspects?: number;
  /** Max "warn" verdicts allowed per route. Default 5 (more permissive). */
  maxWarns?: number;
  /** Pixel-diff threshold passed through to runMediaVariants. Default 0.03. */
  threshold?: number;
}

export interface DiffPrConfig {
  baseUrl?: string;
  routes: DiffPrRoute[];
  viewports?: string[];
  thresholds: Record<string, number>;
  tokens?: string;
  approvalPath?: string;
  baselineDir: string;
  a11y?: A11yPolicy;
  /**
   * When present, `vlmkit diff-pr` runs the media-variants emulation
   * suite (forced-colors / reduced-motion / print / rtl / zoom-200)
   * per route at the default viewport and gates on the per-route
   * verdict counts. Omit to skip media variants entirely.
   */
  mediaVariants?: MediaVariantsPolicy;
  /**
   * When present, `vlmkit diff-pr` renders each route in chromium /
   * firefox / webkit and diffs the screenshots; engines that exceed
   * the threshold count toward the route's pass/fail. Omit to skip.
   */
  crossBrowser?: CrossBrowserPolicy;
  /** Resolved path of the file the config was loaded from (diag only). */
  configPath?: string;
}

/**
 * Decode the config text as TOML when the path ends in `.toml`, otherwise
 * JSON. Without a path (raw string in tests) JSON is assumed.
 */
function parseConfigSource(raw: string, configPath?: string): unknown {
  if (configPath && /\.toml$/i.test(configPath)) {
    try {
      return parseToml(raw);
    } catch (error) {
      throw new Error(`Invalid vlmkit.config TOML: ${String(error)}`);
    }
  }
  try {
    return JSON.parse(raw);
  } catch (error) {
    throw new Error(`Invalid vlmkit.config JSON: ${String(error)}`);
  }
}

const DEFAULT_THRESHOLDS: Record<string, number> = {
  mobile: 0.01,
  desktop: 0.005,
  wide: 0.005,
};
/**
 * Default baseline directory, as the relative string the config format stores.
 *
 * Computed per call so callers that change cwd during a test or batch run get
 * the state directory belonging to that project.
 */
function defaultBaselineDir(): string {
  return relative(process.cwd(), resolveStatePath(process.cwd(), "baselines")) || `${STATE_DIR}/baselines`;
}

export function parseDiffPrConfig(raw: string, configPath?: string): DiffPrConfig {
  const parsed = parseConfigSource(raw, configPath);
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
    throw new Error("vlmkit.config must be an object");
  }
  const obj = parsed as Record<string, unknown>;
  const baseUrl = asOptionalString(obj.baseUrl, "baseUrl");
  const viewports = asOptionalStringArray(obj.viewports, "viewports");
  const tokens = asOptionalString(obj.tokens, "tokens");
  const approvalPath = asOptionalString(obj.approvalPath, "approvalPath");
  const baselineDir = asOptionalString(obj.baselineDir, "baselineDir") ?? defaultBaselineDir();
  const thresholds = parseThresholds(obj.thresholds);

  // Accept routes at top level or under capture.routes for parity
  // with the existing workflow config shape.
  const captureSection = obj.capture && typeof obj.capture === "object" && !Array.isArray(obj.capture)
    ? obj.capture as Record<string, unknown>
    : null;
  const rawRoutes = captureSection?.routes ?? obj.routes;
  if (!Array.isArray(rawRoutes)) {
    throw new Error("vlmkit.config must declare `routes` (top-level or under `capture`)");
  }
  if (rawRoutes.length === 0) {
    throw new Error("vlmkit.config.routes must declare at least one route");
  }
  const routes = rawRoutes.map((r, i) => parseRoute(r, i, baseUrl));

  return {
    baseUrl,
    routes,
    viewports,
    thresholds: { ...DEFAULT_THRESHOLDS, ...thresholds },
    tokens,
    approvalPath,
    baselineDir,
    a11y: parseA11yPolicy(obj.a11y, "a11y"),
    mediaVariants: parseMediaVariantsPolicy(obj.mediaVariants, "mediaVariants"),
    crossBrowser: parseCrossBrowserPolicy(obj.crossBrowser, "crossBrowser"),
    configPath,
  };
}

const VALID_ENGINES = new Set(["chromium", "firefox", "webkit"]);

function parseCrossBrowserPolicy(value: unknown, label: string): CrossBrowserPolicy | undefined {
  if (value === undefined || value === null) return undefined;
  if (typeof value !== "object" || Array.isArray(value)) {
    throw new Error(`${label} must be an object`);
  }
  const r = value as Record<string, unknown>;
  let engines: CrossBrowserPolicy["engines"] | undefined;
  if (r.engines !== undefined && r.engines !== null) {
    if (!Array.isArray(r.engines)) throw new Error(`${label}.engines must be an array`);
    engines = r.engines.map((e, i) => {
      if (typeof e !== "string" || !VALID_ENGINES.has(e)) {
        throw new Error(`${label}.engines[${i}] must be one of: ${[...VALID_ENGINES].join(", ")}`);
      }
      return e as NonNullable<CrossBrowserPolicy["engines"]>[number];
    });
  }
  const num = (v: unknown, k: string) => {
    if (v === undefined || v === null) return undefined;
    if (typeof v !== "number" || Number.isNaN(v) || v < 0) {
      throw new Error(`${label}.${k} must be a non-negative number`);
    }
    return v;
  };
  const bool = (v: unknown, k: string) => {
    if (v === undefined || v === null) return undefined;
    if (typeof v !== "boolean") throw new Error(`${label}.${k} must be boolean`);
    return v;
  };
  return {
    engines,
    threshold: num(r.threshold, "threshold"),
    allowSkipped: bool(r.allowSkipped, "allowSkipped"),
    maxOver: num(r.maxOver, "maxOver"),
  };
}

const VALID_MEDIA_VARIANTS = new Set(["forced-colors", "reduced-motion", "print", "rtl", "zoom-200"]);

function parseMediaVariantsPolicy(value: unknown, label: string): MediaVariantsPolicy | undefined {
  if (value === undefined || value === null) return undefined;
  if (typeof value !== "object" || Array.isArray(value)) {
    throw new Error(`${label} must be an object`);
  }
  const r = value as Record<string, unknown>;
  let variants: MediaVariantsPolicy["variants"] | undefined;
  if (r.variants !== undefined && r.variants !== null) {
    if (!Array.isArray(r.variants)) throw new Error(`${label}.variants must be an array`);
    variants = r.variants.map((v, i) => {
      if (typeof v !== "string" || !VALID_MEDIA_VARIANTS.has(v)) {
        throw new Error(`${label}.variants[${i}] must be one of: ${[...VALID_MEDIA_VARIANTS].join(", ")}`);
      }
      return v as NonNullable<MediaVariantsPolicy["variants"]>[number];
    });
  }
  const num = (v: unknown, k: string) => {
    if (v === undefined || v === null) return undefined;
    if (typeof v !== "number" || Number.isNaN(v) || v < 0) {
      throw new Error(`${label}.${k} must be a non-negative number`);
    }
    return v;
  };
  return {
    variants,
    maxSuspects: num(r.maxSuspects, "maxSuspects"),
    maxWarns: num(r.maxWarns, "maxWarns"),
    threshold: num(r.threshold, "threshold"),
  };
}

function parseA11yPolicy(value: unknown, label: string): A11yPolicy | undefined {
  if (value === undefined || value === null) return undefined;
  if (typeof value !== "object" || Array.isArray(value)) {
    throw new Error(`${label} must be an object`);
  }
  const r = value as Record<string, unknown>;
  const level = asOptionalString(r.level, `${label}.level`);
  if (level !== undefined && level !== "AA" && level !== "AAA") {
    throw new Error(`${label}.level must be "AA" or "AAA"`);
  }
  const num = (v: unknown, k: string) => {
    if (v === undefined || v === null) return undefined;
    if (typeof v !== "number" || Number.isNaN(v) || v < 0) {
      throw new Error(`${label}.${k} must be a non-negative number`);
    }
    return v;
  };
  const bool = (v: unknown, k: string) => {
    if (v === undefined || v === null) return undefined;
    if (typeof v !== "boolean") throw new Error(`${label}.${k} must be boolean`);
    return v;
  };
  return {
    level: level as A11yPolicy["level"],
    maxContrastFailures: num(r.maxContrastFailures, "maxContrastFailures"),
    maxTouchFailures: num(r.maxTouchFailures, "maxTouchFailures"),
    maxFocusOrderFailures: num(r.maxFocusOrderFailures, "maxFocusOrderFailures"),
    maxSemanticFailures: num(r.maxSemanticFailures, "maxSemanticFailures"),
    contrast: bool(r.contrast, "contrast"),
    touch: bool(r.touch, "touch"),
    focusOrder: bool(r.focusOrder, "focusOrder"),
    semantic: bool(r.semantic, "semantic"),
  };
}

/** Resolve effective a11y policy for a route by merging route over project. */
export function resolveA11yPolicy(config: DiffPrConfig, route: DiffPrRoute): A11yPolicy | undefined {
  if (!config.a11y && !route.a11y) return undefined;
  return {
    level: route.a11y?.level ?? config.a11y?.level ?? "AA",
    maxContrastFailures: route.a11y?.maxContrastFailures ?? config.a11y?.maxContrastFailures ?? 0,
    maxTouchFailures: route.a11y?.maxTouchFailures ?? config.a11y?.maxTouchFailures ?? 0,
    maxFocusOrderFailures: route.a11y?.maxFocusOrderFailures ?? config.a11y?.maxFocusOrderFailures ?? 0,
    maxSemanticFailures: route.a11y?.maxSemanticFailures ?? config.a11y?.maxSemanticFailures ?? 0,
    contrast: route.a11y?.contrast ?? config.a11y?.contrast ?? true,
    touch: route.a11y?.touch ?? config.a11y?.touch ?? true,
    focusOrder: route.a11y?.focusOrder ?? config.a11y?.focusOrder ?? false,
    semantic: route.a11y?.semantic ?? config.a11y?.semantic ?? true,
  };
}

function parseThresholds(value: unknown): Record<string, number> {
  if (value === undefined || value === null) return {};
  if (typeof value !== "object" || Array.isArray(value)) {
    throw new Error("thresholds must be an object");
  }
  const out: Record<string, number> = {};
  for (const [vp, n] of Object.entries(value as Record<string, unknown>)) {
    if (typeof n !== "number" || Number.isNaN(n)) {
      throw new Error(`thresholds.${vp} must be a number`);
    }
    out[vp] = n;
  }
  return out;
}

function parseRoute(value: unknown, index: number, baseUrl: string | undefined): DiffPrRoute {
  if (typeof value === "string") {
    const url = hasScheme(value) ? value : (baseUrl ? joinUrl(baseUrl, value) : value);
    return { name: routeNameFromUrl(url), url };
  }
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error(`routes[${index}] must be a string or object`);
  }
  const r = value as Record<string, unknown>;
  // Path-or-url support (mirror capture-config semantics).
  const urlField = r.url ?? r.path;
  const path = asRequiredString(urlField, `routes[${index}] must have url or path`);
  // Treat scheme-prefixed values (http://, https://, file://) as
  // already-absolute. Bare leading-slash paths get joined to baseUrl.
  const fullUrl = hasScheme(path) ? path : (baseUrl ? joinUrl(baseUrl, path) : path);
  const name = asOptionalString(r.name, `routes[${index}].name`) ?? routeNameFromUrl(fullUrl);
  const waitFor = asOptionalString(r.waitFor ?? r.wait_for, `routes[${index}].waitFor`);
  const thresholds = parseThresholds(r.thresholds);
  const a11y = parseA11yPolicy(r.a11y, `routes[${index}].a11y`);
  return {
    name,
    url: fullUrl,
    waitFor,
    thresholds: Object.keys(thresholds).length > 0 ? thresholds : undefined,
    a11y,
  };
}

function hasScheme(s: string): boolean {
  return /^[a-z][a-z0-9+.-]*:/i.test(s);
}

function joinUrl(base: string, path: string): string {
  if (hasScheme(path)) return path;
  const trimmedBase = base.replace(/\/+$/, "");
  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
  return `${trimmedBase}${normalizedPath}`;
}

function routeNameFromUrl(url: string): string {
  try {
    const u = new URL(url);
    const cleaned = u.pathname.replace(/^\/+|\/+$/g, "");
    if (!cleaned) return "home";
    return cleaned.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase() || "home";
  } catch {
    return url.split("/").filter(Boolean).pop()?.replace(/[^a-zA-Z0-9]+/g, "_").toLowerCase() || "route";
  }
}

function asOptionalString(value: unknown, label: string): string | undefined {
  if (value === undefined || value === null) return undefined;
  if (typeof value !== "string") throw new Error(`${label} must be a string`);
  return value.trim() === "" ? undefined : value;
}

function asRequiredString(value: unknown, label: string): string {
  const s = asOptionalString(value, label);
  if (!s) throw new Error(label);
  return s;
}

function asOptionalStringArray(value: unknown, label: string): string[] | undefined {
  if (value === undefined || value === null) return undefined;
  if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
  return value.map((s, i) => {
    if (typeof s !== "string") throw new Error(`${label}[${i}] must be a string`);
    return s;
  });
}

/**
 * Resolve the threshold to apply for a given route+viewport. Route-
 * level threshold wins over project-level. Default is the project-
 * level value (which itself has built-in defaults).
 */
export function resolveThreshold(config: DiffPrConfig, route: DiffPrRoute, viewport: string): number {
  return route.thresholds?.[viewport] ?? config.thresholds[viewport] ?? 0.01;
}

export function findConfigPath(cwd: string, explicit?: string): string | undefined {
  if (explicit) {
    const abs = isAbsolute(explicit) ? explicit : resolve(cwd, explicit);
    if (!existsSync(abs)) throw new Error(`config not found: ${abs}`);
    return abs;
  }
  // JSON wins over TOML when both canonical files are present.
  for (const name of CONFIG_CANDIDATES) {
    const candidate = resolve(cwd, name);
    if (existsSync(candidate)) {
      return candidate;
    }
  }
  return undefined;
}

export function loadDiffPrConfig(configPath: string): DiffPrConfig {
  return parseDiffPrConfig(readFileSync(configPath, "utf-8"), configPath);
}

export function configBaseDir(config: DiffPrConfig): string {
  return config.configPath ? dirname(config.configPath) : process.cwd();
}
