import type { CommandContext } from "@tailor-platform/erp-kit/core";
import { createKyselyMock } from "@tailor-platform/sdk/vitest";
import { describe, expect, it } from "vitest";
import type { Namespace } from "../generated/kysely-tailordb";
import { InvalidRetentionTtlError } from "../lib/errors.generated";
import { baseFailedAudit, baseQueuedAudit } from "../testing/fixtures";
import {
  run,
  type RunNotificationAuditRetentionSweepInput,
} from "./runNotificationAuditRetentionSweep";

const MS_PER_DAY = 24 * 60 * 60 * 1000;

// Extract the cutoff bound from a recorded update: the SET clause carries
// updatedAt (= now), the WHERE clause carries the cutoff — the earliest Date
// parameter is the cutoff.
function cutoffParam(parameters: readonly unknown[]): Date {
  const dates = parameters.filter((p): p is Date => p instanceof Date);
  expect(dates.length).toBeGreaterThanOrEqual(2);
  return new Date(Math.min(...dates.map((d) => d.getTime())));
}

describe("runNotificationAuditRetentionSweep", () => {
  const systemCtx: CommandContext = {
    actorId: "scheduler-system",
    permissions: [],
  };

  const baseInput: RunNotificationAuditRetentionSweepInput = {};

  // 100 days ago — past the default 90-day TTL.
  const oldDate = new Date(Date.now() - 100 * MS_PER_DAY);

  it("anonymizes user-driven NotificationDeliveryAudit rows older than the TTL with a set-based update that clears errorDetail and tombstones occurredBy", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    // 1. tombstone update, 2. system-row errorDetail clear, 3. destination sweep
    mock.enqueueResults(
      [{ ...baseFailedAudit, occurredAt: oldDate, occurredBy: "TOMBSTONED", errorDetail: null }],
      [],
      [],
    );

    const result = await mock.withTx((trx) => run(trx, baseInput, systemCtx));

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.rowsAnonymized).toBe(1);
    }
    const setValues = mock.updates[0]?.updateValues() as
      | { errorDetail?: string | null; occurredBy?: string }
      | undefined;
    expect(setValues?.errorDetail).toBeNull();
    expect(setValues?.occurredBy).toBe("TOMBSTONED");
  });

  it("scopes the tombstone update by the cutoff and excludes rows whose occurredBy is already system / TOMBSTONED", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    mock.enqueueResults([], [], []);

    await mock.withTx((trx) => run(trx, baseInput, systemCtx));

    const tombstone = mock.updates[0];
    expect(tombstone).toBeDefined();
    expect(tombstone?.sql).toContain("not in");
    // The exclusion list binds both sentinels.
    expect(tombstone?.parameters).toContain("system");
    expect(tombstone?.parameters).toContain("TOMBSTONED");
    // The WHERE clause binds the cutoff (now - 90d by default).
    const cutoff = cutoffParam(tombstone?.parameters ?? []);
    expect(Math.abs(cutoff.getTime() - (Date.now() - 90 * MS_PER_DAY))).toBeLessThan(10_000);
  });

  it("clears errorDetail on system / tombstoned rows older than the TTL without rewriting occurredBy", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    mock.enqueueResults([], [{ ...baseQueuedAudit, occurredAt: oldDate, errorDetail: null }], []);

    const result = await mock.withTx((trx) => run(trx, baseInput, systemCtx));

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.rowsAnonymized).toBe(1);
    }
    const clear = mock.updates[1];
    const setValues = clear?.updateValues();
    expect(setValues?.errorDetail).toBeNull();
    // occurredBy is preserved: the system sentinel is not a user identifier.
    expect(setValues).not.toHaveProperty("occurredBy");
    // Anonymized rows (errorDetail already null) are excluded from the scan.
    expect(clear?.sql).toContain("is not null");
    expect(clear?.parameters).toContain("system");
    expect(clear?.parameters).toContain("TOMBSTONED");
  });

  it("preserves audit row id, notificationId, eventType, occurredAt, and errorClass", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    mock.enqueueResults([], [], []);

    await mock.withTx((trx) => run(trx, baseInput, systemCtx));

    for (const update of mock.updates) {
      const setValues = update.updateValues();
      expect(setValues).not.toHaveProperty("id");
      expect(setValues).not.toHaveProperty("notificationId");
      expect(setValues).not.toHaveProperty("eventType");
      expect(setValues).not.toHaveProperty("occurredAt");
      expect(setValues).not.toHaveProperty("errorClass");
    }
  });

  it("respects the ttlDays override when computing the cutoff", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    mock.enqueueResults([], [], []);

    const result = await mock.withTx((trx) => run(trx, { ...baseInput, ttlDays: 30 }, systemCtx));

    expect(result.ok).toBe(true);
    const cutoff = cutoffParam(mock.updates[0]?.parameters ?? []);
    expect(Math.abs(cutoff.getTime() - (Date.now() - 30 * MS_PER_DAY))).toBeLessThan(10_000);
  });

  it("rejects a zero, negative, fractional, or NaN ttlDays with INVALID_RETENTION_TTL without touching any row", async () => {
    for (const ttlDays of [0, -5, Number.NaN, 1.5]) {
      const mock = createKyselyMock<Namespace[keyof Namespace]>();

      const result = await mock.withTx((trx) => run(trx, { ttlDays }, systemCtx));

      expect(result.ok).toBe(false);
      if (!result.ok) {
        expect(result.error).toBeInstanceOf(InvalidRetentionTtlError);
      }
      expect(mock.updates.length).toBe(0);
    }
  });

  it("reports rowsAnonymized=0 when no rows are past the TTL (the WHERE bounds exclude un-aged rows)", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    mock.enqueueResults([], [], []);

    const result = await mock.withTx((trx) => run(trx, baseInput, systemCtx));

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.rowsAnonymized).toBe(0);
      expect(result.value.destinationRowsAnonymized).toBe(0);
    }
  });

  it("is idempotent across sweep passes: already-anonymized rows are excluded by the WHERE predicates", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    // The DB matches nothing on the second pass because the tombstone update
    // excludes occurredBy IN (system, TOMBSTONED) and the clear update
    // excludes errorDetail IS NULL.
    mock.enqueueResults([], [], []);

    const result = await mock.withTx((trx) => run(trx, baseInput, systemCtx));

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.rowsAnonymized).toBe(0);
    }
    expect(mock.updates[0]?.sql).toContain("not in");
    expect(mock.updates[1]?.sql).toContain("is not null");
  });

  it("does not delete any audit rows or alter parent Notifications", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    mock.enqueueResults(
      [{ ...baseFailedAudit, occurredAt: oldDate, occurredBy: "TOMBSTONED", errorDetail: null }],
      [],
      [],
    );

    await mock.withTx((trx) => run(trx, baseInput, systemCtx));

    expect(mock.deletes.length).toBe(0);
    for (const update of mock.updates) {
      // The SET shapes match the audit / destination-log rows, never a
      // Notification shape (deliveryStatus / etc.).
      const setValues = update.updateValues();
      expect(setValues).not.toHaveProperty("deliveryStatus");
      expect(setValues).not.toHaveProperty("recipientUserId");
    }
  });

  it("propagates a storage failure instead of silently swallowing it (the transaction aborts)", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    mock.setQueryResolver((query) => {
      if (query.node.kind === "UpdateQueryNode") {
        throw new Error("transient storage error");
      }
      return [];
    });

    await expect(mock.withTx((trx) => run(trx, baseInput, systemCtx))).rejects.toThrow(
      "transient storage error",
    );
  });

  it("a BOUNCED webhook arriving after the sweep on an anonymized Notification still writes its audit row and updates deliveryStatus", async () => {
    // Contract-level assertion: the sweep does not delete or alter parent
    // Notifications. A subsequent recordDeliveryEvent(BOUNCED) call from
    // the EMAIL adapter still finds the parent Notification and writes a
    // fresh audit row.
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    mock.enqueueResults(
      [{ ...baseFailedAudit, occurredAt: oldDate, occurredBy: "TOMBSTONED", errorDetail: null }],
      [],
      [],
    );

    const result = await mock.withTx((trx) => run(trx, baseInput, systemCtx));

    expect(result.ok).toBe(true);
    // The sweep only touched audit / destination-log rows.
    for (const update of mock.updates) {
      const setValues = update.updateValues();
      expect(setValues).not.toHaveProperty("deliveryStatus");
    }
  });

  const baseDestinationLog = {
    id: "ddl-1",
    sourceType: "PurchaseOrder",
    sourceId: "po-1",
    targetType: "PurchaseOrder",
    targetId: "po-1",
    channelId: "SLACK",
    externalChannelRef: "C0123456789",
    status: "FAILED" as const,
    providerMessageId: null,
    providerResponse: null,
    failureReason: null,
    retryCount: 0,
    attemptedAt: oldDate,
    completedAt: oldDate,
    createdAt: oldDate,
    updatedAt: null,
  };

  it("anonymizes DestinationDeliveryLog rows older than the TTL by clearing providerResponse and failureReason while preserving the structural shell", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    // Both audit updates match nothing; the destination sweep clears one row.
    mock.enqueueResults([], [], [{ ...baseDestinationLog }]);

    const result = await mock.withTx((trx) => run(trx, baseInput, systemCtx));

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.destinationRowsAnonymized).toBe(1);
    }
    const destination = mock.updates[2];
    const setValues = destination?.updateValues();
    expect(setValues?.providerResponse).toBeNull();
    expect(setValues?.failureReason).toBeNull();
    expect(setValues).not.toHaveProperty("status");
    expect(setValues).not.toHaveProperty("targetType");
    // Scoped by the attemptedAt cutoff and the not-yet-anonymized predicate.
    const cutoff = cutoffParam(destination?.parameters ?? []);
    expect(Math.abs(cutoff.getTime() - (Date.now() - 90 * MS_PER_DAY))).toBeLessThan(10_000);
    expect(destination?.sql).toContain("is not null");
  });

  it("does not anonymize DestinationDeliveryLog rows newer than the TTL or already anonymized", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    // The WHERE bounds (attemptedAt < cutoff AND free-form fields not yet
    // null) match nothing.
    mock.enqueueResults([], [], []);

    const result = await mock.withTx((trx) => run(trx, baseInput, systemCtx));

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.destinationRowsAnonymized).toBe(0);
    }
  });

  it("reports the DestinationDeliveryLog rows anonymized separately from the NotificationDeliveryAudit count", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    // Tombstone update hits one row, system-row clear none, destination one.
    mock.enqueueResults(
      [{ ...baseFailedAudit, occurredAt: oldDate, occurredBy: "TOMBSTONED", errorDetail: null }],
      [],
      [{ ...baseDestinationLog }],
    );

    const result = await mock.withTx((trx) => run(trx, baseInput, systemCtx));

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.rowsAnonymized).toBe(1);
      expect(result.value.destinationRowsAnonymized).toBe(1);
    }
  });
});
