import { describe, expect, it, vi } from "vitest";
import {
  buildInsightSnapshot,
  type Anomaly,
  type InsightsDbAccess,
  type InsightSnapshot,
} from "../router/insights-anomalies.js";

// Fixed reference date for all deterministic tests.
// now = 2026-04-10 (Friday)
// periodStart = 2026-04-01, periodEnd = 2026-04-30
// twoWeeksFromNow = 2026-04-24
// countBusinessDays(2026-01-01, 2026-12-31) = 261
// countBusinessDays(2026-01-01, 2026-04-10) = 72
// expectedBurnRate = 72 / 261 ≈ 0.2759  (≈28%)
const NOW = new Date("2026-04-10");

// ---------------------------------------------------------------------------
// Shared fixture shapes
// ---------------------------------------------------------------------------

interface InsightProjectRecord {
  id: string;
  name: string;
  budgetCents: number;
  startDate: Date;
  endDate: Date;
  demandRequirements: {
    headcount: number;
    startDate: Date;
    endDate: Date;
    _count: { assignments: number };
  }[];
  assignments: {
    resourceId: string;
    startDate: Date;
    endDate: Date;
    hoursPerDay: number;
    dailyCostCents: number;
    status: string;
  }[];
}

interface InsightResourceRecord {
  id: string;
  displayName: string;
  availability: unknown;
}

interface InsightAssignmentLoadRecord {
  resourceId: string;
  hoursPerDay: number;
}

// ---------------------------------------------------------------------------
// DB mock factory
// ---------------------------------------------------------------------------

function makeDb(
  projects: InsightProjectRecord[] = [],
  resources: InsightResourceRecord[] = [],
  assignments: InsightAssignmentLoadRecord[] = [],
): InsightsDbAccess {
  return {
    project: { findMany: vi.fn().mockResolvedValue(projects) },
    resource: { findMany: vi.fn().mockResolvedValue(resources) },
    assignment: { findMany: vi.fn().mockResolvedValue(assignments) },
  };
}

// ---------------------------------------------------------------------------
// Reusable project factories
// ---------------------------------------------------------------------------

/** Base project with no anomaly indicators (zero budget, no demands, no assignments). */
function baseProject(overrides: Partial<InsightProjectRecord> = {}): InsightProjectRecord {
  return {
    id: "p1",
    name: "Base Project",
    budgetCents: 0,
    startDate: new Date("2026-01-01"),
    endDate: new Date("2026-12-31"),
    demandRequirements: [],
    assignments: [],
    ...overrides,
  };
}

/** Project fixture designed to trigger a budget WARNING.
 *
 * Budget = 1,000,000 cents.  One assignment: dailyCostCents=5000 from 2026-01-01 to 2026-04-10.
 * assignmentDays = countBizDays(2026-01-01, min(2026-04-10, now)) = 72
 * totalCostCents = 5000 * 72 = 360,000
 * actualBurnRate = 360,000 / 1,000,000 = 0.36
 * expectedBurnRate ≈ 0.2759
 * 0.36 > 0.2759 * 1.2 = 0.3311 → WARNING
 * 0.36 > 0.2759 * 1.5 = 0.4138 → NO (not critical)
 */
const budgetWarningProject: InsightProjectRecord = {
  id: "p-budget-warn",
  name: "Budget Warning",
  budgetCents: 1_000_000,
  startDate: new Date("2026-01-01"),
  endDate: new Date("2026-12-31"),
  demandRequirements: [],
  assignments: [
    {
      resourceId: "r1",
      startDate: new Date("2026-01-01"),
      endDate: new Date("2026-04-10"),
      hoursPerDay: 8,
      dailyCostCents: 5_000,
      status: "ACTIVE",
    },
  ],
};

/** Project fixture designed to trigger a budget CRITICAL.
 *
 * dailyCostCents=6000; actualBurnRate = 6000*72/1,000,000 = 0.432
 * 0.432 > 0.2759 * 1.5 = 0.4138 → CRITICAL
 */
const budgetCriticalProject: InsightProjectRecord = {
  id: "p-budget-crit",
  name: "Budget Critical",
  budgetCents: 1_000_000,
  startDate: new Date("2026-01-01"),
  endDate: new Date("2026-12-31"),
  demandRequirements: [],
  assignments: [
    {
      resourceId: "r1",
      startDate: new Date("2026-01-01"),
      endDate: new Date("2026-04-10"),
      hoursPerDay: 8,
      dailyCostCents: 6_000,
      status: "ACTIVE",
    },
  ],
};

