import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import * as Sentry from "@sentry/node";
import { promises as fs } from "fs";
import {
  aiFilesStateSchema,
  attemptReadAiState,
  hasAiState,
  readAiStateOrDefault,
  writeAiState,
} from "./state.js";

vi.mock("@sentry/node", () => ({
  captureException: vi.fn(),
  captureMessage: vi.fn(),
}));

vi.mock("fs", () => ({
  promises: {
    readFile: vi.fn(),
    writeFile: vi.fn(),
    mkdir: vi.fn().mockResolvedValue(undefined),
  },
}));

const mockFs = vi.mocked(fs);
const mockCaptureException = vi.mocked(Sentry.captureException);

const dummyConvexDir = "/tmp/test-project/convex";

describe("aiFilesStateSchema", () => {
  test("accepts a fully populated valid state object", () => {
    const result = aiFilesStateSchema.safeParse({
      guidelinesHash: "abc123",
      agentsMdSectionHash: "def456",
      claudeMdHash: "ghi789",
      agentSkillsSha: "deadbeef",
    });
    expect(result.success).toBe(true);
  });

  test("accepts null hashes", () => {
    const result = aiFilesStateSchema.safeParse({
      guidelinesHash: null,
      agentsMdSectionHash: null,
      claudeMdHash: null,
      agentSkillsSha: null,
    });
    expect(result.success).toBe(true);
  });

  test("strips legacy local skill tracking fields", () => {
    const result = aiFilesStateSchema.safeParse({
      version: 2,
      guidelinesHash: "abc",
      agentsMdSectionHash: null,
      claudeMdHash: null,
      agentSkillsSha: null,
      installedSkillNames: ["convex-migrations"],
    });
    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data).toEqual({
        guidelinesHash: "abc",
        agentsMdSectionHash: null,
        claudeMdHash: null,
        agentSkillsSha: null,
      });
    }
  });

  test("rejects a number where a string hash is expected", () => {
    const result = aiFilesStateSchema.safeParse({
      guidelinesHash: 123,
      agentsMdSectionHash: null,
      claudeMdHash: null,
      agentSkillsSha: null,
    });
    expect(result.success).toBe(false);
  });

  test("rejects missing required fields", () => {
    const result = aiFilesStateSchema.safeParse({
      guidelinesHash: "abc",
    });
    expect(result.success).toBe(false);
  });
});

describe("attemptReadAiState", () => {
  beforeEach(() => vi.clearAllMocks());
  afterEach(() => vi.resetAllMocks());

  test("returns no-file when the state file does not exist", async () => {
    mockFs.readFile.mockRejectedValue(
      Object.assign(new Error("ENOENT"), { code: "ENOENT" }),
    );

    const result = await attemptReadAiState(dummyConvexDir);

    expect(result).toEqual({ kind: "no-file" });
    expect(mockCaptureException).not.toHaveBeenCalled();
  });

  test("returns no-file when the state file is empty", async () => {
    mockFs.readFile.mockResolvedValueOnce("");

    const result = await attemptReadAiState(dummyConvexDir);

    expect(result).toEqual({ kind: "no-file" });
    expect(mockCaptureException).not.toHaveBeenCalled();
  });

  test("throws when reading the state file fails for reasons other than not found", async () => {
    const error = Object.assign(new Error("EACCES"), { code: "EACCES" });
    mockFs.readFile.mockRejectedValueOnce(error);

    await expect(attemptReadAiState(dummyConvexDir)).rejects.toBe(error);
    expect(mockCaptureException).not.toHaveBeenCalled();
  });

  test("returns ok with parsed state", async () => {
    mockFs.readFile.mockResolvedValueOnce(
      JSON.stringify({
        version: 1,
        guidelinesHash: "abc",
        agentsMdSectionHash: "def",
        claudeMdHash: null,
        agentSkillsSha: null,
      }),
    );

    const result = await attemptReadAiState(dummyConvexDir);

    expect(result).toEqual({
      kind: "ok",
      state: {
        guidelinesHash: "abc",
        agentsMdSectionHash: "def",
        claudeMdHash: null,
        agentSkillsSha: null,
      },
    });
  });

  test("strips legacy tracking fields when reading older state files", async () => {
    mockFs.readFile.mockResolvedValueOnce(
      JSON.stringify({
        version: 2,
        guidelinesHash: "abc",
        agentsMdSectionHash: "def",
        claudeMdHash: null,
        agentSkillsSha: "skills-sha",
        installedSkillNames: ["convex-migrations"],
      }),
    );

    const result = await attemptReadAiState(dummyConvexDir);

    expect(result).toEqual({
      kind: "ok",
      state: {
        guidelinesHash: "abc",
        agentsMdSectionHash: "def",
        claudeMdHash: null,
        agentSkillsSha: "skills-sha",
      },
    });
  });

  test("returns parse-error and captures exception when JSON is invalid", async () => {
    mockFs.readFile.mockResolvedValueOnce("not valid json {{{}");

    const result = await attemptReadAiState(dummyConvexDir);

    expect(result.kind).toBe("parse-error");
    expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error));
  });

  test("returns parse-error and captures exception when schema validation fails", async () => {
    mockFs.readFile.mockResolvedValueOnce(
      JSON.stringify({
        guidelinesHash: 99,
        agentsMdSectionHash: null,
      }),
    );

    const result = await attemptReadAiState(dummyConvexDir);

    expect(result.kind).toBe("parse-error");
    expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error));
  });
});

