import { chromium, type Browser } from "playwright";
import { join } from "node:path";
import {
  classify,
  type ClassifiedRun,
  type CoverageEvent,
  type KernelStores,
} from "../../../core/src/classify/classifier.js";
import { capabilityProfileRef, type CapabilityProfile } from "../../../core/src/sdk/capability.js";
import { matrixCellRef } from "../../../schema/src/records/context.js";
import type { MatrixCell } from "../../../schema/src/records/context.js";
import { awaitSettled, withCellDirection, type CaptureOptions, type NetworkEntry } from "@invariantum/playwright";
import type { CellEvidence } from "../../../playwright/src/cell-runner.js";
import {
  AuthAdapterLoadError,
  loadAuthAdapters,
  type AuthAdapter,
} from "../../../playwright/src/auth.js";
import { planMatrix, type MatrixConfig } from "../../../playwright/src/matrix.js";
import {
  assetsDetectors,
  consistencyDetectors,
  measuredLayoutDetectors,
  geometryDetectors,
  interactionDetectors,
  classifyInteractionEvidence,
  layoutDetectors,
  relationsDetectors,
  renderedDetectors,
  runAssetsMatrix,
  runConsistencyMatrix,
  runMeasuredLayoutMatrix,
  runGeometryMatrix,
  runInteractionMatrix,
  runDeclaredJourney,
  runLayoutMatrix,
  runRelationsMatrix,
  runRenderedMatrix,
  runSweepsMatrix,
  sweepsDetectors,
  attachInteractionCaptureListeners,
  captureDeclaredJourneyEvidence,
  DeclaredJourneyRunError,
} from "@invariantum/ui-detectors";
import { parseUiJourneyAdapter, parseUiJourneyPlan, type UiJourneyAdapter } from "@invariantum/feature/journey";
import { verifyAuthProof } from "@invariantum/playwright";
import type { InvariantumConfig } from "../config.js";
import { redactionRulesFromConfig } from "../redaction-rules.js";
import { resolveStateRoot } from "../state-root.js";
import type { RedactionRule } from "../../../playwright/src/redaction.js";
import { cliExecutionContext, witnessRefId } from "../witness.js";

const DEFAULT_ROLES = ["anonymous"];
const DEFAULT_LOCALES = ["en"];
const DEFAULT_VIEWPORTS = [{ name: "desktop", width: 1280, height: 800 }];
const DEFAULT_TIMEOUTS = { navigateMs: 30_000, settleMs: 5_000 };

const DEFAULT_CAPTURE: CaptureOptions = {
  console: true,
  network: true,
  domSnapshot: true,
  screenshot: true,
  trace: false,
  performance: true,
};

// Machine output on stdout stays deterministic; a long run is otherwise opaque
// until it ends, which makes a stall indistinguishable from slow progress.
function reportProgress(message: string): void {
  process.stderr.write(`invariantum ui: ${message}\n`);
}

type FamilyContext = {
  plan: { cells: MatrixCell[] };
  adapters: Record<string, AuthAdapter>;
  baseUrl: string;
  stores: KernelStores;
  runId: string;
  seed: string;
  clockStart: string;
  browser: Browser;
  capture: CaptureOptions;
  timeouts: { navigateMs: number; settleMs: number };
  capabilities: CapabilityProfile;
  artifactRunDir?: string;
  redactionRules?: RedactionRule[];
  onCellEvidence?: (evidence: CellEvidence) => void;
};

type UiJourneyDeclaration = NonNullable<NonNullable<InvariantumConfig["ui"]>["journeys"]>["declarations"][number];

class JourneySetupError extends Error {
  constructor(cause: unknown) {
    super(cause instanceof Error ? cause.message : String(cause));
    this.name = "JourneySetupError";
  }
}

