import { describe, it, expect, vi, beforeEach } from "vitest";

// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------

const mocks = vi.hoisted(() => ({
  readFileSync: vi.fn(),
  writeFileSync: vi.fn(),
  existsSync: vi.fn(),
  mkdirSync: vi.fn(),
  unlinkSync: vi.fn(),
  generate: vi.fn(),
  // 0857: a real spy so we can assert createLlmClient is called with the
  // parsed { provider, model } from --judge-model. `model` echoes the override
  // (or "test-model") so the run still produces a benchmark.
  createLlmClient: vi.fn((overrides?: { provider?: string; model?: string }) => ({
    generate: mocks.generate,
    model: overrides?.model ?? "test-model",
  })),
}));

vi.mock("node:fs", () => ({
  readFileSync: mocks.readFileSync,
  writeFileSync: mocks.writeFileSync,
  existsSync: mocks.existsSync,
  mkdirSync: mocks.mkdirSync,
  unlinkSync: mocks.unlinkSync,
}));

vi.mock("../../../eval/llm.js", () => ({
  createLlmClient: mocks.createLlmClient,
  estimateDurationSec: () => ({ minSec: 30, maxSec: 60, label: "30s\u201360s" }),
}));

vi.mock("../../../eval-server/error-classifier.js", () => ({
  classifyError: (err: unknown) => ({
    category: "unknown",
    title: "Operation Failed",
    description: err instanceof Error ? err.message : String(err),
    hint: "Try again.",
    retryable: true,
  }),
}));

vi.mock("../../../eval/progress-log.js", () => ({
  ProgressLog: class {
    update() {}
    complete() {}
    error() {}
  },
}));

// ---------------------------------------------------------------------------
// Import module under test AFTER mocks
// ---------------------------------------------------------------------------

const { runEvalRun } = await import("../run.js");

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

