import { env, fetchMock } from "cloudflare:test";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { runGc, type Manifest } from "../src/gc";
import { activateTestSite, seedLicenseAndBucket } from "./helpers";

const S3_ORIGIN = "https://s3.eu-central-1.wasabisys.com";
const NOW = new Date("2026-07-23T03:00:00Z");
const LIVE_SHA = "1".repeat(64);
const ORPHAN_SHA = "2".repeat(64);
const MANIFEST_SHA = "3".repeat(64);
const STALE_PENDING_SHA = "4".repeat(64);
const RECENT_ORPHAN_SHA = "5".repeat(64);
const PENDING_REF_SHA = "6".repeat(64);

beforeAll(() => {
  fetchMock.activate();
  fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());

async function insertBlob(siteId: string, sha: string, status: string, createdAt: string) {
  await env.DB.prepare(
    "INSERT INTO blobs (site_id, sha256, size_bytes, status, created_at) VALUES (?, ?, 100, ?, ?)",
  )
    .bind(siteId, sha, status, createdAt)
    .run();
}

async function gzipBytes(value: unknown): Promise<Uint8Array> {
  const stream = new Response(JSON.stringify(value)).body!.pipeThrough(
    new CompressionStream("gzip"),
  );
  return new Uint8Array(await new Response(stream).arrayBuffer());
}

describe("runGc", () => {
  beforeEach(seedLicenseAndBucket); // retention_days = 30

  it("expires old snapshots, deletes orphan and stale pending blobs, keeps referenced ones", async () => {
    const { site_id } = await activateTestSite();
    // Snapshot vivante (hier) + snapshot expirée (il y a 60 jours)
    await insertBlob(site_id, MANIFEST_SHA, "stored", "2026-07-22T00:00:00Z");
    await insertBlob(site_id, LIVE_SHA, "stored", "2026-07-22T00:00:00Z");
    await insertBlob(site_id, ORPHAN_SHA, "stored", "2026-05-01T00:00:00Z");
    await insertBlob(site_id, STALE_PENDING_SHA, "pending", "2026-07-20T00:00:00Z");
    // Orphelin stored RÉCENT (7 h avant NOW, < 24 h) : protégé par la grâce, aucun DELETE.
    await insertBlob(site_id, RECENT_ORPHAN_SHA, "stored", "2026-07-22T20:00:00Z");
    // Pending vieux de 3 j MAIS référencé par la snapshot vivante : ne doit PAS être balayé.
    await insertBlob(site_id, PENDING_REF_SHA, "pending", "2026-07-20T00:00:00Z");
    await env.DB.prepare("UPDATE sites SET storage_bytes_used = 400 WHERE id = ?")
      .bind(site_id)
      .run();
    await env.DB.prepare(
      "INSERT INTO snapshots (site_id, ts, manifest_sha256, stats_json, status) VALUES (?, '2026-07-22T04:00:00Z', ?, '{}', 'complete'), (?, '2026-05-24T04:00:00Z', ?, '{}', 'complete')",
    )
      .bind(site_id, MANIFEST_SHA, site_id, ORPHAN_SHA)
      .run();

    const manifest: Manifest = {
      version: 1,
      files: [
        { p: "wp-content/x.php", h: LIVE_SHA, s: 100, m: 1721730000 },
        { p: "wp-content/pending.php", h: PENDING_REF_SHA, s: 100, m: 1721730000 },
      ],
      db: [],
    };
    fetchMock
      .get(S3_ORIGIN)
      .intercept({
        path: `/wpback-test-001/s/${site_id}/blobs/${MANIFEST_SHA}`,
        method: "GET",
      })
      .reply(200, await gzipBytes(manifest)); // le plugin uploade le manifest gzippé
    fetchMock
      .get(S3_ORIGIN)
      .intercept({
        path: `/wpback-test-001/s/${site_id}/blobs/${ORPHAN_SHA}`,
        method: "DELETE",
      })
      .reply(204, "");
    fetchMock
      .get(S3_ORIGIN)
      .intercept({
        path: `/wpback-test-001/s/${site_id}/blobs/${STALE_PENDING_SHA}`,
        method: "DELETE",
      })
      .reply(204, "");

    const result = await runGc(env, NOW);
    expect(result.expiredSnapshots).toBe(1);
    expect(result.deletedBlobs).toBe(2);

    const remaining = await env.DB.prepare(
      "SELECT sha256 FROM blobs WHERE site_id = ? ORDER BY sha256",
    )
      .bind(site_id)
      .all<{ sha256: string }>();
    expect(remaining.results.map((r) => r.sha256)).toEqual([
      LIVE_SHA,
      MANIFEST_SHA,
      RECENT_ORPHAN_SHA,
      PENDING_REF_SHA,
    ]);

    const site = await env.DB.prepare("SELECT storage_bytes_used FROM sites WHERE id = ?")
      .bind(site_id)
      .first<{ storage_bytes_used: number }>();
    expect(site?.storage_bytes_used).toBe(300); // -100 (orphan stored), pending sans décrément, recent orphan protégé
  });

  it("skips the whole site when a live manifest cannot be read", async () => {
    const { site_id } = await activateTestSite();
    await insertBlob(site_id, MANIFEST_SHA, "stored", "2026-07-22T00:00:00Z");
    await insertBlob(site_id, ORPHAN_SHA, "stored", "2026-05-01T00:00:00Z");
    await env.DB.prepare(
      "INSERT INTO snapshots (site_id, ts, manifest_sha256, stats_json, status) VALUES (?, '2026-07-22T04:00:00Z', ?, '{}', 'complete')",
    )
      .bind(site_id, MANIFEST_SHA)
      .run();
    fetchMock
      .get(S3_ORIGIN)
      .intercept({
        path: `/wpback-test-001/s/${site_id}/blobs/${MANIFEST_SHA}`,
        method: "GET",
      })
      .reply(500, "");

    const result = await runGc(env, NOW);
    expect(result.deletedBlobs).toBe(0);
    const count = await env.DB.prepare("SELECT COUNT(*) AS n FROM blobs WHERE site_id = ?")
      .bind(site_id)
      .first<{ n: number }>();
    expect(count?.n).toBe(2);
  });

  it("skips the whole site when a live manifest has an invalid shape", async () => {
    const { site_id } = await activateTestSite();
    await insertBlob(site_id, MANIFEST_SHA, "stored", "2026-07-22T00:00:00Z");
    // Orphelin ancien qui serait supprimable si le site n'était pas skippé.
    await insertBlob(site_id, ORPHAN_SHA, "stored", "2026-05-01T00:00:00Z");
    await env.DB.prepare(
      "INSERT INTO snapshots (site_id, ts, manifest_sha256, stats_json, status) VALUES (?, '2026-07-22T04:00:00Z', ?, '{}', 'complete')",
    )
      .bind(site_id, MANIFEST_SHA)
      .run();
    // Manifest gzippé dont les entrées files n'ont PAS de clé `h` (v2 futur / plugin bugué).
    fetchMock
      .get(S3_ORIGIN)
      .intercept({
        path: `/wpback-test-001/s/${site_id}/blobs/${MANIFEST_SHA}`,
        method: "GET",
      })
      .reply(200, await gzipBytes({ version: 1, files: [{ p: "x.php", s: 1, m: 1 }], db: [] }));
    // Aucun interceptor DELETE déclaré : la non-suppression est prouvée par
    // disableNetConnect + assertNoPendingInterceptors (un DELETE tenté throwerait).

    const result = await runGc(env, NOW);
    expect(result.deletedBlobs).toBe(0);

    const remaining = await env.DB.prepare(
      "SELECT sha256 FROM blobs WHERE site_id = ? ORDER BY sha256",
    )
      .bind(site_id)
      .all<{ sha256: string }>();
    expect(remaining.results.map((r) => r.sha256)).toEqual([ORPHAN_SHA, MANIFEST_SHA]);
  });

  it("never expires the most recent (last) live snapshot of a site", async () => {
    const { site_id } = await activateTestSite();
    // UNE seule snapshot complete, vieille de 60 j (retention 30) : elle ne doit PAS expirer.
    await insertBlob(site_id, MANIFEST_SHA, "stored", "2026-05-01T00:00:00Z");
    await insertBlob(site_id, LIVE_SHA, "stored", "2026-05-01T00:00:00Z");
    await env.DB.prepare("UPDATE sites SET storage_bytes_used = 200 WHERE id = ?")
      .bind(site_id)
      .run();
    await env.DB.prepare(
      "INSERT INTO snapshots (site_id, ts, manifest_sha256, stats_json, status) VALUES (?, '2026-05-24T04:00:00Z', ?, '{}', 'complete')",
    )
      .bind(site_id, MANIFEST_SHA)
      .run();

    const manifest: Manifest = {
      version: 1,
      files: [{ p: "wp-content/x.php", h: LIVE_SHA, s: 100, m: 1721730000 }],
      db: [],
    };
    // La snapshot restant vivante, son manifest est bien lu.
    fetchMock
      .get(S3_ORIGIN)
      .intercept({
        path: `/wpback-test-001/s/${site_id}/blobs/${MANIFEST_SHA}`,
        method: "GET",
      })
      .reply(200, await gzipBytes(manifest));

    const result = await runGc(env, NOW);
    expect(result.expiredSnapshots).toBe(0);
    expect(result.deletedBlobs).toBe(0);

    const snap = await env.DB.prepare("SELECT status FROM snapshots WHERE site_id = ?")
      .bind(site_id)
      .first<{ status: string }>();
    expect(snap?.status).toBe("complete");

    const remaining = await env.DB.prepare(
      "SELECT sha256 FROM blobs WHERE site_id = ? ORDER BY sha256",
    )
      .bind(site_id)
      .all<{ sha256: string }>();
    expect(remaining.results.map((r) => r.sha256)).toEqual([LIVE_SHA, MANIFEST_SHA]);
  });

  it("isolates sweep failures per site: a failing DELETE on site A does not affect site B", async () => {
    const A_ORPHAN = "a".repeat(64);
    const B_ORPHAN = "b".repeat(64);
    const a = await activateTestSite("https://a.example.com");
    const b = await activateTestSite("https://b.example.com");
    // Chaque site : un orphelin ancien, aucune snapshot (referenced vide → éligible au sweep).
    await insertBlob(a.site_id, A_ORPHAN, "stored", "2026-05-01T00:00:00Z");
    await insertBlob(b.site_id, B_ORPHAN, "stored", "2026-05-01T00:00:00Z");
    await env.DB.prepare("UPDATE sites SET storage_bytes_used = 100 WHERE id = ?")
      .bind(b.site_id)
      .run();

    // Site A : le DELETE Wasabi échoue (403) → deleteObject throw.
    fetchMock
      .get(S3_ORIGIN)
      .intercept({
        path: `/wpback-test-001/s/${a.site_id}/blobs/${A_ORPHAN}`,
        method: "DELETE",
      })
      .reply(403, "");
    // Site B : le DELETE réussit (204).
    fetchMock
      .get(S3_ORIGIN)
      .intercept({
        path: `/wpback-test-001/s/${b.site_id}/blobs/${B_ORPHAN}`,
        method: "DELETE",
      })
      .reply(204, "");

    const result = await runGc(env, NOW);
    // runGc ne throw pas ; seul le blob de B est supprimé.
    expect(result.deletedBlobs).toBe(1);

    const aRow = await env.DB.prepare(
      "SELECT COUNT(*) AS n FROM blobs WHERE site_id = ? AND sha256 = ?",
    )
      .bind(a.site_id, A_ORPHAN)
      .first<{ n: number }>();
    // D1-first (Finding B) : la row part avant le DELETE Wasabi qui throw.
    // L'objet reste sur Wasabi (fuite bénigne, direction leak-safe) mais jamais
    // une row pointant du vide. La panne de A n'affecte toujours pas B.
    expect(aRow?.n).toBe(0);

    const bRow = await env.DB.prepare(
      "SELECT COUNT(*) AS n FROM blobs WHERE site_id = ? AND sha256 = ?",
    )
      .bind(b.site_id, B_ORPHAN)
      .first<{ n: number }>();
    expect(bRow?.n).toBe(0);

    const bSite = await env.DB.prepare("SELECT storage_bytes_used FROM sites WHERE id = ?")
      .bind(b.site_id)
      .first<{ storage_bytes_used: number }>();
    expect(bSite?.storage_bytes_used).toBe(0); // 100 - 100
  });
});
