import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { logMessage } from "../../../bundler/log.js";
import {
  attemptReadAiState,
  readAiStateOrDefault,
  writeAiState,
} from "./state.js";
import {
  downloadGuidelines,
  fetchAgentSkillsCatalog,
  fetchAgentSkillsSha,
  getVersion,
} from "../versionApi.js";
import fs from "fs";
import os from "os";
import path from "path";
import {
  checkAiFilesStalenessAndLog,
  installAiFiles,
  removeAiFiles,
} from "./index.js";
import { statusAiFiles } from "./status.js";
import {
  AGENTS_MD_START_MARKER,
  AGENTS_MD_END_MARKER,
} from "../../codegen_templates/agentsmd.js";
import {
  CLAUDE_MD_START_MARKER,
  CLAUDE_MD_END_MARKER,
} from "../../codegen_templates/claudemd.js";

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

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

vi.mock("../../../bundler/log.js", () => ({
  logMessage: vi.fn(),
}));

vi.mock("./state.js", () => ({
  attemptReadAiState: vi.fn(),
  readAiStateOrDefault: vi.fn(),
  writeAiState: vi.fn(),
  hasAiState: vi.fn().mockResolvedValue(false),
}));

vi.mock("../versionApi.js", () => ({
  downloadGuidelines: vi.fn(),
  fetchAgentSkillsCatalog: vi.fn(),
  fetchAgentSkillsSha: vi.fn(),
  getVersion: vi.fn(),
}));

vi.mock("child_process", () => ({
  default: {
    spawn: vi.fn(() => {
      const emitter = { on: vi.fn() };
      emitter.on.mockImplementation(
        (event: string, cb: (arg: number) => void) => {
          if (event === "close") cb(0);
        },
      );
      return emitter;
    }),
  },
}));

const mockLogMessage = vi.mocked(logMessage);
const mockAttemptReadAiState = vi.mocked(attemptReadAiState);
const mockReadAiStateOrDefault = vi.mocked(readAiStateOrDefault);
const mockWriteAiState = vi.mocked(writeAiState);
const mockDownloadGuidelines = vi.mocked(downloadGuidelines);
const mockFetchAgentSkillsCatalog = vi.mocked(fetchAgentSkillsCatalog);
const mockFetchAgentSkillsSha = vi.mocked(fetchAgentSkillsSha);
const mockGetVersion = vi.mocked(getVersion);

/** Minimal valid state used across tests; includes all required fields. */
const baseState = {
  guidelinesHash: null,
  agentsMdSectionHash: null,
  claudeMdHash: null,
  agentSkillsSha: null,
};

beforeEach(() => {
  mockFetchAgentSkillsCatalog.mockResolvedValue({
    kind: "ok",
    data: {
      latestRepoSha: "canonical-sha-abc123",
      skills: [
        {
          skillName: "migration-helper",
          status: { kind: "active" },
          hash: "hash-a",
          lastSeenRepoSha: "canonical-sha-abc123",
          lastSeenAt: 123,
        },
        {
          skillName: "schema-builder",
          status: { kind: "active" },
          hash: "hash-b",
          lastSeenRepoSha: "canonical-sha-abc123",
          lastSeenAt: 123,
        },
      ],
    },
  });
});

// ---------------------------------------------------------------------------
// checkAiFilesStaleness
// ---------------------------------------------------------------------------