const VALID_EVALS = {
  skill_name: "test-skill",
  evals: [
    {
      id: 1,
      name: "Basic test",
      prompt: "Test prompt 1",
      expected_output: "Expected output 1",
      files: [],
      assertions: [
        { id: "a1", text: "Output mentions AI", type: "boolean" },
        { id: "a2", text: "Output is concise", type: "boolean" },
      ],
    },
    {
      id: 2,
      name: "Edge case test",
      prompt: "Test prompt 2",
      expected_output: "Expected output 2",
      files: [],
      assertions: [
        { id: "b1", text: "Output handles edge case", type: "boolean" },
      ],
    },
  ],
};

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("runEvalRun", () => {
  beforeEach(() => {
    vi.resetAllMocks();
    // 0857: vi.resetAllMocks() clears the hoisted createLlmClient factory impl —
    // re-establish it so each test gets a client whose model echoes any override.
    mocks.createLlmClient.mockImplementation(
      (overrides?: { provider?: string; model?: string }) => ({
        generate: mocks.generate,
        model: overrides?.model ?? "test-model",
      }),
    );
    // Default: evals.json and SKILL.md both exist
    mocks.existsSync.mockReturnValue(true);
    mocks.readFileSync.mockImplementation((path: string) => {
      if (typeof path === "string" && path.endsWith("SKILL.md")) {
        return "# Test Skill\nYou are a test skill.";
      }
      return JSON.stringify(VALID_EVALS);
    });
  });

  it("prints results table on success", async () => {
    // Mock LLM: first call returns output, subsequent calls judge assertions
    let callCount = 0;
    mocks.generate.mockImplementation(async () => {
      callCount++;
      // First call per case = generate output, remaining = judge assertions
      if (callCount === 1) return { text: "AI is great" };
      if (callCount <= 3) return { text: JSON.stringify({ pass: true, reasoning: "ok" }) };
      if (callCount === 4) return { text: "Edge case handled" };
      return { text: JSON.stringify({ pass: false, reasoning: "not quite" }) };
    });

    const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});

    await runEvalRun("/skills/test-skill");

    // Check table was printed
    const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
    expect(output).toContain("a1");
    expect(output).toContain("b1");
    consoleSpy.mockRestore();
  });

  it("prints duration estimate before running", async () => {
    mocks.generate.mockImplementation(async () => {
      return { text: JSON.stringify({ pass: true, reasoning: "ok" }) };
    });

    const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});

    await runEvalRun("/skills/test-skill");

    const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
    expect(output).toContain("Estimated duration");
    expect(output).toContain("Progress file");
    consoleSpy.mockRestore();
  });

  it("writes benchmark.json after run", async () => {
    mocks.generate.mockImplementation(async (_sys: string, prompt: string) => {
      if (prompt.includes("Test prompt")) return { text: "LLM output here" };
      return { text: JSON.stringify({ pass: true, reasoning: "ok" }) };
    });
    vi.spyOn(console, "log").mockImplementation(() => {});

    await runEvalRun("/skills/test-skill");

    // Find the benchmark.json write (skip progress log writes)
    const benchmarkWrite = mocks.writeFileSync.mock.calls.find(
      (c: unknown[]) => typeof c[0] === "string" && (c[0] as string).includes("benchmark.json"),
    );
    expect(benchmarkWrite).toBeDefined();
    const writtenContent = JSON.parse(benchmarkWrite![1] as string);
    expect(writtenContent.skill_name).toBe("test-skill");
    expect(writtenContent.cases).toHaveLength(2);

    vi.restoreAllMocks();
  });

  it("marks error case on LLM failure and continues", async () => {
    let callCount = 0;
    mocks.generate.mockImplementation(async () => {
      callCount++;
      // First case: output generation fails
      if (callCount === 1) throw new Error("API timeout");
      // Second case: works fine
      if (callCount === 2) return { text: "Edge case handled" };
      return { text: JSON.stringify({ pass: true, reasoning: "ok" }) };
    });
    vi.spyOn(console, "log").mockImplementation(() => {});

    await runEvalRun("/skills/test-skill");

    const benchmarkWrite = mocks.writeFileSync.mock.calls.find(
      (c: unknown[]) => typeof c[0] === "string" && (c[0] as string).includes("benchmark.json"),
    );
    expect(benchmarkWrite).toBeDefined();
    const writtenContent = JSON.parse(benchmarkWrite![1] as string);
    expect(writtenContent.cases[0].status).toBe("error");
    expect(writtenContent.cases[0].error_message).toContain("API timeout");
    expect(writtenContent.cases[1].status).toBe("pass");

    vi.restoreAllMocks();
  });

  it("exits with error for missing evals.json", async () => {
    mocks.existsSync.mockReturnValue(false);
    const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
    const exitSpy = vi
      .spyOn(process, "exit")
      .mockImplementation((() => {}) as any);

    await runEvalRun("/skills/test-skill");

    expect(consoleSpy).toHaveBeenCalledWith(
      expect.stringContaining("No evals.json found"),
    );
    consoleSpy.mockRestore();
    exitSpy.mockRestore();
  });

  it("exits with error for invalid evals.json", async () => {
    mocks.existsSync.mockReturnValue(true);
    mocks.readFileSync.mockReturnValue("{ broken json");
    const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
    const exitSpy = vi
      .spyOn(process, "exit")
      .mockImplementation((() => {}) as any);

    await runEvalRun("/skills/test-skill");

    expect(consoleSpy).toHaveBeenCalledWith(
      expect.stringContaining("Invalid evals.json"),
    );
    consoleSpy.mockRestore();
    exitSpy.mockRestore();
  });

  // -------------------------------------------------------------------------
  // 0857 — model-selection plumbing guard.
  //
  // A regression that drops the `--judge-model` flag would make every judge
  // call fall back to the default client. This test pins that the parsed
  // { provider, model } reaches createLlmClient, so the flag can never be
  // silently ignored.
  // -------------------------------------------------------------------------
  it("threads --judge-model into createLlmClient as { provider, model }", async () => {
    mocks.generate.mockImplementation(async () => {
      return { text: JSON.stringify({ pass: true, reasoning: "ok" }) };
    });
    vi.spyOn(console, "log").mockImplementation(() => {});
    vi.spyOn(console, "warn").mockImplementation(() => {});

    await runEvalRun("/skills/test-skill", { judgeModel: "anthropic/claude-opus-4-6" });

    // The judge client MUST be constructed from the parsed flag — not defaulted.
    expect(mocks.createLlmClient).toHaveBeenCalledWith({
      provider: "anthropic",
      model: "claude-opus-4-6",
    });

    vi.restoreAllMocks();
  });

  it("does not pass a judge provider/model when --judge-model is absent", async () => {
    mocks.generate.mockImplementation(async () => {
      return { text: JSON.stringify({ pass: true, reasoning: "ok" }) };
    });
    vi.spyOn(console, "log").mockImplementation(() => {});

    await runEvalRun("/skills/test-skill");

    // Only the main client is built (no overrides object with provider+model).
    const sawJudgeProviderModel = mocks.createLlmClient.mock.calls.some(
      (c: unknown[]) => {
        const arg = c[0] as { provider?: string; model?: string } | undefined;
        return !!arg && typeof arg.provider === "string" && typeof arg.model === "string";
      },
    );
    expect(sawJudgeProviderModel).toBe(false);

    vi.restoreAllMocks();
  });
});
