import { Hono } from "hono";
import { assertValidManifest, type Manifest } from "../gc";
import { apiError } from "../lib/errors";
import { blobKey, getObjectJson } from "../lib/wasabi";
import { getBucket, wasabiCreds } from "./storage";
import type { AppEnv } from "../types";

const FUTURE_SKEW_MS = 15 * 60 * 1000;
const STORED_PAGE_SIZE = 5000;

const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
const SHA256_RE = /^[a-f0-9]{64}$/;

export interface SnapshotStats {
  files_total: number;
  bytes_total: number;
  files_changed: number;
  bytes_uploaded: number;
  db_tables: number;
}

const STAT_KEYS: (keyof SnapshotStats)[] = [
  "files_total",
  "bytes_total",
  "files_changed",
  "bytes_uploaded",
  "db_tables",
];

function parseStats(value: unknown): SnapshotStats | null {
  if (typeof value !== "object" || value === null) return null;
  const obj = value as Record<string, unknown>;
  const out = {} as SnapshotStats;
  for (const k of STAT_KEYS) {
    const v = obj[k];
    if (typeof v !== "number" || !Number.isInteger(v) || v < 0) return null;
    out[k] = v;
  }
  return out;
}

export const snapshots = new Hono<AppEnv>();

snapshots.post("/commit", async (c) => {
  const site = c.get("site");
  const body = (await c.req.json().catch(() => null)) as
    | { ts?: unknown; manifest_sha256?: unknown; stats?: unknown }
    | null;
  if (
    !body ||
    typeof body.ts !== "string" ||
    !ISO_UTC_RE.test(body.ts) ||
    typeof body.manifest_sha256 !== "string" ||
    !SHA256_RE.test(body.manifest_sha256)
  ) {
    return apiError(c, 400, "invalid_request", "ts (ISO 8601 UTC) and manifest_sha256 required");
  }
  // La regex accepte des dates calendaires impossibles (« 2026-13-01… ») ; on les
  // rejette ici. DOIT venir avant le clamp futur : NaN > x est false, donc un ts
  // invalide passerait silencieusement le check de skew.
  if (Number.isNaN(Date.parse(body.ts))) {
    return apiError(c, 400, "invalid_request", "ts is not a valid calendar date");
  }
  // Finding C : un ts client trop dans le futur fausserait la rétention (GC).
  if (Date.parse(body.ts) > Date.now() + FUTURE_SKEW_MS) {
    return apiError(c, 400, "invalid_request", "ts is too far in the future");
  }
  const stats = parseStats(body.stats);
  if (!stats) return apiError(c, 400, "invalid_request", "stats must contain the 5 integer counters");

  const manifestBlob = await c.env.DB.prepare(
    "SELECT status FROM blobs WHERE site_id = ? AND sha256 = ? AND status = 'stored'",
  )
    .bind(site.id, body.manifest_sha256)
    .first();
  if (!manifestBlob) {
    return apiError(c, 400, "manifest_missing", "Manifest blob is not stored");
  }

  // Finding A2 : valider le CONTENU du manifest avant de figer la snapshot.
  // Sans ça, un backup dont des blobs ont été balayés pendant qu'il tournait
  // committerait une snapshot `complete` pointant du vide.
  let manifest: Manifest;
  try {
    manifest = await getObjectJson<Manifest>(
      wasabiCreds(c.env),
      await getBucket(c),
      blobKey(site.id, body.manifest_sha256),
    );
  } catch {
    return apiError(c, 400, "manifest_unreadable", "Manifest blob could not be read from storage");
  }
  try {
    assertValidManifest(manifest, body.manifest_sha256);
  } catch {
    return apiError(c, 400, "manifest_invalid", "Manifest shape is invalid");
  }

  const needed = new Set<string>();
  for (const f of manifest.files) needed.add(f.h);
  for (const d of manifest.db) needed.add(d.h);

  // Ensemble des sha256 `stored` du site par pagination keyset (scalable
  // jusqu'à ~100k entrées, réponse bornée : on ne renvoie que le compte).
  const stored = new Set<string>();
  let cursor = "";
  for (;;) {
    const page = await c.env.DB.prepare(
      "SELECT sha256 FROM blobs WHERE site_id = ? AND status = 'stored' AND sha256 > ? ORDER BY sha256 LIMIT ?",
    )
      .bind(site.id, cursor, STORED_PAGE_SIZE)
      .all<{ sha256: string }>();
    for (const r of page.results) stored.add(r.sha256);
    if (page.results.length < STORED_PAGE_SIZE) break;
    cursor = page.results[page.results.length - 1]!.sha256;
  }

  let missing = 0;
  for (const h of needed) if (!stored.has(h)) missing += 1;
  if (missing > 0) {
    return apiError(
      c,
      400,
      "manifest_blobs_missing",
      `${missing} referenced blob(s) are not stored for this site`,
    );
  }

  const result = await c.env.DB.prepare(
    "INSERT INTO snapshots (site_id, ts, manifest_sha256, stats_json, status) VALUES (?, ?, ?, ?, 'complete')",
  )
    .bind(site.id, body.ts, body.manifest_sha256, JSON.stringify(stats))
    .run();
  return c.json({ snapshot_id: result.meta.last_row_id }, 201);
});

snapshots.get("/", async (c) => {
  const site = c.get("site");
  const rows = await c.env.DB.prepare(
    "SELECT id, ts, manifest_sha256, stats_json FROM snapshots WHERE site_id = ? AND status = 'complete' ORDER BY ts DESC, id DESC",
  )
    .bind(site.id)
    .all<{ id: number; ts: string; manifest_sha256: string; stats_json: string }>();
  return c.json({
    snapshots: rows.results.map((r) => ({
      snapshot_id: r.id,
      ts: r.ts,
      manifest_sha256: r.manifest_sha256,
      stats: JSON.parse(r.stats_json) as SnapshotStats,
    })),
  });
});