function joinUrl(baseUrl: string, path: string): string {
  if (path.startsWith("http://") || path.startsWith("https://")) return path;
  return `${baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
}

function journeyFailureRun(input: { id: string; phase: "setup" | "adapter" | "detector" | "infrastructure"; code: string; error: unknown; source: UiScopeInput; cell?: MatrixCell }): ClassifiedRun {
  return classify({
    detectorOutcomes: [],
    harnessEvents: [{
      phase: input.phase,
      scope: { id: input.id, detectorId: "interaction-journey", surfaceId: input.id },
      ...(input.cell === undefined ? { plannedContext: cliExecutionContext(input.source.seed) } : { plannedContext: { kind: "browser", cell: matrixCellRef(input.cell) } }),
      cause: { code: input.code, message: input.error instanceof Error ? input.error.message : String(input.error), retryable: false },
      artifactRefs: [],
    }],
    coverageEvents: [], stores: input.source.stores, runId: input.source.runId,
  });
}

async function loadJourneyAdapter(ref: { module: string; export: string }): Promise<UiJourneyAdapter> {
  let module: Record<string, unknown>;
  try { module = await import(ref.module) as Record<string, unknown>; } catch (error) { throw new Error(`module import failed: ${error instanceof Error ? error.message : String(error)}`); }
  return parseUiJourneyAdapter(module[ref.export]);
}

async function executeDeclaredJourney(input: { browser: Browser; baseUrl: string; cell: MatrixCell; declaration: UiJourneyDeclaration; adapter: UiJourneyAdapter; auth?: AuthAdapter | undefined; source: UiScopeInput; timeouts: { navigateMs: number; settleMs: number }; onCellEvidence?: (evidence: CellEvidence) => void }): Promise<{ run: ClassifiedRun; coverage?: CoverageEvent; exercised: number }> {
  const context = await input.browser.newContext({ locale: input.cell.locale, viewport: { width: input.cell.viewport.width, height: input.cell.viewport.height }, deviceScaleFactor: input.cell.viewport.deviceScaleFactor });
  try {
    if (input.auth !== undefined) verifyAuthProof(await input.auth.setup(context, input.cell), input.cell);
    const declaration = {
      ...input.declaration,
      appliesTo: {
        routes: input.declaration.appliesTo.routes,
        ...(input.declaration.appliesTo.roles === undefined
          ? {}
          : { roles: input.declaration.appliesTo.roles }),
        ...(input.declaration.appliesTo.viewports === undefined
          ? {}
          : { viewports: input.declaration.appliesTo.viewports }),
      },
    };
    const prepared = await input.adapter.prepare({ declaration, cell: input.cell });
    if ("applicable" in prepared) {
      return { run: emptyRun(), coverage: {
        scope: { id: `journey:${input.declaration.id}:${input.cell.id}`, detectorId: "interaction-journey", surfaceId: input.declaration.id },
        context: { kind: "browser", cell: { id: input.cell.id } }, reason: "unproven-precondition", witnessRefs: [{ id: `journey-unavailable:${input.declaration.id}:${prepared.reason}` }],
      }, exercised: 0 };
    }
    const plan = parseUiJourneyPlan(prepared);
    if (plan.journey.id !== input.declaration.id || plan.journey.status !== input.declaration.status) throw new Error("prepared plan journey identity does not match declaration");
    const page = await context.newPage();
    try {
      await page.goto(joinUrl(input.baseUrl, input.cell.url), { timeout: input.timeouts.navigateMs, waitUntil: "domcontentloaded" });
    } catch (error) { throw new JourneySetupError(error); }
    const settled = await awaitSettled(page, { timeoutMs: input.timeouts.settleMs });
    if (settled.timedOut) throw new Error("journey setup did not settle before capture");
    const captureBuckets = { console: [], network: [] as NetworkEntry[] };
    const detach = attachInteractionCaptureListeners(page, DEFAULT_CAPTURE, captureBuckets);
    try {
      const result = await runDeclaredJourney({ page, cell: input.cell, plan, network: captureBuckets.network, oracles: input.adapter.oracles, stores: input.source.stores, runId: input.source.runId });
      const evidence = await captureDeclaredJourneyEvidence({
        page,
        cell: input.cell,
        settled,
        console: captureBuckets.console,
        network: captureBuckets.network,
        journeyEvidence: result.evidence,
        capture: DEFAULT_CAPTURE,
        artifactRunDir: join(resolveStateRoot(input.source.config, input.source.cwd), "artifacts", input.source.runId),
        sourceRunId: input.source.runId,
        redactionRules: redactionRulesFromConfig(input.source.config),
      });
      input.onCellEvidence?.(evidence);
      return {
        run: mergeRuns(
          result.journeyOutcome?.classified ?? emptyRun(),
          await classifyInteractionEvidence({
            evidence,
            cell: input.cell,
            detectors: interactionDetectors,
            stores: input.source.stores,
            runId: input.source.runId,
            seed: input.source.seed,
            clockStart: input.source.clockStart,
            capabilities: input.source.capabilities,
          }),
        ),
        ...(result.coverage === undefined
          ? {}
          : { coverage: { ...result.coverage, capabilityProfileRef: capabilityProfileRef(input.source.capabilities, []) } }),
        exercised: 1,
      };
    } finally { detach(); }
  } finally { await context.close(); }
}

const UI_FAMILY_CATALOG: Record<string, (context: FamilyContext) => Promise<ClassifiedRun>> = {
  assets: (context) => runAssetsMatrix({ ...context, detectors: [...assetsDetectors] }),
  consistency: (context) => runConsistencyMatrix({ ...context, detectors: [...consistencyDetectors] }),
  "measured-layout": (context) => runMeasuredLayoutMatrix({ ...context, detectors: [...measuredLayoutDetectors] }),
  geometry: (context) => runGeometryMatrix({ ...context, detectors: [...geometryDetectors] }),
  interaction: (context) => runInteractionMatrix({ ...context, detectors: [...interactionDetectors] }),
  layout: (context) => runLayoutMatrix({ ...context, detectors: [...layoutDetectors] }),
  relations: (context) => runRelationsMatrix({ ...context, detectors: [...relationsDetectors] }),
  rendered: (context) => runRenderedMatrix({ ...context, detectors: [...renderedDetectors] }),
  sweeps: (context) => runSweepsMatrix({ ...context, detectors: [...sweepsDetectors] }),
};

export const UI_FAMILY_NAMES = Object.keys(UI_FAMILY_CATALOG).sort();

export type UiScopeInput = {
  config: InvariantumConfig;
  stores: KernelStores;
  runId: string;
  seed: string;
  clockStart: string;
  capabilities: CapabilityProfile;
  cwd: string;
};

export type UiScopeResult = {
  run: ClassifiedRun;
  exercisedCells: number;
  journeyCounts: { selected: number; exercised: number };
  coverageEvents: CoverageEvent[];
  lines: string[];
  cells: MatrixCell[];
};

function coverageEventForUi(
  scopeId: string,
  reason: CoverageEvent["reason"],
  seed: string,
  detail: unknown,
  capabilities: CapabilityProfile,
): CoverageEvent {
  return {
    scope: { id: scopeId, detectorId: "invariantum-ui", surfaceId: "invariantum-ui" },
    context: cliExecutionContext(seed),
    reason,
    witnessRefs: [{ id: witnessRefId({ kind: scopeId, seed, detail }) }],
    capabilityProfileRef: capabilityProfileRef(capabilities, []),
  };
}

export function resolveUiFamilies(config: InvariantumConfig): string[] {
  const requested = config.ui?.families;
  if (requested === undefined) {
    return [...UI_FAMILY_NAMES];
  }

  const selected = requested.map((name) => name.trim());
  for (const name of selected) {
    if (!(name in UI_FAMILY_CATALOG)) {
      throw new Error(`unknown ui detector family in ui.families: ${name}`);
    }
  }

  return [...new Set(selected)].sort();
}

export function buildUiMatrixConfig(config: InvariantumConfig): MatrixConfig {
  const ui = config.ui;
  if (ui === undefined) {
    throw new Error("ui scope requires a ui config block");
  }

  return {
    routes: ui.routes.map((route) => ({
      pattern: route.pattern,
      ...(route.fixtures === undefined ? {} : { fixtures: route.fixtures }),
    })),
    roles: ui.roles ?? DEFAULT_ROLES,
    locales: ui.locales ?? DEFAULT_LOCALES,
    viewports: ui.viewports ?? DEFAULT_VIEWPORTS,
  };
}

function mergeRuns(left: ClassifiedRun, right: ClassifiedRun): ClassifiedRun {
  const unique = <T extends { id: string }>(values: T[]): T[] =>
    [...new Map(values.map((value) => [value.id, value])).values()];
  return {
    findings: unique([...left.findings, ...right.findings]),
    hiddenFindings: unique([...left.hiddenFindings, ...right.hiddenFindings]),
    coverageOutcomes: unique([...left.coverageOutcomes, ...right.coverageOutcomes]),
    harnessOutcomes: unique([...left.harnessOutcomes, ...right.harnessOutcomes]),
  };
}

function emptyRun(): ClassifiedRun {
  return { findings: [], hiddenFindings: [], coverageOutcomes: [], harnessOutcomes: [] };
}

function familyFailureRun(
  family: string,
  error: unknown,
  input: UiScopeInput,
): ClassifiedRun {
  return classify({
    detectorOutcomes: [],
    harnessEvents: [
      {
        phase: "infrastructure",
        scope: { id: `ui-family-${family}`, detectorId: "invariantum-ui", surfaceId: "invariantum-ui" },
        plannedContext: cliExecutionContext(input.seed),
        cause: {
          code: "ui-family-failed",
          message: error instanceof Error ? error.message : String(error),
          retryable: false,
        },
        artifactRefs: [],
      },
    ],
    coverageEvents: [],
    stores: input.stores,
    runId: input.runId,
  });
}

function authFailureRun(input: {
  scopeId: string;
  code: string;
  message: string;
  source: UiScopeInput;
}): ClassifiedRun {
  return classify({
    detectorOutcomes: [],
    harnessEvents: [
      {
        phase: "adapter",
        scope: { id: input.scopeId, detectorId: "invariantum-ui", surfaceId: "invariantum-ui" },
        plannedContext: cliExecutionContext(input.source.seed),
        cause: { code: input.code, message: input.message, retryable: false },
        artifactRefs: [],
      },
    ],
    coverageEvents: [],
    stores: input.source.stores,
    runId: input.source.runId,
  });
}

async function loadUiAuthAdapters(
  ui: NonNullable<InvariantumConfig["ui"]>,
): Promise<Record<string, AuthAdapter>> {
  const entries = Object.entries(ui.auth?.adapters ?? {});
  const loaded = await loadAuthAdapters(entries.map(([, ref]) => ref));
  const adapterEntries: Array<[string, AuthAdapter]> = [];
  for (const [index, [role]] of entries.entries()) {
    const adapter = loaded[index];
    if (adapter === undefined) {
      throw new Error(`auth adapter loader omitted role '${role}'`);
    }
    adapterEntries.push([role, adapter]);
  }
  return Object.fromEntries(adapterEntries);
}

export async function runUiScope(input: UiScopeInput): Promise<UiScopeResult> {
  const ui = input.config.ui;
  if (ui === undefined) {
    return {
      run: emptyRun(),
      exercisedCells: 0,
      journeyCounts: { selected: 0, exercised: 0 },
      coverageEvents: [coverageEventForUi("ui-config", "empty-selection", input.seed, "missing-ui-config", input.capabilities)],
      lines: ["coverage-incomplete: scope 'ui' requires a ui config block with baseUrl and routes"],
      cells: [],
    };
  }

  if (ui.routes.length === 0) {
    return {
      run: emptyRun(),
      exercisedCells: 0,
      journeyCounts: { selected: 0, exercised: 0 },
      coverageEvents: [coverageEventForUi("ui-routes", "empty-selection", input.seed, "no-routes", input.capabilities)],
      lines: ["coverage-incomplete: ui scope selected zero routes"],
      cells: [],
    };
  }

  const families = resolveUiFamilies(input.config);
  const plan = planMatrix(buildUiMatrixConfig(input.config), (ui.journeys?.declarations ?? []).map((declaration) => ({
    id: declaration.id,
    appliesTo: {
      routes: declaration.appliesTo.routes,
      ...(declaration.appliesTo.roles === undefined ? {} : { roles: declaration.appliesTo.roles }),
      ...(declaration.appliesTo.viewports === undefined ? {} : { viewports: declaration.appliesTo.viewports }),
    },
  })));
  const coverageEvents: CoverageEvent[] = [];
  const lines: string[] = [];
  let adapters: Record<string, AuthAdapter>;

  try {
    adapters = await loadUiAuthAdapters(ui);
  } catch (error) {
    const message = error instanceof AuthAdapterLoadError ? error.message : String(error);
    coverageEvents.push(coverageEventForUi("ui-auth-adapter-load", "unproven-precondition", input.seed, message, input.capabilities));
    lines.push("coverage-incomplete: ui auth adapter load failed");
    return {
      run: authFailureRun({
        scopeId: "ui-auth-adapter-load",
        code: "ui-auth-adapter-load-failed",
        message,
        source: input,
      }),
      exercisedCells: 0,
      journeyCounts: { selected: 0, exercised: 0 },
      coverageEvents,
      lines,
      cells: plan.cells,
    };
  }

  for (const [index, gap] of plan.coverageGaps.entries()) {
    const gapId = `ui-matrix-gap-${String(index)}-${gap.reason}`;
    coverageEvents.push(coverageEventForUi(gapId, gap.reason, input.seed, gap, input.capabilities));
    lines.push(`coverage-incomplete: ui matrix gap ${gap.reason}`);
  }

  const missingRoles = [...new Set(plan.cells.map((cell) => cell.role))]
    .filter((role) => role !== "anonymous" && !Object.hasOwn(adapters, role));
  let run = emptyRun();
  for (const role of missingRoles) {
    const message = `ui role '${role}' has no auth adapter`;
    run = mergeRuns(run, authFailureRun({
      scopeId: `ui-role-adapter-missing-${role}`,
      code: "ui-role-adapter-missing",
      message,
      source: input,
    }));
    coverageEvents.push(coverageEventForUi(`ui-role-adapter-missing-${role}`, "unproven-precondition", input.seed, role, input.capabilities));
    lines.push(`coverage-incomplete: ${message}`);
  }

  let cells = plan.cells.filter((cell) => cell.role === "anonymous" || Object.hasOwn(adapters, cell.role));
  if (cells.length === 0) {
    coverageEvents.push(coverageEventForUi("ui-matrix", "empty-selection", input.seed, "no-cells", input.capabilities));
    lines.push("coverage-incomplete: ui matrix planned zero cells");
    return { run, exercisedCells: 0, journeyCounts: { selected: 0, exercised: 0 }, coverageEvents, lines, cells };
  }

  const browser = await chromium.launch({ headless: true });
  const artifactRunDir = join(
    resolveStateRoot(input.config, input.cwd),
    "artifacts",
    input.runId,
  );
  const redactionRules = redactionRulesFromConfig(input.config);
  let selectedJourneys = 0;
  let exercisedJourneys = 0;
  try {
    const directions = new Map<string, string>();
    const recordCellDirection = (evidence: CellEvidence): void => {
      if (evidence.documentDirection !== undefined) {
        directions.set(evidence.cell.id, evidence.documentDirection);
      }
    };
    const declarations = new Map((ui.journeys?.declarations ?? []).map((declaration) => [declaration.id, declaration]));
    const journeyAdapters = new Map<string, UiJourneyAdapter>();
    const journeyHandledCellIds = new Set<string>();
    for (const cell of cells) {
      for (const ref of cell.journeys ?? []) {
        const declaration = declarations.get(ref.id);
        if (declaration === undefined) continue;
        selectedJourneys += 1;
        journeyHandledCellIds.add(cell.id);
        let adapter = journeyAdapters.get(declaration.id);
        try {
          adapter ??= await loadJourneyAdapter(declaration.adapter);
          journeyAdapters.set(declaration.id, adapter);
          const result = await executeDeclaredJourney({ browser, baseUrl: ui.baseUrl, cell, declaration, adapter, auth: adapters[cell.role], source: input, timeouts: ui.timeouts ?? DEFAULT_TIMEOUTS, onCellEvidence: recordCellDirection });
          run = mergeRuns(run, result.run);
          exercisedJourneys += result.exercised;
          if (result.coverage !== undefined) coverageEvents.push(result.coverage);
        } catch (error) {
          const executionFailure = error instanceof DeclaredJourneyRunError;
          const setupFailure = error instanceof JourneySetupError;
          run = mergeRuns(run, journeyFailureRun({
            id: `journey:${declaration.id}:${cell.id}`,
            phase: executionFailure ? "detector" : setupFailure ? "setup" : "adapter",
            code: executionFailure ? `ui-journey-${error.boundary}-failed` : setupFailure ? "ui-journey-navigation-failed" : "ui-journey-adapter-failed",
            error, source: input, cell,
          }));
        }
      }
    }
    for (const [familyIndex, family] of families.entries()) {
      const runFamily = UI_FAMILY_CATALOG[family];
      if (runFamily === undefined) continue;
      reportProgress(
        `family ${String(familyIndex + 1)}/${String(families.length)} ${family} start cells=${String(cells.length)}`,
      );
      const familyStartedAt = Date.now();
      try {
        run = mergeRuns(
          run,
          await runFamily({
            plan: {
              cells,
              ...(family === "interaction" && journeyHandledCellIds.size > 0
                ? { journeyHandledCellIds: [...journeyHandledCellIds].sort() }
                : {}),
            },
            adapters,
            baseUrl: ui.baseUrl,
            stores: input.stores,
            runId: input.runId,
            seed: input.seed,
            clockStart: input.clockStart,
            browser,
            capture: DEFAULT_CAPTURE,
            timeouts: ui.timeouts ?? DEFAULT_TIMEOUTS,
            capabilities: input.capabilities,
            artifactRunDir,
            redactionRules,
            onCellEvidence: recordCellDirection,
            ...(family === "interaction" && ui.interactionDiscovery !== undefined
              ? { interactionDiscovery: ui.interactionDiscovery }
              : {}),
          }),
        );
      } catch (error) {
        run = mergeRuns(run, familyFailureRun(family, error, input));
        lines.push(`harness-failure: ui family '${family}' did not complete`);
      }
      reportProgress(
        `family ${String(familyIndex + 1)}/${String(families.length)} ${family} done in ${String(Date.now() - familyStartedAt)}ms`,
      );
    }
    cells = cells.map((cell) => {
      const direction = directions.get(cell.id);
      return direction === undefined ? cell : withCellDirection(cell, direction);
    });
  } finally {
    await browser.close();
  }

  lines.push(`ui scope: ${String(cells.length)} cells across ${families.join(",")}`);
  return { run, exercisedCells: cells.length, journeyCounts: { selected: selectedJourneys, exercised: exercisedJourneys }, coverageEvents, lines, cells };
}
