import { describe, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readFileSync } from "node:fs";
import type { ActivityEvent, ActivityReadOptions } from "./types";
import { buildSeries, clearActivityCache, readActivity, readActivityCached } from "./read";

const CONTROLLER_FIXTURE = join(import.meta.dir, "../../test/fixtures/activity/controller.jsonl");
const ACTIONS_FIXTURE = join(import.meta.dir, "../../test/fixtures/activity/actions.jsonl");
const WINDOW_FROM = "2026-07-31T23:00:00.000Z";
const WINDOW_TO = "2026-08-01T02:00:00.000Z";

function fixturesSources() {
  const controllerFixture = readFileSync(CONTROLLER_FIXTURE, "utf8");
  const actionsFixture = readFileSync(ACTIONS_FIXTURE, "utf8");
  return {
    controller: {
      path: "controller.jsonl",
      readFileImpl: () => controllerFixture,
      existsSyncImpl: () => true,
    },
    actions: {
      path: "actions.jsonl",
      readFileImpl: () => actionsFixture,
      existsSyncImpl: () => true,
    },
  };
}

describe("readActivity", () => {
  test("adds facet counts computed over the full match set before limit truncation", () => {
    const result = readActivity({
      from: WINDOW_FROM,
      to: WINDOW_TO,
      limit: 2,
      sources: fixturesSources(),
    });

    expect(result.total).toBe(6);
    expect(result.truncated).toBe(true);
    expect(result.events).toHaveLength(2);
    expect(result.categoryCounts).toEqual([
      { category: "buildbox", count: 3 },
      { category: "service", count: 3 },
    ]);
    expect(result.sourceCounts).toEqual([
      { source: "actions", count: 3 },
      { source: "controller", count: 3 },
    ]);
  });

  test("filters events, facets, coverage, and series capabilities by registered source id", () => {
    const result = readActivity({
      from: WINDOW_FROM,
      to: WINDOW_TO,
      sourceIds: ["actions"],
      sources: fixturesSources(),
    });

    expect(result.total).toBe(3);
    expect(result.events.every((event) => event.source === "actions")).toBe(true);
    expect(result.sourceCounts).toEqual([{ source: "actions", count: 3 }]);
    expect(result.categoryCounts).toEqual([{ category: "service", count: 3 }]);
    expect(result.coverage.map((source) => source.id)).toEqual(["actions"]);
  });

  test("keeps other category counts when filtering by one category", () => {
    const result = readActivity({
      from: WINDOW_FROM,
      to: WINDOW_TO,
      categories: ["buildbox"],
      limit: 1,
      sources: fixturesSources(),
    });

    expect(result.total).toBe(3);
    expect(result.truncated).toBe(true);
    expect(result.events.every((event) => event.category === "buildbox")).toBe(true);
    expect(result.categoryCounts).toEqual([
      { category: "buildbox", count: 3 },
      { category: "service", count: 3 },
    ]);
    expect(result.sourceCounts).toEqual([
      { source: "actions", count: 3 },
      { source: "controller", count: 3 },
    ]);
  });

  test("narrows category and source facets with non-category filters", () => {
    const result = readActivity({
      from: WINDOW_FROM,
      to: WINDOW_TO,
      severityFloor: "error",
      sources: fixturesSources(),
    });

    expect(result.total).toBe(3);
    expect(result.categoryCounts).toEqual([
      { category: "service", count: 2 },
      { category: "buildbox", count: 1 },
    ]);
    expect(result.sourceCounts).toEqual([
      { source: "actions", count: 2 },
      { source: "controller", count: 1 },
    ]);
  });

  test("omits facets with zero matches instead of fabricating zeros", () => {
    const result = readActivity({
      from: WINDOW_FROM,
      to: WINDOW_TO,
      actors: ["human"],
      sources: fixturesSources(),
    });

    expect(result.total).toBe(3);
    expect(result.categoryCounts).toEqual([{ category: "service", count: 3 }]);
    expect(result.sourceCounts).toEqual([{ source: "actions", count: 3 }]);
    expect(result.categoryCounts.find((entry) => entry.category === "buildbox")).toBeUndefined();
  });

  test("respects limit after sorting and clamps above max to 2000", () => {
    const payload = Array.from({ length: 2505 }, (_, index) => {
      const ordinal = index + 1;
      return `{"ts":${ordinal},"action":"auto_unblock","root":"run-${ordinal}"}`;
    }).join("\n");

    const result = readActivity({
      limit: 2500,
      sources: {
        controller: {
          path: "/tmp/no-such-controller.jsonl",
          existsSyncImpl: () => false,
        },
        landq: {
          repoRoots: ["/tmp/no-such-landq"],
          existsSyncImpl: () => false,
        },
        actions: {
          path: "/tmp/no-such-actions.jsonl",
          existsSyncImpl: () => false,
        },
        agentSessions: {
          root: "/tmp/no-such-agent-sessions",
          existsSyncImpl: () => false,
        },
        fleetHarden: {
          path: "/tmp/no-such-fleet-harden.jsonl",
          existsSyncImpl: () => false,
        },
        notifications: {
          path: "/tmp/no-such-notifications.jsonl",
          existsSyncImpl: () => false,
        },
        notifGate: {
          path: "/tmp/no-such-notif-gate.jsonl",
          existsSyncImpl: () => false,
        },
        reaper: {
          path: "reaper.jsonl",
          readFileImpl: () => payload,
          existsSyncImpl: () => true,
        },
        toolSuggest: {
          path: "/tmp/no-such-tool-suggest.jsonl",
          existsSyncImpl: () => false,
        },
        kubernetes: {
          path: "/tmp/no-such-kubernetes.jsonl",
          existsSyncImpl: () => false,
        },
        hookFires: {
          path: "/tmp/no-such-hook-fires.jsonl",
          existsSyncImpl: () => false,
        },
      } satisfies Required<NonNullable<ActivityReadOptions["sources"]>>,
    });

    expect(result.limit).toBe(2000);
    expect(result.truncated).toBe(true);
    expect(result.total).toBe(2505);
    expect(result.events).toHaveLength(2000);
    expect(result.events.at(0)?.id).toBe("reaper:2505");
    expect(result.events.at(-1)?.id).toBe("reaper:506");
  });

  test("normalizes evidence, correlation, source metadata, project facets, and series", () => {
    const result = readActivity({
      from: WINDOW_FROM,
      to: WINDOW_TO,
      sources: fixturesSources(),
    });

    expect(result.coverage.find((source) => source.id === "kubernetes")?.authority).toBe("derived");
    expect(result.coverage.filter((source) => source.id !== "kubernetes").every((source) => source.authority === "authoritative")).toBe(true);
    expect(result.coverage.every((source) => typeof source.storage === "string" && source.storage.length > 0)).toBe(true);
    expect(result.coverage.every((source) => typeof source.retention === "string" && source.retention.length > 0)).toBe(true);
    expect(result.coverage.every((source) => typeof source.queryBounds === "string" && source.queryBounds.length > 0)).toBe(true);
    expect(result.projectCounts).toEqual([
      { project: "multideal", count: 2 },
      { project: "omega", count: 1 },
    ]);
    expect(result.series?.map((series) => series.id)).toEqual(["activeAgents", "logVolume", "commits", "bots", "builds", "incidents"]);
    expect(result.series?.find((series) => series.id === "logVolume")?.status).toBe("unavailable");
    expect(result.series?.find((series) => series.id === "logVolume")?.reason).toContain("Coverage incomplete");

    const controller = result.events.find((event) => event.source === "controller");
    expect(controller).toBeDefined();
    if (!controller) throw new Error("controller fixture event missing");
    expect(controller.lifecycle).toBeDefined();
    expect(controller.result).toBeDefined();
    expect(controller.correlation).toBeDefined();
    expect(controller.evidence).toEqual([
      {
        sourceId: "controller",
        sourcePath: "controller.jsonl",
        recordId: controller.id,
        href: `/logs/controller?event=${encodeURIComponent(controller.id)}`,
      },
    ]);
  });

  test("bypasses the production snapshot for injected source readers", () => {
    clearActivityCache();
    const first = readActivityCached({ from: WINDOW_FROM, sources: fixturesSources() });
    const second = readActivityCached({ from: WINDOW_FROM, sources: fixturesSources() });
    expect(first).not.toBe(second);
    expect(first.events).toEqual(second.events);
  });

  test("keeps unsupported series unavailable instead of inferring them from unrelated events", () => {
    const result = readActivity({ from: WINDOW_FROM, to: WINDOW_TO, sources: fixturesSources() });
    for (const id of ["commits", "bots", "builds", "incidents"] as const) {
      expect(result.series?.find((series) => series.id === id)?.status).toBe("unavailable");
    }
  });

  test("counts overlapping canonical session intervals instead of start events", () => {
    const make = (id: string, session: string, ts: string, lifecycle: "started" | "completed"): ActivityEvent => ({ id, session, ts, lifecycle, category: "agent", source: "agent-sessions", actor: "agent", severity: "info", title: id });
    const events = [
      make("a-start", "a", "2026-08-08T10:00:00.000Z", "started"),
      make("b-start", "b", "2026-08-08T10:10:00.000Z", "started"),
      make("a-end", "a", "2026-08-08T10:20:00.000Z", "completed"),
      make("b-end", "b", "2026-08-08T10:30:00.000Z", "completed"),
    ];
    const coverage = [{ id: "agent-sessions", label: "sessions", category: "agent" as const, path: "sessions", storage: "test", queryBounds: "test", status: "ok" as const, records: 4, totalRecords: 4, skipped: 0 }];
    const capabilities = new Map([["activeAgents" as const, new Set(["agent-sessions"])]]);
    const series = buildSeries(events, coverage, capabilities, Date.parse("2026-08-08T10:00:00.000Z"), Date.parse("2026-08-08T10:30:00.000Z")).find((entry) => entry.id === "activeAgents");
    expect(series?.status).toBe("available");
    expect(Math.max(...(series?.points.map((point) => point.value) ?? []))).toBe(2);
  });

  test("counts a short interval inside a bucket and excludes an interval ending at bucket start", () => {
    const start = Date.parse("2026-08-08T10:00:00.000Z");
    const end = Date.parse("2026-08-08T10:02:00.000Z");
    const make = (id: string, session: string, offset: number, lifecycle: "started" | "completed"): ActivityEvent => ({ id, session, ts: new Date(start + offset).toISOString(), lifecycle, category: "agent", source: "agent-sessions", actor: "agent", severity: "info", title: id });
    const events = [make("old-start", "old", -60_000, "started"), make("old-end", "old", 0, "completed"), make("short-start", "short", 20_000, "started"), make("short-end", "short", 40_000, "completed")];
    const coverage = [{ id: "agent-sessions", label: "sessions", category: "agent" as const, path: "sessions", storage: "test", queryBounds: "test", status: "ok" as const, records: 4, totalRecords: 4, skipped: 0 }];
    const active = buildSeries(events, coverage, new Map([["activeAgents" as const, new Set(["agent-sessions"])]]), start, end).find((series) => series.id === "activeAgents");
    expect(active?.points[0]?.value).toBe(1);
  });

  test("orders simultaneous end before start for half-open intervals", () => {
    const start = Date.parse("2026-08-08T10:00:00.000Z");
    const boundary = start + 60_000;
    const end = start + 120_000;
    const make = (id: string, session: string, ts: number, lifecycle: "started" | "completed"): ActivityEvent => ({ id, session, ts: new Date(ts).toISOString(), lifecycle, category: "agent", source: "agent-sessions", actor: "agent", severity: "info", title: id });
    const events = [make("a-start", "a", start, "started"), make("a-end", "a", boundary, "completed"), make("b-start", "b", boundary, "started"), make("b-end", "b", end, "completed")];
    const coverage = [{ id: "agent-sessions", label: "sessions", category: "agent" as const, path: "sessions", storage: "test", queryBounds: "test", status: "ok" as const, records: 4, totalRecords: 4, skipped: 0 }];
    const active = buildSeries(events, coverage, new Map([["activeAgents" as const, new Set(["agent-sessions"])]]), start, end).find((series) => series.id === "activeAgents");
    expect(Math.max(...(active?.points.map((point) => point.value) ?? []))).toBe(1);
  });

  test("distinguishes authoritative quiet coverage from unknown retention", () => {
    const start = Date.parse("2026-08-08T10:00:00.000Z");
    const end = start + 120_000;
    const capabilities = new Map([["logVolume" as const, new Set(["controller"])]]);
    const base = { id: "controller", label: "controller", category: "service" as const, path: "events", storage: "test", queryBounds: "test", status: "ok" as const, records: 0, totalRecords: 0, skipped: 0 };
    const quiet = buildSeries([], [{ ...base, coverageFrom: new Date(start).toISOString() }], capabilities, start, end).find((series) => series.id === "logVolume");
    expect(quiet?.status).toBe("available");
    expect(quiet?.points.every((point) => point.value === 0)).toBe(true);
    const unknown = buildSeries([], [base], capabilities, start, end).find((series) => series.id === "logVolume");
    expect(unknown).toMatchObject({ status: "unavailable", reason: expect.stringContaining("Authoritative retained coverage is unknown") });
  });

  test("excludes open ledger records because they do not prove a running process", () => {
    const start = Date.parse("2026-08-08T10:00:00.000Z");
    const end = Date.parse("2026-08-08T10:30:00.000Z");
    const event: ActivityEvent = { id: "agent-sessions:open:start", session: "open", ts: new Date(start).toISOString(), lifecycle: "started", category: "agent", source: "agent-sessions", actor: "agent", severity: "info", title: "open" };
    const coverage = [{ id: "agent-sessions", label: "sessions", category: "agent" as const, path: "sessions", storage: "test", queryBounds: "test", status: "ok" as const, records: 1, totalRecords: 1, skipped: 0 }];
    const series = buildSeries([event], coverage, new Map([["activeAgents" as const, new Set(["agent-sessions"])]]), start, end).find((entry) => entry.id === "activeAgents");
    expect(series).toMatchObject({
      label: "Concurrent completed sessions",
      status: "unavailable",
      reason: expect.stringContaining("open ledger records are excluded"),
      points: [],
    });
  });

  test("reconciles raw session record IDs to completed intervals without duplicates", () => {
    const start = Date.parse("2026-08-08T10:00:00.000Z");
    const end = Date.parse("2026-08-08T10:30:00.000Z");
    const make = (recordId: string, session: string, offset: number, lifecycle: "started" | "completed"): ActivityEvent => ({ id: `agent-sessions:${recordId}`, session, ts: new Date(start + offset).toISOString(), lifecycle, category: "agent", source: "agent-sessions", actor: "agent", severity: "info", title: recordId });
    const raw = [
      make("a:start", "a", 0, "started"),
      make("a:end", "a", 20 * 60_000, "completed"),
      make("b:start", "b", 10 * 60_000, "started"),
      make("b:end", "b", 30 * 60_000, "completed"),
      make("open:start", "open", 5 * 60_000, "started"),
      make("a:start-duplicate", "a", 0, "started"),
    ];
    const coverage = [{ id: "agent-sessions", label: "sessions", category: "agent" as const, path: "sessions", storage: "test", queryBounds: "test", status: "ok" as const, records: raw.length, totalRecords: raw.length, skipped: 0 }];
    const series = buildSeries(raw, coverage, new Map([["activeAgents" as const, new Set(["agent-sessions"])]]), start, end).find((entry) => entry.id === "activeAgents");
    expect(new Set(raw.map((event) => event.id)).size).toBe(raw.length);
    expect(new Set(raw.filter((event) => event.lifecycle === "completed").map((event) => event.session))).toEqual(new Set(["a", "b"]));
    expect(Math.max(...(series?.points.map((point) => point.value) ?? []))).toBe(2);
  });

  test("filters by project while keeping project facet alternatives", () => {
    const result = readActivity({
      from: WINDOW_FROM,
      to: WINDOW_TO,
      projects: ["multideal"],
      sources: fixturesSources(),
    });

    expect(result.events.length).toBeGreaterThan(0);
    expect(result.events.every((event) => event.project === "multideal")).toBe(true);
    expect(result.projectCounts).toContainEqual({ project: "multideal", count: 2 });
  });

  test("keeps other source coverage when one source throws while still returning events", () => {
    const rootDir = mkdtempSync(join(tmpdir(), "overdeck-read-activity-"));
    const sessionsDir = join(rootDir, "sessions");
    mkdirSync(sessionsDir, { recursive: true });

    const controllerFixture = readFileSync(CONTROLLER_FIXTURE, "utf8");

    try {
      const result = readActivity({
        from: WINDOW_FROM,
        to: WINDOW_TO,
        sources: {
          landq: {
            repoRoots: ["/tmp/no-such-landq"],
            existsSyncImpl: () => {
              throw new Error("landq unreadable");
            },
          },
          controller: {
            path: "controller.jsonl",
            readFileImpl: () => controllerFixture,
            existsSyncImpl: () => true,
          },
          actions: {
            path: "/tmp/no-such-actions.jsonl",
            existsSyncImpl: () => false,
          },
          agentSessions: {
            root: rootDir,
            existsSyncImpl: () => true,
            readdirSyncImpl: () => [],
          },
          fleetHarden: {
            path: "/tmp/no-such-fleet-harden.jsonl",
            existsSyncImpl: () => false,
          },
          notifications: {
            path: "/tmp/no-such-notifications.jsonl",
            existsSyncImpl: () => false,
          },
          reaper: {
            path: "/tmp/no-such-reaper.log",
            existsSyncImpl: () => false,
          },
        },
      });

      expect(result.total).toBeGreaterThan(0);
      const controllers = result.events.filter((event) => event.source === "controller");
      expect(controllers.length).toBeGreaterThan(0);
      const landqCoverage = result.coverage.find((entry) => entry.id === "landq");
      expect(landqCoverage?.status).toBe("error");
      expect(landqCoverage?.error).toContain("landq unreadable");
      expect(result.suppressedNotifications).toBeDefined();
      expect(result.suppressedNotifications).toHaveProperty("status");
    } finally {
      rmSync(rootDir, { recursive: true, force: true });
    }
  });
});
