import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { parseArgs } from "../src/cli/args";
import { ConfigStore, resolveConfig } from "../src/config";
import { deriveCliToolPairs } from "../scripts/shared/workflow-inventory.mjs";
import { createMockProviderRuntime } from "./provider-runtime-mock";

vi.mock("@opencode-ai/plugin", async () => {
  const { z } = await import("zod");
  const toolFn = (input: { description: string; args: unknown; execute: (...args: unknown[]) => unknown }) => input;
  toolFn.schema = z;
  return { tool: toolFn };
});

const CLI_TO_TOOL_PAIRS = deriveCliToolPairs();

const parseToolResponse = (value: string): Record<string, unknown> => JSON.parse(value) as Record<string, unknown>;

type NormalizedParityRecord = {
  mode: string;
  surface: string;
  command: string;
  status: "ok" | "error";
  errorCode: string | null;
  errorClass: string | null;
  targetId: string | null;
  blockerMeta: string | null;
};

type ParityFieldMismatch = {
  field: keyof NormalizedParityRecord;
  expected: string | null;
  actual: string | null;
};

const normalizeParityRecord = (input: {
  mode: string;
  surface: string;
  command: string;
  status: "ok" | "error";
  errorCode?: string | null;
  errorClass?: string | null;
  targetId?: string | null;
  blockerMeta?: string | null;
}): NormalizedParityRecord => ({
  mode: input.mode,
  surface: input.surface,
  command: input.command,
  status: input.status,
  errorCode: input.errorCode ?? null,
  errorClass: input.errorClass ?? null,
  targetId: input.targetId ?? null,
  blockerMeta: input.blockerMeta ?? null
});

const compareNormalizedParity = (
  expected: NormalizedParityRecord,
  actual: NormalizedParityRecord
): ParityFieldMismatch[] => {
  const fields: Array<keyof NormalizedParityRecord> = [
    "mode",
    "surface",
    "command",
    "status",
    "errorCode",
    "errorClass",
    "targetId",
    "blockerMeta"
  ];
  const mismatches: ParityFieldMismatch[] = [];
  for (const field of fields) {
    if (expected[field] !== actual[field]) {
      mismatches.push({
        field,
        expected: expected[field],
        actual: actual[field]
      });
    }
  }
  return mismatches;
};

const DECLARED_DIVERGENCE_REGISTRY_PATH = resolve(process.cwd(), "docs/PARITY_DECLARED_DIVERGENCES.md");
const hasDeclaredDivergenceRegistry = existsSync(DECLARED_DIVERGENCE_REGISTRY_PATH);

const loadDeclaredDivergenceIds = (): Set<string> => {
  if (!hasDeclaredDivergenceRegistry) {
    return new Set<string>();
  }
  const content = readFileSync(DECLARED_DIVERGENCE_REGISTRY_PATH, "utf8");
  const ids = new Set<string>();
  for (const line of content.split(/\r?\n/)) {
    const match = line.match(/^\|\s*(D[0-9]{3,})\s*\|/);
    if (!match) continue;
    ids.add(match[1]);
  }
  return ids;
};

const assertDeclaredParityMismatches = (
  mismatches: ParityFieldMismatch[],
  divergenceId: string | null,
  declaredIds: Set<string>
): void => {
  if (mismatches.length === 0) {
    return;
  }
  if (!divergenceId) {
    const summary = mismatches.map((item) => item.field).join(", ");
    throw new Error(`Undeclared parity mismatch fields: ${summary}`);
  }
  if (!declaredIds.has(divergenceId)) {
    throw new Error(`Unknown declared divergence id: ${divergenceId}`);
  }
};