describe("checkAiFilesStaleness", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });
  afterEach(() => {
    vi.unstubAllEnvs();
    vi.resetAllMocks();
  });

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

  test("logs install nudge when no state file exists, even with null canonical values", async () => {
    mockAttemptReadAiState.mockResolvedValue({ kind: "no-file" });

    await checkAiFilesStalenessAndLog({
      canonicalGuidelinesHash: null,
      canonicalAgentSkillsSha: null,
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    expect(mockAttemptReadAiState).toHaveBeenCalled();
    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("npx convex ai-files install"),
    );
    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("not installed"),
    );
  });

  test("does nothing when both canonical values are null but state exists (version server unavailable)", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: { ...baseState, guidelinesHash: "some-hash" },
    });

    await checkAiFilesStalenessAndLog({
      canonicalGuidelinesHash: null,
      canonicalAgentSkillsSha: null,
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    expect(mockLogMessage).not.toHaveBeenCalled();
  });

  test("logs install nudge when no state file exists, even if canonical hashes are available", async () => {
    mockAttemptReadAiState.mockResolvedValue({ kind: "no-file" });

    await checkAiFilesStalenessAndLog({
      canonicalGuidelinesHash: "canonical-hash",
      canonicalAgentSkillsSha: null,
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("npx convex ai-files install"),
    );
    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("npx convex ai-files disable"),
    );
    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("not installed"),
    );
  });

  test("does nothing when config has enabled=false (user opted out)", async () => {
    mockAttemptReadAiState.mockResolvedValue({ kind: "no-file" });

    await checkAiFilesStalenessAndLog({
      canonicalGuidelinesHash: "canonical-hash",
      canonicalAgentSkillsSha: null,
      aiFilesConfig: { enabled: false },
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    expect(mockLogMessage).not.toHaveBeenCalled();
  });

  test("does nothing when stored guidelines hash matches canonical", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: { ...baseState, guidelinesHash: "same-hash" },
    });

    await checkAiFilesStalenessAndLog({
      canonicalGuidelinesHash: "same-hash",
      canonicalAgentSkillsSha: null,
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    expect(mockLogMessage).not.toHaveBeenCalled();
  });

  test("logs nag message when guidelines hash is stale", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: { ...baseState, guidelinesHash: "old-hash" },
    });

    await checkAiFilesStalenessAndLog({
      canonicalGuidelinesHash: "new-canonical-hash",
      canonicalAgentSkillsSha: null,
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("npx convex ai-files update"),
    );
  });

  test("logs nag message when agent skills SHA is stale", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: {
        ...baseState,
        guidelinesHash: "current-hash",
        agentSkillsSha: "old-sha",
      },
    });

    await checkAiFilesStalenessAndLog({
      canonicalGuidelinesHash: "current-hash",
      canonicalAgentSkillsSha: "new-sha",
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("npx convex ai-files update"),
    );
  });

  test("does nothing when stored guidelinesHash is null (never written)", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: baseState,
    });

    await checkAiFilesStalenessAndLog({
      canonicalGuidelinesHash: "some-hash",
      canonicalAgentSkillsSha: "some-sha",
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    expect(mockLogMessage).not.toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// installAiFiles
// ---------------------------------------------------------------------------

describe("installAiFiles", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockFetchAgentSkillsSha.mockResolvedValue("canonical-sha-abc123");
    mockGetVersion.mockResolvedValue({
      kind: "ok",
      data: {
        message: null,
        guidelinesHash: null,
        agentSkillsSha: "canonical-sha-abc123",
        disableSkillsCli: false,
        disableSkillsCliMessage: null,
      },
    });
  });
  afterEach(() => vi.resetAllMocks());

  test("runs full init and installs skills when no state exists", async () => {
    mockReadAiStateOrDefault.mockResolvedValue(baseState);

    const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
    const convexDir = path.join(tmpDir, "convex");
    try {
      fs.mkdirSync(convexDir, { recursive: true });
      fs.writeFileSync(path.join(convexDir, "schema.ts"), "");

      mockDownloadGuidelines.mockResolvedValue("guidelines content");

      await installAiFiles({ projectDir: tmpDir, convexDir });

      expect(
        fs.existsSync(
          path.join(convexDir, "_generated", "ai", "guidelines.md"),
        ),
      ).toBe(true);

      const { default: cp } = await import("child_process");
      const spawnCalls = vi.mocked(cp.spawn).mock.calls;
      const addCall = spawnCalls.find(
        (c) => Array.isArray(c[1]) && c[1].includes("add"),
      );
      expect(addCall).toBeDefined();
    } finally {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
  });

  test("logs warning when guidelines download is unavailable", async () => {
    const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
    const convexDir = path.join(tmpDir, "convex");
    try {
      mockReadAiStateOrDefault.mockResolvedValue(baseState);
      mockDownloadGuidelines.mockResolvedValue(null);

      await installAiFiles({ projectDir: tmpDir, convexDir });

      expect(mockLogMessage).toHaveBeenCalledWith(
        expect.stringContaining("Could not download Convex AI guidelines"),
      );
    } finally {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
  });

  test("skips skills install when server kill switch is enabled", async () => {
    const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
    const convexDir = path.join(tmpDir, "convex");
    try {
      mockReadAiStateOrDefault.mockResolvedValue(baseState);
      mockDownloadGuidelines.mockResolvedValue("guidelines content");
      mockGetVersion.mockResolvedValue({
        kind: "ok",
        data: {
          message: null,
          guidelinesHash: null,
          agentSkillsSha: null,
          disableSkillsCli: true,
          disableSkillsCliMessage: null,
        },
      });

      await installAiFiles({ projectDir: tmpDir, convexDir });

      const { default: cp } = await import("child_process");
      const spawnCalls = vi.mocked(cp.spawn).mock.calls;
      const addCall = spawnCalls.find(
        (c) => Array.isArray(c[1]) && c[1].includes("add"),
      );
      expect(addCall).toBeUndefined();
      expect(mockLogMessage).toHaveBeenCalledWith(
        expect.stringContaining("Agent skills are temporarily disabled."),
      );
    } finally {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
  });

  test("logs the server-provided message when skills are disabled", async () => {
    const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
    const convexDir = path.join(tmpDir, "convex");
    try {
      mockReadAiStateOrDefault.mockResolvedValue(baseState);
      mockDownloadGuidelines.mockResolvedValue("guidelines content");
      mockGetVersion.mockResolvedValue({
        kind: "ok",
        data: {
          message: null,
          guidelinesHash: null,
          agentSkillsSha: null,
          disableSkillsCli: true,
          disableSkillsCliMessage:
            "Skills are down for maintenance until 3pm PT.",
        },
      });

      await installAiFiles({ projectDir: tmpDir, convexDir });

      const { default: cp } = await import("child_process");
      const spawnCalls = vi.mocked(cp.spawn).mock.calls;
      const addCall = spawnCalls.find(
        (c) => Array.isArray(c[1]) && c[1].includes("add"),
      );
      expect(addCall).toBeUndefined();
      expect(mockLogMessage).toHaveBeenCalledWith(
        expect.stringContaining(
          "Skills are down for maintenance until 3pm PT.",
        ),
      );
      expect(mockLogMessage).not.toHaveBeenCalledWith(
        expect.stringContaining("Agent skills are temporarily disabled."),
      );
    } finally {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
  });
});

// ---------------------------------------------------------------------------
// removeAiFiles
// ---------------------------------------------------------------------------

describe("removeAiFiles", () => {
  let tmpDir: string;
  let convexDir: string;

  beforeEach(() => {
    vi.clearAllMocks();
    mockGetVersion.mockResolvedValue({
      kind: "ok",
      data: {
        message: null,
        guidelinesHash: null,
        agentSkillsSha: "canonical-sha-abc123",
        disableSkillsCli: false,
        disableSkillsCliMessage: null,
      },
    });
    tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
    convexDir = path.join(tmpDir, "convex");
    fs.mkdirSync(path.join(convexDir, "_generated", "ai"), {
      recursive: true,
    });
  });

  afterEach(() => {
    fs.rmSync(tmpDir, { recursive: true, force: true });
    vi.resetAllMocks();
  });

  test("logs removed when canonical skills remove succeeds without local artifacts", async () => {
    mockAttemptReadAiState.mockResolvedValue({ kind: "no-file" });
    fs.rmSync(path.join(convexDir, "_generated", "ai"), { recursive: true });

    await removeAiFiles({ projectDir: tmpDir, convexDir });

    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("Convex AI files removed"),
    );
  });

  test("removes ai dir even when no state file exists", async () => {
    mockAttemptReadAiState.mockResolvedValue({ kind: "no-file" });

    await removeAiFiles({ projectDir: tmpDir, convexDir });

    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("Convex AI files removed"),
    );
    expect(fs.existsSync(path.join(convexDir, "_generated", "ai"))).toBe(false);
  });

  test("deletes AGENTS.md if stripping the Convex section leaves it empty", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: baseState,
    });

    const agentsMdContent = `${AGENTS_MD_START_MARKER}\n## Convex\nGuidelines.\n${AGENTS_MD_END_MARKER}\n`;
    fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), agentsMdContent, "utf8");

    await removeAiFiles({ projectDir: tmpDir, convexDir });

    expect(fs.existsSync(path.join(tmpDir, "AGENTS.md"))).toBe(false);
  });

  test("strips Convex section from AGENTS.md", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: baseState,
    });

    const agentsMdContent =
      `# My project\n\n` +
      `${AGENTS_MD_START_MARKER}\n## Convex\nGuidelines.\n${AGENTS_MD_END_MARKER}\n\n` +
      `# After\n`;
    fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), agentsMdContent, "utf8");

    await removeAiFiles({ projectDir: tmpDir, convexDir });

    const result = fs.readFileSync(path.join(tmpDir, "AGENTS.md"), "utf8");
    expect(result).toContain("# My project");
    expect(result).toContain("# After");
    expect(result).not.toContain(AGENTS_MD_START_MARKER);
    expect(result).not.toContain("## Convex");
  });

  test("deletes CLAUDE.md when it only contains the managed section", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: baseState,
    });
    const managed = `${CLAUDE_MD_START_MARKER}\n## Convex\nRead guidelines.\n${CLAUDE_MD_END_MARKER}\n`;
    fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), managed, "utf8");

    await removeAiFiles({ projectDir: tmpDir, convexDir });

    expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(false);
  });

  test("leaves CLAUDE.md when it has no managed markers", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: baseState,
    });

    fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), "User content\n", "utf8");

    await removeAiFiles({ projectDir: tmpDir, convexDir });

    expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(true);
  });

  test("strips only the Convex section from CLAUDE.md", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: baseState,
    });
    const managed = `${CLAUDE_MD_START_MARKER}\n## Convex\nRead guidelines.\n${CLAUDE_MD_END_MARKER}`;
    fs.writeFileSync(
      path.join(tmpDir, "CLAUDE.md"),
      `# User header\n\n${managed}\n\n# User footer\n`,
      "utf8",
    );

    await removeAiFiles({ projectDir: tmpDir, convexDir });

    const content = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf8");
    expect(content).toContain("# User header");
    expect(content).toContain("# User footer");
    expect(content).not.toContain(CLAUDE_MD_START_MARKER);
    expect(content).not.toContain("Read guidelines.");
  });

  test("leaves CLAUDE.md alone when it has no managed markers (legacy)", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: { ...baseState, claudeMdHash: "some-hash" },
    });

    fs.writeFileSync(
      path.join(tmpDir, "CLAUDE.md"),
      "My custom CLAUDE.md content\n",
      "utf8",
    );

    await removeAiFiles({ projectDir: tmpDir, convexDir });

    expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(true);
    expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf8")).toBe(
      "My custom CLAUDE.md content\n",
    );
  });

  test("calls skills remove for each canonical catalog skill name", async () => {
    const result = await removeAiFiles({ projectDir: tmpDir, convexDir });

    const { default: cp } = await import("child_process");
    const spawnCalls = vi.mocked(cp.spawn).mock.calls;
    const removeCall = spawnCalls.find(
      (c) => Array.isArray(c[1]) && c[1].includes("remove"),
    );
    expect(result).toEqual({ kind: "success" });
    expect(removeCall).toBeDefined();
    expect(removeCall![1]).toContain("migration-helper");
    expect(removeCall![1]).toContain("schema-builder");
  });

  test("returns an error when the canonical catalog cannot be fetched", async () => {
    mockFetchAgentSkillsCatalog.mockResolvedValueOnce({ kind: "error" });

    const result = await removeAiFiles({ projectDir: tmpDir, convexDir });

    expect(result).toEqual({
      kind: "error",
      message:
        "Could not fetch canonical agent skills from version.convex.dev. Aborting `convex ai-files remove`.",
    });
  });

  test("deletes skills-lock.json if it becomes empty after removing our skills", async () => {
    const lockfileContent = {
      version: 1,
      skills: {
        "migration-helper": { source: "test" },
      },
    };
    fs.writeFileSync(
      path.join(tmpDir, "skills-lock.json"),
      JSON.stringify(lockfileContent),
      "utf8",
    );

    await removeAiFiles({ projectDir: tmpDir, convexDir });

    expect(fs.existsSync(path.join(tmpDir, "skills-lock.json"))).toBe(false);
  });

  test("preserves skills-lock.json if it contains other skills", async () => {
    const lockfileContent = {
      version: 1,
      skills: {
        "migration-helper": { source: "test" },
        "some-other-skill": { source: "other" },
      },
    };
    fs.writeFileSync(
      path.join(tmpDir, "skills-lock.json"),
      JSON.stringify(lockfileContent),
      "utf8",
    );

    await removeAiFiles({ projectDir: tmpDir, convexDir });

    expect(fs.existsSync(path.join(tmpDir, "skills-lock.json"))).toBe(true);
  });

  test("skips skills remove when server kill switch is enabled", async () => {
    mockGetVersion.mockResolvedValue({
      kind: "ok",
      data: {
        message: null,
        guidelinesHash: null,
        agentSkillsSha: null,
        disableSkillsCli: true,
        disableSkillsCliMessage: null,
      },
    });

    await removeAiFiles({ projectDir: tmpDir, convexDir });

    const { default: cp } = await import("child_process");
    const spawnCalls = vi.mocked(cp.spawn).mock.calls;
    const removeCall = spawnCalls.find(
      (c) => Array.isArray(c[1]) && c[1].includes("remove"),
    );
    expect(removeCall).toBeUndefined();
    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("Agent skills are temporarily disabled."),
    );
  });

  test("does NOT write state after plain remove", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: baseState,
    });

    await removeAiFiles({ projectDir: tmpDir, convexDir });

    expect(mockWriteAiState).not.toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// statusAiFiles
