import { blobKey, deleteObject, getObjectJson } from "./lib/wasabi";
import { wasabiCreds } from "./routes/storage";
import type { Env } from "./types";

// Clés courtes du manifest plugin : p = path, h = sha256, s = size, m = mtime.
// Le blob manifest est gzippé par le plugin ; getObjectJson le dégunzippe.
export interface Manifest {
  version: number;
  files: { p: string; h: string; s: number; m: number }[];
  db: { table: string; h: string; s: number; checksum: string }[];
}

const PENDING_MAX_AGE_MS = 24 * 3600 * 1000;
const STORED_GRACE_MS = 24 * 3600 * 1000;
const MANIFEST_SHA_RE = /^[a-f0-9]{64}$/;

export function assertValidManifest(m: Manifest, key: string): void {
  if (
    m === null ||
    typeof m !== "object" ||
    m.version !== 1 ||
    !Array.isArray(m.files) ||
    !Array.isArray(m.db)
  ) {
    throw new Error(`invalid manifest shape at ${key}`);
  }
  for (const f of m.files) {
    if (typeof f?.h !== "string" || !MANIFEST_SHA_RE.test(f.h)) {
      throw new Error(`invalid file entry in manifest ${key}`);
    }
  }
  for (const d of m.db) {
    if (typeof d?.h !== "string" || !MANIFEST_SHA_RE.test(d.h)) {
      throw new Error(`invalid db entry in manifest ${key}`);
    }
  }
}

interface GcSiteRow {
  id: string;
  bucket_name: string;
  bucket_region: string;
}

export async function runGc(
  env: Env,
  now: Date,
): Promise<{ expiredSnapshots: number; deletedBlobs: number }> {
  const creds = wasabiCreds(env);
  const nowIso = now.toISOString();

  const expired = await env.DB.prepare(
    `UPDATE snapshots SET status = 'expired'
     WHERE status = 'complete'
       AND ts < (
         SELECT strftime('%Y-%m-%dT%H:%M:%SZ', datetime(?1, '-' || l.retention_days || ' days'))
         FROM sites s JOIN licenses l ON l.license_key = s.license_key
         WHERE s.id = snapshots.site_id
       )
       AND ts < (
         SELECT MAX(ts) FROM snapshots s2
         WHERE s2.site_id = snapshots.site_id AND s2.status = 'complete'
       )`,
  )
    .bind(nowIso)
    .run();

  const sites = await env.DB.prepare(
    `SELECT s.id, b.name AS bucket_name, b.region AS bucket_region
     FROM sites s JOIN buckets b ON b.id = s.bucket_id`,
  ).all<GcSiteRow>();

  let deletedBlobs = 0;

  for (const site of sites.results) {
    const bucket = { name: site.bucket_name, region: site.bucket_region };
    const referenced = new Set<string>();

    try {
      const live = await env.DB.prepare(
        "SELECT manifest_sha256 FROM snapshots WHERE site_id = ? AND status = 'complete'",
      )
        .bind(site.id)
        .all<{ manifest_sha256: string }>();

      for (const row of live.results) {
        referenced.add(row.manifest_sha256);
        const manifest = await getObjectJson<Manifest>(
          creds,
          bucket,
          blobKey(site.id, row.manifest_sha256),
        );
        assertValidManifest(manifest, blobKey(site.id, row.manifest_sha256));
        for (const f of manifest.files) referenced.add(f.h);
        for (const d of manifest.db) referenced.add(d.h);
      }
    } catch (err) {
      console.error(`gc: skipping site ${site.id}, manifest read failed`, err);
      continue;
    }

    const blobs = await env.DB.prepare(
      "SELECT sha256, size_bytes, status, created_at FROM blobs WHERE site_id = ?",
    )
      .bind(site.id)
      .all<{ sha256: string; size_bytes: number; status: string; created_at: string }>();

    let freed = 0;
    try {
      for (const b of blobs.results) {
        const orphanStored =
          b.status === "stored" &&
          !referenced.has(b.sha256) &&
          now.getTime() - Date.parse(b.created_at) > STORED_GRACE_MS;
        const stalePending =
          b.status === "pending" &&
          !referenced.has(b.sha256) &&
          now.getTime() - Date.parse(b.created_at) > PENDING_MAX_AGE_MS;
        if (!orphanStored && !stalePending) continue;

        // D1-first, conditional DELETE : si un sign-upload `exists` a ré-armé
        // created_at entre le SELECT et ce DELETE, la ligne ne matche plus et
        // le blob survit (protège la dédup contre le sweep — TOCTOU).
        const gone = await env.DB.prepare(
          "DELETE FROM blobs WHERE site_id = ? AND sha256 = ? AND created_at = ?",
        )
          .bind(site.id, b.sha256, b.created_at)
          .run();
        if ((gone.meta.changes ?? 0) === 0) continue;
        // Row supprimée : on efface l'objet. Un crash ici laisse un objet
        // orphelin sur Wasabi (fuite bénigne), jamais une row pointant du vide.
        await deleteObject(creds, bucket, blobKey(site.id, b.sha256));
        deletedBlobs += 1;
        if (b.status === "stored") freed += b.size_bytes;
      }
    } catch (err) {
      console.error(`gc: sweep failed for site ${site.id}`, err);
    } finally {
      if (freed > 0) {
        await env.DB.prepare(
          "UPDATE sites SET storage_bytes_used = MAX(0, storage_bytes_used - ?) WHERE id = ?",
        )
          .bind(freed, site.id)
          .run();
      }
    }
  }

  return { expiredSnapshots: expired.meta.changes ?? 0, deletedBlobs };
}