const createDeps = () => {
  const manager = {
    launch: vi.fn().mockResolvedValue({ sessionId: "s1", mode: "managed", activeTargetId: "t1", warnings: [], wsEndpoint: "ws://managed" }),
    connect: vi.fn().mockResolvedValue({ sessionId: "s2", mode: "cdpConnect", activeTargetId: "t2", warnings: [], wsEndpoint: "ws://cdp" }),
    connectRelay: vi.fn().mockResolvedValue({ sessionId: "s3", mode: "extension", activeTargetId: "t3", warnings: [], wsEndpoint: "ws://relay" }),
    disconnect: vi.fn().mockResolvedValue(undefined),
    status: vi.fn().mockResolvedValue({ mode: "managed", activeTargetId: "t1", url: "https://example.com", title: "Example" }),
    listTargets: vi.fn().mockResolvedValue({ activeTargetId: "t1", targets: [] }),
    useTarget: vi.fn().mockResolvedValue({ activeTargetId: "t2", url: "https://example.com", title: "Example" }),
    newTarget: vi.fn().mockResolvedValue({ targetId: "t3" }),
    closeTarget: vi.fn().mockResolvedValue(undefined),
    page: vi.fn().mockResolvedValue({ targetId: "t1", created: true, url: "https://example.com", title: "Example" }),
    listPages: vi.fn().mockResolvedValue({ pages: [] }),
    closePage: vi.fn().mockResolvedValue(undefined),
    goto: vi.fn().mockResolvedValue({ finalUrl: "https://example.com", status: 200, timingMs: 1, meta: { blockerState: "clear" } }),
    waitForRef: vi.fn().mockResolvedValue({ timingMs: 1, meta: { blockerState: "clear" } }),
    waitForLoad: vi.fn().mockResolvedValue({ timingMs: 1, meta: { blockerState: "clear" } }),
    snapshot: vi.fn().mockResolvedValue({ snapshotId: "snap-1", content: "", truncated: false, refCount: 0, timingMs: 1 }),
    click: vi.fn().mockResolvedValue({ timingMs: 1, navigated: false }),
    hover: vi.fn().mockResolvedValue({ timingMs: 1 }),
    press: vi.fn().mockResolvedValue({ timingMs: 1 }),
    check: vi.fn().mockResolvedValue({ timingMs: 1 }),
    uncheck: vi.fn().mockResolvedValue({ timingMs: 1 }),
    type: vi.fn().mockResolvedValue({ timingMs: 1 }),
    select: vi.fn().mockResolvedValue(undefined),
    scroll: vi.fn().mockResolvedValue(undefined),
    scrollIntoView: vi.fn().mockResolvedValue({ timingMs: 1 }),
    domGetHtml: vi.fn().mockResolvedValue({ outerHTML: "<div></div>", truncated: false }),
    domGetText: vi.fn().mockResolvedValue({ text: "text", truncated: false }),
    domGetAttr: vi.fn().mockResolvedValue({ value: "attr" }),
    domGetValue: vi.fn().mockResolvedValue({ value: "value" }),
    domIsVisible: vi.fn().mockResolvedValue({ value: true }),
    domIsEnabled: vi.fn().mockResolvedValue({ value: true }),
    domIsChecked: vi.fn().mockResolvedValue({ value: false }),
    clonePage: vi.fn().mockResolvedValue({ component: "<Component />", css: ".root{}" }),
    cloneComponent: vi.fn().mockResolvedValue({ component: "<Component />", css: ".root{}" }),
    perfMetrics: vi.fn().mockResolvedValue({ metrics: [] }),
    screenshot: vi.fn().mockResolvedValue({ base64: "image" }),
    consolePoll: vi.fn().mockReturnValue({ events: [], nextSeq: 0 }),
    networkPoll: vi.fn().mockReturnValue({ events: [], nextSeq: 0 }),
    debugTraceSnapshot: vi.fn().mockResolvedValue({ requestId: "req-1", channels: {}, page: {}, meta: { blockerState: "clear" } }),
    cookieImport: vi.fn().mockResolvedValue({ requestId: "req-1", imported: 0, rejected: [] }),
    cookieList: vi.fn().mockResolvedValue({ requestId: "req-2", cookies: [], count: 0 })
  };

  const runner = { run: vi.fn().mockResolvedValue({ results: [], timingMs: 1 }) };
  const annotationManager = { requestAnnotation: vi.fn().mockResolvedValue({ status: "ok", payload: null }) };
  const skills = {
    loadBestPractices: vi.fn().mockResolvedValue("guide"),
    listSkills: vi.fn().mockResolvedValue([]),
    loadSkill: vi.fn().mockResolvedValue("guide")
  };

  const baseConfig = resolveConfig({});
  const config = new ConfigStore({ ...baseConfig, relayToken: false });
  const providerRuntime = createMockProviderRuntime();

  return { manager, runner, annotationManager, config, skills, providerRuntime };
};