// ---------------------------------------------------------------------------

describe("statusAiFiles", () => {
  const dummyProjectDir = "/tmp/test-project";
  const dummyConvexDir = "/tmp/test-project/convex";

  beforeEach(() => {
    vi.clearAllMocks();
    mockGetVersion.mockResolvedValue({
      kind: "ok",
      data: {
        message: null,
        guidelinesHash: "canonical-guidelines-hash",
        agentSkillsSha: "canonical-skills-sha",
        disableSkillsCli: false,
        disableSkillsCliMessage: null,
      },
    });
  });
  afterEach(() => vi.resetAllMocks());

  test("reports not installed when state is missing", async () => {
    mockAttemptReadAiState.mockResolvedValue({ kind: "no-file" });

    await statusAiFiles({
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("not installed"),
    );
    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("npx convex ai-files install"),
    );
  });

  test("reports disabled when config has enabled=false", async () => {
    await statusAiFiles({
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
      aiFilesConfig: { enabled: false },
    });

    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("disabled"),
    );
    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("npx convex ai-files enable"),
    );
  });

  test("reports enabled when state exists and messages are not disabled", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: baseState,
    });

    await statusAiFiles({
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("enabled"),
    );
  });

  test("reports guidelines as up to date when hash matches canonical", async () => {
    const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
    const convexDir = path.join(tmpDir, "convex");
    try {
      const { hashSha256 } = await import("../utils/hash.js");
      const content = "guidelines content";
      const hash = hashSha256(content);

      fs.mkdirSync(path.join(convexDir, "_generated", "ai"), {
        recursive: true,
      });
      fs.writeFileSync(
        path.join(convexDir, "_generated", "ai", "guidelines.md"),
        content,
        "utf8",
      );

      mockAttemptReadAiState.mockResolvedValue({
        kind: "ok",
        state: { ...baseState, guidelinesHash: hash },
      });
      mockGetVersion.mockResolvedValue({
        kind: "ok",
        data: {
          message: null,
          guidelinesHash: hash,
          agentSkillsSha: null,
          disableSkillsCli: false,
          disableSkillsCliMessage: null,
        },
      });

      await statusAiFiles({ projectDir: tmpDir, convexDir });

      const calls = mockLogMessage.mock.calls.map((c) => c[0]);
      expect(calls.some((m) => /guidelines\.md.*up to date/.test(m))).toBe(
        true,
      );
    } finally {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
  });

  test("reports guidelines as out of date when hash differs from canonical", async () => {
    const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
    const convexDir = path.join(tmpDir, "convex");
    try {
      const { hashSha256 } = await import("../utils/hash.js");
      const content = "old guidelines content";

      fs.mkdirSync(path.join(convexDir, "_generated", "ai"), {
        recursive: true,
      });
      fs.writeFileSync(
        path.join(convexDir, "_generated", "ai", "guidelines.md"),
        content,
        "utf8",
      );

      mockAttemptReadAiState.mockResolvedValue({
        kind: "ok",
        state: { ...baseState, guidelinesHash: hashSha256(content) },
      });
      mockGetVersion.mockResolvedValue({
        kind: "ok",
        data: {
          message: null,
          guidelinesHash: "new-canonical-hash",
          agentSkillsSha: null,
          disableSkillsCli: false,
          disableSkillsCliMessage: null,
        },
      });

      await statusAiFiles({ projectDir: tmpDir, convexDir });

      expect(mockLogMessage).toHaveBeenCalledWith(
        expect.stringContaining("out of date"),
      );
      expect(mockLogMessage).toHaveBeenCalledWith(
        expect.stringContaining("npx convex ai-files update"),
      );
    } finally {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
  });

  test("reports guidelines as locally modified when disk hash differs from stored", async () => {
    const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
    const convexDir = path.join(tmpDir, "convex");
    try {
      const { hashSha256 } = await import("../utils/hash.js");

      fs.mkdirSync(path.join(convexDir, "_generated", "ai"), {
        recursive: true,
      });
      fs.writeFileSync(
        path.join(convexDir, "_generated", "ai", "guidelines.md"),
        "user-modified content",
        "utf8",
      );

      mockAttemptReadAiState.mockResolvedValue({
        kind: "ok",
        state: {
          ...baseState,
          guidelinesHash: hashSha256("original content"),
        },
      });

      await statusAiFiles({ projectDir: tmpDir, convexDir });

      expect(mockLogMessage).toHaveBeenCalledWith(
        expect.stringContaining("modified locally"),
      );
    } finally {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
  });

  test("reports guidelines as missing when guidelines.md is empty", async () => {
    const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
    const convexDir = path.join(tmpDir, "convex");
    try {
      fs.mkdirSync(path.join(convexDir, "_generated", "ai"), {
        recursive: true,
      });
      fs.writeFileSync(
        path.join(convexDir, "_generated", "ai", "guidelines.md"),
        "",
        "utf8",
      );

      mockAttemptReadAiState.mockResolvedValue({
        kind: "ok",
        state: baseState,
      });

      await statusAiFiles({ projectDir: tmpDir, convexDir });

      expect(mockLogMessage).toHaveBeenCalledWith(
        expect.stringContaining("guidelines.md: not on disk"),
      );
    } finally {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
  });

  test("reports agent skills as out of date when SHA differs from canonical", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: {
        ...baseState,
        agentSkillsSha: "old-sha",
      },
    });
    mockGetVersion.mockResolvedValue({
      kind: "ok",
      data: {
        message: null,
        guidelinesHash: null,
        agentSkillsSha: "new-sha",
        disableSkillsCli: false,
        disableSkillsCliMessage: null,
      },
    });

    await statusAiFiles({
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("out of date"),
    );
    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("npx convex ai-files update"),
    );
  });

  test("skips staleness check when network is unavailable", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: {
        ...baseState,
        guidelinesHash: "old-hash",
        agentSkillsSha: "old-sha",
      },
    });
    mockGetVersion.mockResolvedValue({ kind: "error" });

    await statusAiFiles({
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    const calls = mockLogMessage.mock.calls.map((c) => c[0]);
    expect(calls.some((m) => /out of date/.test(m))).toBe(false);
  });

  test("reports skills as installed when present", async () => {
    mockAttemptReadAiState.mockResolvedValue({
      kind: "ok",
      state: {
        ...baseState,
        agentSkillsSha: "canonical-skills-sha",
      },
    });

    await statusAiFiles({
      projectDir: dummyProjectDir,
      convexDir: dummyConvexDir,
    });

    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("Agent skills: installed"),
    );
    expect(mockLogMessage).toHaveBeenCalledWith(
      expect.stringContaining("up to date"),
    );
  });

  test("reports CLAUDE.md section as missing when file exists without markers", async () => {
    const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
    const convexDir = path.join(tmpDir, "convex");
    try {
      fs.writeFileSync(
        path.join(tmpDir, "CLAUDE.md"),
        "User content\n",
        "utf8",
      );
      mockAttemptReadAiState.mockResolvedValue({
        kind: "ok",
        state: baseState,
      });

      await statusAiFiles({ projectDir: tmpDir, convexDir });

      expect(mockLogMessage).toHaveBeenCalledWith(
        expect.stringContaining("CLAUDE.md: no Convex section present"),
      );
    } finally {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
  });
});