describe("readAiStateOrDefault", () => {
  beforeEach(() => vi.clearAllMocks());
  afterEach(() => vi.resetAllMocks());

  test("returns default state when no state file exists", async () => {
    mockFs.readFile.mockRejectedValue(
      Object.assign(new Error("ENOENT"), { code: "ENOENT" }),
    );

    const result = await readAiStateOrDefault(dummyConvexDir);

    expect(result).toEqual({
      guidelinesHash: null,
      agentsMdSectionHash: null,
      claudeMdHash: null,
      agentSkillsSha: null,
    });
  });

  test("returns default state on parse error", async () => {
    mockFs.readFile.mockResolvedValueOnce("not valid json {{{}");

    const result = await readAiStateOrDefault(dummyConvexDir);

    expect(result).toEqual({
      guidelinesHash: null,
      agentsMdSectionHash: null,
      claudeMdHash: null,
      agentSkillsSha: null,
    });
    expect(mockCaptureException).toHaveBeenCalled();
  });

  test("returns parsed state when state file exists", async () => {
    const stored = {
      guidelinesHash: "abc",
      agentsMdSectionHash: "def",
      claudeMdHash: null,
      agentSkillsSha: "deadbeef",
    };
    mockFs.readFile.mockResolvedValueOnce(JSON.stringify(stored));

    const result = await readAiStateOrDefault(dummyConvexDir);

    expect(result).toEqual(stored);
  });
});

describe("hasAiState", () => {
  beforeEach(() => vi.clearAllMocks());
  afterEach(() => vi.resetAllMocks());

  test("returns false when state file does not exist", async () => {
    mockFs.readFile.mockRejectedValue(
      Object.assign(new Error("ENOENT"), { code: "ENOENT" }),
    );

    const result = await hasAiState(dummyConvexDir);

    expect(result).toBe(false);
    expect(mockCaptureException).not.toHaveBeenCalled();
  });

  test("returns true when a valid state file exists", async () => {
    mockFs.readFile.mockResolvedValueOnce(
      JSON.stringify({
        guidelinesHash: "abc",
        agentsMdSectionHash: "def",
        claudeMdHash: null,
        agentSkillsSha: null,
      }),
    );

    const result = await hasAiState(dummyConvexDir);

    expect(result).toBe(true);
  });

  test("returns false and captures exception when the state file is invalid", async () => {
    mockFs.readFile.mockResolvedValueOnce("not valid json {{{}");

    const result = await hasAiState(dummyConvexDir);

    expect(result).toBe(false);
    expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error));
  });
});

describe("writeAiState", () => {
  beforeEach(() => vi.clearAllMocks());
  afterEach(() => vi.resetAllMocks());

  test("writes state file", async () => {
    mockFs.writeFile.mockResolvedValue(undefined);

    const state = {
      guidelinesHash: "abc",
      agentsMdSectionHash: "def",
      claudeMdHash: null,
      agentSkillsSha: null,
    };

    await writeAiState({ state, convexDir: dummyConvexDir });

    expect(mockFs.writeFile).toHaveBeenCalledTimes(1);
    expect(mockFs.writeFile).toHaveBeenCalledWith(
      expect.stringContaining("ai-files.state.json"),
      JSON.stringify(state, null, 2) + "\n",
      "utf8",
    );
  });
});