/** Project fixture with on-track burn rate (no budget anomaly).
 *
 * dailyCostCents=3000; actualBurnRate = 3000*72/1,000,000 = 0.216
 * 0.216 ≤ 0.2759 * 1.2 = 0.3311 → NO anomaly
 */
const budgetOnTrackProject: InsightProjectRecord = {
  id: "p-budget-ok",
  name: "Budget OK",
  budgetCents: 1_000_000,
  startDate: new Date("2026-01-01"),
  endDate: new Date("2026-12-31"),
  demandRequirements: [],
  assignments: [
    {
      resourceId: "r1",
      startDate: new Date("2026-01-01"),
      endDate: new Date("2026-04-10"),
      hoursPerDay: 8,
      dailyCostCents: 3_000,
      status: "ACTIVE",
    },
  ],
};

/** Standard resource with 8 h/day availability (5×8 = 40 h/week). */
const resource8h: InsightResourceRecord = {
  id: "r1",
  displayName: "Busy Dev",
  availability: { mon: 8, tue: 8, wed: 8, thu: 8, fri: 8 },
};

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

describe("buildInsightSnapshot", () => {
  // -------------------------------------------------------------------------
  // 1. Empty data
  // -------------------------------------------------------------------------

  describe("empty data", () => {
    it("returns empty anomalies and a zero summary when there is no data", async () => {
      const snapshot = await buildInsightSnapshot(makeDb(), NOW);

      expect(snapshot.anomalies).toHaveLength(0);
      expect(snapshot.summary).toEqual({
        total: 0,
        criticalCount: 0,
        budget: 0,
        staffing: 0,
        timeline: 0,
        utilization: 0,
      });
    });
  });

  // -------------------------------------------------------------------------
  // 2. Budget anomalies
  // -------------------------------------------------------------------------

  describe("budget anomalies", () => {
    it("produces no anomaly when the burn rate is on track (actual <= expected * 1.2)", async () => {
      const snapshot = await buildInsightSnapshot(makeDb([budgetOnTrackProject]), NOW);
      expect(snapshot.anomalies.filter((a) => a.type === "budget")).toHaveLength(0);
    });

    it("produces a warning anomaly when burn rate is 20–50% over expected", async () => {
      const snapshot = await buildInsightSnapshot(makeDb([budgetWarningProject]), NOW);
      const budgetAnomalies = snapshot.anomalies.filter((a) => a.type === "budget");

      expect(budgetAnomalies).toHaveLength(1);
      expect(budgetAnomalies[0]).toMatchObject<Partial<Anomaly>>({
        type: "budget",
        severity: "warning",
        entityId: "p-budget-warn",
        entityName: "Budget Warning",
      });
      expect(budgetAnomalies[0]?.message).toContain("36% spent at 28% timeline");
    });

    it("produces a critical anomaly when burn rate is >50% over expected", async () => {
      const snapshot = await buildInsightSnapshot(makeDb([budgetCriticalProject]), NOW);
      const budgetAnomalies = snapshot.anomalies.filter((a) => a.type === "budget");

      expect(budgetAnomalies).toHaveLength(1);
      expect(budgetAnomalies[0]).toMatchObject<Partial<Anomaly>>({
        type: "budget",
        severity: "critical",
        entityId: "p-budget-crit",
      });
    });

    it("produces no budget anomaly when budgetCents is 0", async () => {
      const project: InsightProjectRecord = {
        ...budgetWarningProject,
        id: "p-zero-budget",
        budgetCents: 0,
      };
      const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
      expect(snapshot.anomalies.filter((a) => a.type === "budget")).toHaveLength(0);
    });
  });

  // -------------------------------------------------------------------------
  // 3. Staffing anomalies
  // -------------------------------------------------------------------------

  describe("staffing anomalies", () => {
    it("produces a warning when >30% of positions are unfilled and demand starts within 2 weeks", async () => {
      // headcount=4, assignments=2 → unfilled=2 → 50% > 30%, ≤60% → warning
      const project = baseProject({
        id: "p-staff-warn",
        name: "Staffing Warning",
        demandRequirements: [
          {
            headcount: 4,
            startDate: new Date("2026-04-15"), // within 2 weeks (≤ 2026-04-24)
            endDate: new Date("2026-06-01"), // after now (≥ 2026-04-10)
            _count: { assignments: 2 },
          },
        ],
      });

      const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
      const staffingAnomalies = snapshot.anomalies.filter((a) => a.type === "staffing");

      expect(staffingAnomalies).toHaveLength(1);
      expect(staffingAnomalies[0]).toMatchObject<Partial<Anomaly>>({
        type: "staffing",
        severity: "warning",
        entityId: "p-staff-warn",
        entityName: "Staffing Warning",
      });
      expect(staffingAnomalies[0]?.message).toContain("2 of 4 positions unfilled");
    });

    it("produces a critical anomaly when >60% of positions are unfilled", async () => {
      // headcount=5, assignments=1 → unfilled=4 → 80% > 60% → critical
      const project = baseProject({
        id: "p-staff-crit",
        name: "Staffing Critical",
        demandRequirements: [
          {
            headcount: 5,
            startDate: new Date("2026-04-15"),
            endDate: new Date("2026-06-01"),
            _count: { assignments: 1 },
          },
        ],
      });

      const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
      const staffingAnomalies = snapshot.anomalies.filter((a) => a.type === "staffing");

      expect(staffingAnomalies).toHaveLength(1);
      expect(staffingAnomalies[0]).toMatchObject<Partial<Anomaly>>({
        type: "staffing",
        severity: "critical",
        entityId: "p-staff-crit",
      });
    });

    it("produces no staffing anomaly when demand starts beyond 2 weeks", async () => {
      // startDate=2026-05-01 > twoWeeksFromNow(2026-04-24) → demand excluded
      const project = baseProject({
        id: "p-staff-future",
        demandRequirements: [
          {
            headcount: 5,
            startDate: new Date("2026-05-01"),
            endDate: new Date("2026-06-01"),
            _count: { assignments: 0 },
          },
        ],
      });

      const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
      expect(snapshot.anomalies.filter((a) => a.type === "staffing")).toHaveLength(0);
    });

    it("produces no staffing anomaly when unfill rate is ≤30%", async () => {
      // headcount=4, assignments=3 → unfilled=1 → 25% ≤ 30% → no anomaly
      const project = baseProject({
        id: "p-staff-ok",
        demandRequirements: [
          {
            headcount: 4,
            startDate: new Date("2026-04-15"),
            endDate: new Date("2026-06-01"),
            _count: { assignments: 3 },
          },
        ],
      });

      const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
      expect(snapshot.anomalies.filter((a) => a.type === "staffing")).toHaveLength(0);
    });
  });

  // -------------------------------------------------------------------------
  // 4. Timeline anomalies
  // -------------------------------------------------------------------------

  describe("timeline anomalies", () => {
    it("produces a warning when an ACTIVE/CONFIRMED assignment extends past the project end date", async () => {
      const project = baseProject({
        id: "p-timeline-warn",
        name: "Timeline Warning",
        endDate: new Date("2026-06-30"),
        assignments: [
          {
            resourceId: "r1",
            startDate: new Date("2026-01-01"),
            endDate: new Date("2026-07-31"), // past project end
            hoursPerDay: 8,
            dailyCostCents: 0,
            status: "ACTIVE",
          },
        ],
      });

      const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
      const timelineAnomalies = snapshot.anomalies.filter((a) => a.type === "timeline");

      expect(timelineAnomalies).toHaveLength(1);
      expect(timelineAnomalies[0]).toMatchObject<Partial<Anomaly>>({
        type: "timeline",
        severity: "warning",
        entityId: "p-timeline-warn",
        entityName: "Timeline Warning",
      });
      expect(timelineAnomalies[0]?.message).toContain(
        "1 assignment(s) extend beyond the project end date",
      );
      expect(timelineAnomalies[0]?.message).toContain("2026-06-30");
    });

    it("produces no timeline anomaly when all assignments end before the project end date", async () => {
      const project = baseProject({
        id: "p-timeline-ok",
        endDate: new Date("2026-12-31"),
        assignments: [
          {
            resourceId: "r1",
            startDate: new Date("2026-01-01"),
            endDate: new Date("2026-06-30"), // before project end
            hoursPerDay: 8,
            dailyCostCents: 0,
            status: "ACTIVE",
          },
        ],
      });

      const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);
      expect(snapshot.anomalies.filter((a) => a.type === "timeline")).toHaveLength(0);
    });
  });

  // -------------------------------------------------------------------------
  // 5. Utilization anomalies
  // -------------------------------------------------------------------------

  describe("utilization anomalies", () => {
    // The utilization query window is 2026-04-01 to 2026-04-30.
    // Any assignment mocked in the `assignments` array is treated as active in that window.

    it("produces a warning when utilization is between 110% and 130%", async () => {
      // dailyAvailableHours = 40/5 = 8 h/day
      // bookedHours = 9 → utilization = Math.round(9/8*100) = 113% → warning
      const snapshot = await buildInsightSnapshot(
        makeDb([], [resource8h], [{ resourceId: "r1", hoursPerDay: 9 }]),
        NOW,
      );
      const utilAnomalies = snapshot.anomalies.filter((a) => a.type === "utilization");

      expect(utilAnomalies).toHaveLength(1);
      expect(utilAnomalies[0]).toMatchObject<Partial<Anomaly>>({
        type: "utilization",
        severity: "warning",
        entityId: "r1",
        entityName: "Busy Dev",
      });
      expect(utilAnomalies[0]?.message).toContain("113% utilization");
    });

    it("produces a critical anomaly when utilization exceeds 130%", async () => {
      // bookedHours = 11 → utilization = Math.round(11/8*100) = 138% → critical
      const snapshot = await buildInsightSnapshot(
        makeDb([], [resource8h], [{ resourceId: "r1", hoursPerDay: 11 }]),
        NOW,
      );
      const utilAnomalies = snapshot.anomalies.filter((a) => a.type === "utilization");

      expect(utilAnomalies).toHaveLength(1);
      expect(utilAnomalies[0]).toMatchObject<Partial<Anomaly>>({
        type: "utilization",
        severity: "critical",
        entityId: "r1",
      });
      expect(utilAnomalies[0]?.message).toContain("138% utilization");
    });

    it("produces a warning when utilization is under 40% but has some bookings", async () => {
      // bookedHours = 2 → utilization = Math.round(2/8*100) = 25% < 40% → underutil warning
      const snapshot = await buildInsightSnapshot(
        makeDb([], [resource8h], [{ resourceId: "r1", hoursPerDay: 2 }]),
        NOW,
      );
      const utilAnomalies = snapshot.anomalies.filter((a) => a.type === "utilization");

      expect(utilAnomalies).toHaveLength(1);
      expect(utilAnomalies[0]).toMatchObject<Partial<Anomaly>>({
        type: "utilization",
        severity: "warning",
        entityId: "r1",
      });
      expect(utilAnomalies[0]?.message).toContain("25% utilization");
    });

    it("produces no anomaly when resource availability is null", async () => {
      const nullAvailResource: InsightResourceRecord = {
        id: "r-null",
        displayName: "Null Avail",
        availability: null,
      };
      const snapshot = await buildInsightSnapshot(
        makeDb([], [nullAvailResource], [{ resourceId: "r-null", hoursPerDay: 15 }]),
        NOW,
      );
      expect(snapshot.anomalies.filter((a) => a.type === "utilization")).toHaveLength(0);
    });
  });

  // -------------------------------------------------------------------------
  // 6. Summary
  // -------------------------------------------------------------------------

  describe("summary counts", () => {
    it("counts anomalies by type correctly", async () => {
      // Trigger: 1 budget warning + 1 timeline warning.
      // Keep endDate=2026-12-31 so the budget math still yields a warning
      // (same as budgetWarningProject), then add a separate assignment that
      // extends past that same end date to create the timeline warning.
      const project: InsightProjectRecord = {
        ...budgetWarningProject,
        id: "p-summary",
        name: "Summary Project",
        // endDate stays 2026-12-31 (inherited from budgetWarningProject spread)
        assignments: [
          ...budgetWarningProject.assignments,
          {
            resourceId: "r2",
            startDate: new Date("2026-01-01"),
            endDate: new Date("2027-01-31"), // extends past 2026-12-31
            hoursPerDay: 8,
            dailyCostCents: 0,
            status: "ACTIVE",
          },
        ],
      };

      const snapshot = await buildInsightSnapshot(makeDb([project]), NOW);

      expect(snapshot.summary.budget).toBe(1);
      expect(snapshot.summary.timeline).toBe(1);
      expect(snapshot.summary.staffing).toBe(0);
      expect(snapshot.summary.utilization).toBe(0);
      expect(snapshot.summary.total).toBe(2);
    });

    it("counts only critical-severity anomalies in criticalCount", async () => {
      // 1 budget warning + 1 budget critical from two separate projects
      const snapshot = await buildInsightSnapshot(
        makeDb([budgetWarningProject, budgetCriticalProject]),
        NOW,
      );

      expect(snapshot.summary.budget).toBe(2);
      expect(snapshot.summary.criticalCount).toBe(1);
    });
  });

  // -------------------------------------------------------------------------
  // 7. Sorting
  // -------------------------------------------------------------------------

  describe("sorting", () => {
    it("places critical anomalies before warning anomalies regardless of insertion order", async () => {
      // budgetWarningProject triggers a warning; budgetCriticalProject triggers a critical
      const snapshot = await buildInsightSnapshot(
        makeDb([budgetWarningProject, budgetCriticalProject]),
        NOW,
      );

      const anomalies = snapshot.anomalies;
      expect(anomalies.length).toBeGreaterThanOrEqual(2);

      const firstCriticalIdx = anomalies.findIndex((a) => a.severity === "critical");
      const lastWarningIdx = anomalies.map((a) => a.severity).lastIndexOf("warning");

      expect(firstCriticalIdx).toBeLessThan(lastWarningIdx);
    });
  });

  // -------------------------------------------------------------------------
  // 8. Combined scenarios
  // -------------------------------------------------------------------------

  describe("combined scenarios", () => {
    it("detects budget, timeline, and utilization anomalies in a single call", async () => {
      // Keep endDate=2026-12-31 so the budget warn math holds, and add a
      // timeline-overrun assignment that ends in 2027.
      const combinedProject: InsightProjectRecord = {
        ...budgetWarningProject,
        id: "p-combined",
        name: "Combined Project",
        assignments: [
          // Budget-triggering assignment (same as budgetWarningProject)
          ...budgetWarningProject.assignments,
          // Timeline-overrun assignment (past 2026-12-31)
          {
            resourceId: "r1",
            startDate: new Date("2026-01-01"),
            endDate: new Date("2027-01-31"),
            hoursPerDay: 8,
            dailyCostCents: 0,
            status: "CONFIRMED",
          },
        ],
      };

      const snapshot = await buildInsightSnapshot(
        makeDb(
          [combinedProject],
          [resource8h],
          [{ resourceId: "r1", hoursPerDay: 11 }], // utilization critical
        ),
        NOW,
      );

      const types = snapshot.anomalies.map((a) => a.type);
      expect(types).toContain("budget");
      expect(types).toContain("timeline");
      expect(types).toContain("utilization");
      expect(snapshot.summary.total).toBeGreaterThanOrEqual(3);
    });

    it("detects staffing, timeline, and utilization anomalies simultaneously for different entities", async () => {
      const staffingProject = baseProject({
        id: "p-multi",
        name: "Multi Anomaly Project",
        endDate: new Date("2026-06-30"),
        demandRequirements: [
          {
            headcount: 5,
            startDate: new Date("2026-04-15"),
            endDate: new Date("2026-06-01"),
            _count: { assignments: 1 }, // 80% unfilled → critical
          },
        ],
        assignments: [
          {
            resourceId: "r1",
            startDate: new Date("2026-01-01"),
            endDate: new Date("2026-07-31"), // extends past project end
            hoursPerDay: 8,
            dailyCostCents: 0,
            status: "ACTIVE",
          },
        ],
      });

      const snapshot = await buildInsightSnapshot(
        makeDb(
          [staffingProject],
          [resource8h],
          [{ resourceId: "r1", hoursPerDay: 9 }], // 113% → utilization warning
        ),
        NOW,
      );

      expect(snapshot.summary.staffing).toBeGreaterThanOrEqual(1);
      expect(snapshot.summary.timeline).toBeGreaterThanOrEqual(1);
      expect(snapshot.summary.utilization).toBeGreaterThanOrEqual(1);
      expect(snapshot.summary.total).toBeGreaterThanOrEqual(3);

      // Critical staffing anomaly must come before warnings in sorted output
      const criticalIdx = snapshot.anomalies.findIndex((a) => a.severity === "critical");
      expect(criticalIdx).toBe(0);
    });
  });
});