describe("parity matrix", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", vi.fn(async (input: string | URL) => {
      const url = String(input);
      if (url.endsWith("/status")) {
        return {
          ok: false,
          status: 503,
          url,
          json: async () => ({})
        };
      }
      return {
        ok: true,
        status: 200,
        url,
        text: async () => `<html><body><main>parity content ${url}</main><a href="https://example.com/result">result</a></body></html>`,
        json: async () => ({})
      };
    }) as unknown as typeof fetch);
  });

  afterEach(() => {
    vi.restoreAllMocks();
    vi.unstubAllGlobals();
  });

  it("keeps parity-critical CLI commands parseable", () => {
    for (const [cliCommand] of CLI_TO_TOOL_PAIRS) {
      const parsed = parseArgs(["node", "opendevbrowser", cliCommand]);
      expect(parsed.command).toBe(cliCommand);
    }
    expect(parseArgs(["node", "opendevbrowser", "rpc"]).command).toBe("rpc");
  });

  it("keeps tool registry parity surface present", async () => {
    const { createTools } = await import("../src/tools");
    const tools = createTools(createDeps() as never);
    const toolNames = Object.keys(tools);

    expect(toolNames.length).toBeGreaterThanOrEqual(CLI_TO_TOOL_PAIRS.length);
    for (const [, toolName] of CLI_TO_TOOL_PAIRS) {
      expect(toolNames).toContain(toolName);
    }
    expect(toolNames).toContain("opendevbrowser_prompting_guide");
    expect(toolNames).toContain("opendevbrowser_skill_list");
    expect(toolNames).toContain("opendevbrowser_skill_load");
    expect(toolNames).not.toContain("opendevbrowser_rpc");
  }, 30000);

  it("keeps macro resolve parity for resolve and execute modes", async () => {
    const parsed = parseArgs([
      "node",
      "opendevbrowser",
      "macro-resolve",
      "--expression=@community.search(\"openai\")",
      "--execute"
    ]);
    expect(parsed.command).toBe("macro-resolve");
    expect(parsed.rawArgs).toContain("--execute");

    const { createTools } = await import("../src/tools");
    const tools = createTools(createDeps() as never);

    const resolveOnly = parseToolResponse(await tools.opendevbrowser_macro_resolve.execute({
      expression: "@community.search(\"openai\")"
    } as never));
    expect(resolveOnly.ok).toBe(true);
    expect(resolveOnly.execution).toBeUndefined();

    const executeMode = parseToolResponse(await tools.opendevbrowser_macro_resolve.execute({
      expression: "@community.search(\"openai\")",
      execute: true
    } as never));
    expect(executeMode.ok).toBe(true);
    expect(executeMode.execution).toMatchObject({
      records: expect.any(Array),
      failures: expect.any(Array),
      metrics: {
        attempted: expect.any(Number),
        succeeded: expect.any(Number),
        failed: expect.any(Number),
        retries: expect.any(Number),
        latencyMs: expect.any(Number)
      },
      meta: {
        ok: expect.any(Boolean),
        partial: expect.any(Boolean),
        sourceSelection: expect.any(String),
        providerOrder: expect.any(Array),
        trace: expect.any(Object),
        tier: expect.objectContaining({
          selected: expect.any(String),
          reasonCode: expect.any(String)
        }),
        provenance: expect.objectContaining({
          provider: expect.any(String),
          retrievalPath: expect.any(String),
          retrievedAt: expect.any(String)
        })
      }
    });
    expect((executeMode.execution as { meta: { ok: boolean } }).meta.ok).toBe(true);
    expect((executeMode.execution as { records: unknown[] }).records.length).toBeGreaterThan(0);
  }, 15000);

  it("keeps blocker metadata placement for execute-mode failures", async () => {
    vi.stubGlobal("fetch", vi.fn(async (input: string | URL) => {
      const url = String(input);
      if (url.includes("x.com/i/flow/login")) {
        return {
          ok: true,
          status: 403,
          url,
          text: async () => "<html><body>login</body></html>",
          json: async () => ({})
        };
      }
      return {
        ok: true,
        status: 200,
        url,
        text: async () => "<html><body>ok</body></html>",
        json: async () => ({})
      };
    }) as unknown as typeof fetch);

    const { createTools } = await import("../src/tools");
    const tools = createTools(createDeps() as never);
    const executeMode = parseToolResponse(await tools.opendevbrowser_macro_resolve.execute({
      expression: "@web.fetch(\"https://x.com/i/flow/login\")",
      execute: true
    } as never));

    expect(executeMode.ok).toBe(true);
    expect(executeMode.execution).toMatchObject({
      meta: {
        ok: false,
        blocker: {
          type: "auth_required"
        }
      }
    });
  }, 15000);

  it("keeps mode parity across managed, extension, and cdpConnect surfaces", async () => {
    const { createTools } = await import("../src/tools");

    const managedDeps = createDeps();
    const managedTools = createTools(managedDeps as never);
    const managed = parseToolResponse(await managedTools.opendevbrowser_launch.execute({ noExtension: true } as never));
    const cdpConnect = parseToolResponse(await managedTools.opendevbrowser_connect.execute({ host: "127.0.0.1" } as never));

    const extensionDeps = createDeps();
    const relay = {
      status: () => ({ extensionConnected: true }),
      getOpsUrl: () => "ws://127.0.0.1:8787/ops"
    };
    const extensionTools = createTools({ ...extensionDeps, relay } as never);
    const extension = parseToolResponse(await extensionTools.opendevbrowser_launch.execute({} as never));

    expect(new Set([managed.mode, extension.mode, cdpConnect.mode])).toEqual(
      new Set(["managed", "extension", "cdpConnect"])
    );
  });

  it("enforces unsupported_mode for extension-intent headless launch attempts", async () => {
    const { createTools } = await import("../src/tools");
    const deps = createDeps();
    const tools = createTools(deps as never);

    const unsupported = parseToolResponse(await tools.opendevbrowser_launch.execute({ headless: true } as never));
    expect(unsupported.ok).toBe(false);
    expect((unsupported.error as { code?: string }).code).toBe("unsupported_mode");
  });

  it("keeps failure-mode parity across managed, extension, and cdpConnect", async () => {
    const { createTools } = await import("../src/tools");

    const managedDeps = createDeps();
    managedDeps.manager.launch.mockRejectedValueOnce(new Error("managed launch timeout"));
    const managedTools = createTools(managedDeps as never);
    const managedFailure = parseToolResponse(
      await managedTools.opendevbrowser_launch.execute({ noExtension: true } as never)
    );
    expect(managedFailure.ok).toBe(false);
    expect((managedFailure.error as { code?: string }).code).toBe("launch_failed");

    const extensionDeps = createDeps();
    extensionDeps.manager.connectRelay.mockRejectedValueOnce(new Error("extension relay timeout"));
    const extensionRelay = {
      refresh: async () => undefined,
      status: () => ({
        extensionConnected: true,
        extensionHandshakeComplete: true,
        cdpConnected: false,
        opsConnected: false,
        pairingRequired: true,
        instanceId: "relay-instance",
        running: true,
        port: 8787
      }),
      getOpsUrl: () => "ws://127.0.0.1:8787/ops",
      getCdpUrl: () => "ws://127.0.0.1:8787/cdp"
    };
    const extensionTools = createTools({ ...extensionDeps, relay: extensionRelay } as never);
    const extensionFailure = parseToolResponse(await extensionTools.opendevbrowser_launch.execute({} as never));
    expect(extensionFailure.ok).toBe(false);
    expect((extensionFailure.error as { code?: string }).code).toBe("extension_connect_failed");

    const cdpDeps = createDeps();
    cdpDeps.manager.connect.mockRejectedValueOnce(new Error("cdp connect timeout"));
    const cdpTools = createTools(cdpDeps as never);
    const cdpFailure = parseToolResponse(await cdpTools.opendevbrowser_connect.execute({
      host: "127.0.0.1",
      port: 9222
    } as never));
    expect(cdpFailure.ok).toBe(false);
    expect((cdpFailure.error as { code?: string }).code).toBe("connect_failed");
  });

  it("loads declared divergence registry ids", () => {
    const declared = loadDeclaredDivergenceIds();
    if (!hasDeclaredDivergenceRegistry) {
      expect(declared.size).toBe(0);
      return;
    }
    expect(declared.size).toBeGreaterThan(0);
    expect(declared.has("D001")).toBe(true);
  });

  it("fails parity gate for undeclared normalized mismatches", () => {
    const declared = loadDeclaredDivergenceIds();
    const expected = normalizeParityRecord({
      mode: "extensionOpsHeaded",
      surface: "tool",
      command: "nav.goto",
      status: "ok"
    });
    const actual = normalizeParityRecord({
      mode: "extensionOpsHeaded",
      surface: "tool",
      command: "nav.goto",
      status: "error",
      errorCode: "timeout",
      errorClass: "retryable"
    });
    const mismatches = compareNormalizedParity(expected, actual);
    expect(() => assertDeclaredParityMismatches(mismatches, null, declared)).toThrow(
      "Undeclared parity mismatch fields"
    );
  });

  it("accepts normalized mismatches only when divergence id is declared", () => {
    const declared = loadDeclaredDivergenceIds();
    const expected = normalizeParityRecord({
      mode: "extensionLegacyCdpHeaded",
      surface: "cli",
      command: "nav.goto",
      status: "ok"
    });
    const actual = normalizeParityRecord({
      mode: "extensionLegacyCdpHeaded",
      surface: "cli",
      command: "nav.goto",
      status: "error",
      errorCode: "parallelism_backpressure",
      errorClass: "retryable"
    });
    const mismatches = compareNormalizedParity(expected, actual);
    if (!hasDeclaredDivergenceRegistry) {
      expect(() => assertDeclaredParityMismatches(mismatches, "D001", declared)).toThrow(
        "Unknown declared divergence id: D001"
      );
      return;
    }
    expect(() => assertDeclaredParityMismatches(mismatches, "D001", declared)).not.toThrow();
    expect(() => assertDeclaredParityMismatches(mismatches, "D404", declared)).toThrow(
      "Unknown declared divergence id: D404"
    );
  });
});
