import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createSystrayAdapter } from "./systray";

const FIXTURES = join(import.meta.dir, "../../test/fixtures/systray");

function fixture(name: string): string {
  return readFileSync(join(FIXTURES, name), "utf8");
}

/** Indexes into a non-empty array, throwing rather than returning possibly-undefined. */
function pick<T>(arr: readonly T[], index: number): T {
  const value = arr[index];
  if (value === undefined) {
    throw new Error(`index ${index} out of bounds (length ${arr.length})`);
  }
  return value;
}

/** Returns an adapter whose injected clock/read/stat all advance together, one step per poll(). */
function sequencedAdapter(files: string[], times: number[], overrides: Record<string, unknown> = {}) {
  let call = 0;
  const adapter = createSystrayAdapter({
    snapshotPath: "/fake/systray/health_cache.json",
    readFileImpl: () => pick(files, Math.min(call, files.length - 1)),
    statImpl: () => pick(times, Math.min(call, times.length - 1)),
    now: () => pick(times, Math.min(call, times.length - 1)),
    ...overrides,
  });
  return {
    poll: async () => {
      const result = await adapter.poll();
      call++;
      return result;
    },
  };
}

describe("createSystrayAdapter", () => {
  test("linear-projection ETA from 60% -> 70% -> 78% over 30 minutes", async () => {
    const files = [
      fixture("snapshot-60pct.json"),
      fixture("snapshot-70pct.json"),
      fixture("snapshot-78pct.json"),
    ];
    const times = [0, 15 * 60_000, 30 * 60_000];
    const adapter = sequencedAdapter(files, times);

    await adapter.poll();
    await adapter.poll();
    const result = await adapter.poll();

    const panel = result.panels.find((p) => p.id === "limits");
    expect(panel).toBeDefined();
    const data = panel!.data as { accounts: Array<{ slug: string; capEtaMinutes: number | null }> };
    const acct = data.accounts.find((a) => a.slug === "codex:acct-a");
    expect(acct).toBeDefined();
    expect(acct!.capEtaMinutes).not.toBeNull();
    // regression slope ~0.6%/min from (0,60)(15,70)(30,78) -> ETA to 100% ~36 min from t=30
    expect(acct!.capEtaMinutes!).toBeGreaterThan(30);
    expect(acct!.capEtaMinutes!).toBeLessThan(42);

    // percent 78 >= default 75 threshold -> warn item
    const item = result.items.find((i) => i.kind === "limit" && i.source === "systray-ai");
    expect(item).toBeDefined();
    expect(item!.severity).toBe("warn");
  });

  test("panel exposes percent, status, spend, window per account", async () => {
    const adapter = sequencedAdapter([fixture("snapshot-60pct.json")], [0]);
    const result = await adapter.poll();
    const panel = result.panels.find((p) => p.id === "limits")!;
    const data = panel.data as {
      accounts: Array<{
        slug: string;
        provider: string;
        label: string;
        percent: number | null;
        status: string;
        spend: unknown;
        window: { primaryResetAt: number | null; secondaryResetAt: number | null };
      }>;
    };
    const acct = data.accounts[0];
    if (!acct) throw new Error("expected an account in panel data");
    expect(acct.slug).toBe("codex:acct-a");
    expect(acct.provider).toBe("codex");
    expect(acct.label).toBe("codex · acct-a");
    expect(acct.percent).toBe(60);
    expect(acct.status).toBe("ok");
    expect(acct.spend).toEqual({ amount: 12.5, limit: 50.0, currency: "usd", display: "$12.50 / $50.00" });
    expect(acct.window.primaryResetAt).toBe(1784780140.0);
  });

  test("percent >= threshold triggers warn item even with single sample (no ETA)", async () => {
    const adapter = sequencedAdapter([fixture("snapshot-multi.json")], [0]);
    const result = await adapter.poll();
    const item = result.items.find((i) => i.detail.includes("acct-over") || i.title.includes("acct-over"));
    expect(item).toBeDefined();
    expect(item!.severity).toBe("warn");
    const underItem = result.items.find((i) => i.title.includes("acct-under"));
    expect(underItem).toBeUndefined();
  });

  test("ETA < threshold triggers warn item even when percent is below percentWarn", async () => {
    const files = [fixture("snapshot-eta-trigger.json"), fixture("snapshot-eta-trigger-2.json")];
    const times = [0, 10 * 60_000];
    const adapter = sequencedAdapter(files, times);

    await adapter.poll();
    const result = await adapter.poll();

    const panel = result.panels.find((p) => p.id === "limits")!;
    const data = panel.data as { accounts: Array<{ slug: string; percent: number; capEtaMinutes: number | null }> };
    const acct = data.accounts[0];
    if (!acct) throw new Error("expected an account in panel data");
    expect(acct.percent).toBe(55); // below default percentWarn=75
    expect(acct.capEtaMinutes).not.toBeNull();
    expect(acct.capEtaMinutes!).toBeLessThan(60);

    const item = result.items.find((i) => i.kind === "limit");
    expect(item).toBeDefined();
    expect(item!.severity).toBe("warn");
  });

  test("declarative auto-resolve: warn item is simply omitted once back under threshold", async () => {
    const files = [fixture("snapshot-multi.json"), fixture("snapshot-under.json"), fixture("snapshot-under.json")];
    const times = [0, 60_000, 120_000];
    const adapter = sequencedAdapter(files, times);

    const r1 = await adapter.poll();
    expect(r1.items.some((i) => i.title.includes("acct-over") && i.severity === "warn")).toBe(true);

    const r2 = await adapter.poll();
    expect(r2.items.some((i) => i.title.includes("acct-over"))).toBe(false);

    const r3 = await adapter.poll();
    expect(r3.items.some((i) => i.title.includes("acct-over"))).toBe(false);
  });

  test("stale snapshot: panel marked stale, no alert items emitted despite over-threshold data", async () => {
    const adapter = createSystrayAdapter({
      snapshotPath: "/fake/systray/health_cache.json",
      readFileImpl: () => fixture("snapshot-stale.json"),
      statImpl: () => 0, // file mtime at t=0
      now: () => 10 * 60_000, // now is 10 minutes later
      staleAfterS: 120, // 2 minutes
    });

    const result = await adapter.poll();
    const panel = result.panels.find((p) => p.id === "limits")!;
    const data = panel.data as { stale: boolean; ageSeconds: number };
    expect(data.stale).toBe(true);
    expect(data.ageSeconds).toBeGreaterThan(120);
    expect(result.items).toHaveLength(0);
  });

  test("fresh snapshot within stale_after_s: panel not stale", async () => {
    const adapter = createSystrayAdapter({
      snapshotPath: "/fake/systray/health_cache.json",
      readFileImpl: () => fixture("snapshot-60pct.json"),
      statImpl: () => 0,
      now: () => 30_000, // 30s later, under default staleAfterS
      staleAfterS: 120,
    });
    const result = await adapter.poll();
    const panel = result.panels.find((p) => p.id === "limits")!;
    const data = panel.data as { stale: boolean };
    expect(data.stale).toBe(false);
  });

  test("tolerates one atomic-rename race by retrying the read once", async () => {
    let attempts = 0;
    const adapter = createSystrayAdapter({
      snapshotPath: "/fake/systray/health_cache.json",
      readFileImpl: (path) => {
        if (path.endsWith("accounts.json")) throw new Error("ENOENT: no accounts registry");
        attempts++;
        if (attempts === 1) {
          throw new Error("ENOENT: no such file or directory (rename race)");
        }
        return fixture("snapshot-60pct.json");
      },
      statImpl: () => 0,
      now: () => 0,
    });

    const result = await adapter.poll();
    expect(attempts).toBe(2);
    const panel = result.panels.find((p) => p.id === "limits")!;
    const data = panel.data as { accounts: Array<{ slug: string }> };
    expect(data.accounts).toHaveLength(1);
  });

  test("uses accounts.json alias as the display label", async () => {
    const adapter = createSystrayAdapter({
      snapshotPath: "/fake/systray/health_cache.json",
      readFileImpl: (path) => {
        if (path.endsWith("accounts.json")) {
          return JSON.stringify({
            accounts: [{ slug: "acct-a", alias: "display-a" }],
          });
        }
        return fixture("snapshot-60pct.json");
      },
      statImpl: () => 0,
      now: () => 0,
    });
    const result = await adapter.poll();
    const panel = result.panels.find((p) => p.id === "limits")!;
    const data = panel.data as { accounts: Array<{ slug: string; label: string }> };
    const acct = data.accounts[0];
    if (!acct) throw new Error("expected an account in panel data");
    expect(acct.slug).toBe("codex:acct-a");
    expect(acct.label).toBe("codex · display-a");
  });

  test("propagates failure when the read fails twice in a row (retry-once only)", async () => {
    const adapter = createSystrayAdapter({
      snapshotPath: "/fake/systray/health_cache.json",
      readFileImpl: () => {
        throw new Error("ENOENT: persistent failure");
      },
      statImpl: () => 0,
      now: () => 0,
    });

    await expect(adapter.poll()).rejects.toThrow();
  });

  test("merges codex and claude health caches with provider-prefixed labels", async () => {
    const adapter = createSystrayAdapter({
      snapshotPath: "/fake/systray/health_cache.json",
      claudeSnapshotPath: "/fake/systray/claude_health_cache.json",
      readFileImpl: (path) => {
        if (path.endsWith("claude_accounts.json")) {
          return JSON.stringify({ accounts: [{ slug: "zync", alias: "zync" }] });
        }
        if (path.endsWith("accounts.json")) {
          return JSON.stringify({ accounts: [{ slug: "acct-a", alias: "acct-a" }] });
        }
        if (path.endsWith("claude_health_cache.json")) {
          return JSON.stringify({
            zync: {
              status: "ok",
              primary_used_pct: 53,
              secondary_used_pct: 45,
              primary_reset_at: 1785928200,
              secondary_reset_at: 1786442400,
              spend: null,
            },
          });
        }
        return fixture("snapshot-60pct.json");
      },
      statImpl: () => 0,
      existsImpl: (path) => path.endsWith("claude_health_cache.json") || path.endsWith("health_cache.json"),
      now: () => 0,
    });

    const result = await adapter.poll();
    const panel = result.panels.find((p) => p.id === "limits")!;
    const data = panel.data as {
      accounts: Array<{ slug: string; provider: string; label: string; percent: number | null }>;
    };
    expect(data.accounts).toHaveLength(2);
    expect(data.accounts[0]).toMatchObject({
      slug: "codex:acct-a",
      provider: "codex",
      label: "codex · acct-a",
      percent: 60,
    });
    expect(data.accounts[1]).toMatchObject({
      slug: "claude:zync",
      provider: "claude",
      label: "claude · zync",
      percent: 53,
    });
  });
});
