import { createHash, randomUUID } from "node:crypto";
import { createRequire } from "node:module";

import type BetterSqlite3 from "better-sqlite3";
import { isProjectEditableState, type CanvasState, type ProjectEditableState } from "@dada/shared-contracts";

import { ProjectError } from "./project-errors.js";
export { ProjectError } from "./project-errors.js";

const require = createRequire(import.meta.url);
const Database = require("better-sqlite3") as typeof BetterSqlite3;

export const projectRatios = ["3:4", "1:1", "4:3", "9:16"] as const;
export type ProjectRatio = typeof projectRatios[number];
export type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
export type ProjectManagedResourceKind = "derived" | "export" | "generated" | "reference";
export const ratioPixels: Record<ProjectRatio, { height: number; width: number }> = {
  "3:4": { height: 1440, width: 1080 },
  "1:1": { height: 1080, width: 1080 },
  "4:3": { height: 1080, width: 1440 },
  "9:16": { height: 1920, width: 1080 },
};
export const projectLimit = 20;
export const historyLimit = 10;
const trashRetentionMilliseconds = 720 * 60 * 60 * 1_000;
const generationErrorCategories = new Set([
  "upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled",
  "gateway_balance_insufficient", "gateway_contract_invalid", "reference_invalid",
  "unknown_retryable", "unknown_non_retryable",
]);

interface ProjectRow {
  created_at: number;
  current_image_id: string | null;
  deleted_at: number | null;
  draft_prompt: string;
  name: string;
  owner_id: string;
  pixel_height: number;
  pixel_width: number;
  project_id: string;
  purge_at: number | null;
  ratio: ProjectRatio;
  state_version: number;
  status: "active" | "trashed" | "purged";
  updated_at: number;
}

interface GenerationRow {
  created_at: number;
  error_category: string | null;
  generation_id: string;
  prompt: string;
  project_id: string;
  ratio: ProjectRatio;
  status: GenerationStatus;
  updated_at: number;
}

interface ImageRow {
  created_at: number;
  generation_id: string;
  image_id: string;
}

interface ProjectStateRow {
  canvas_json: string;
  created_at: number;
  name: string;
  project_id: string;
  state_version: number;
}

interface LatestExportRow {
  byte_size: number;
  created_at: number;
  export_id: string;
  format: "jpg" | "png";
  pixel_height: number;
  pixel_width: number;
  sha256: string;
  state_version: number;
}

function isProjectRatio(value: string): value is ProjectRatio {
  return projectRatios.includes(value as ProjectRatio);
}

export function normalizePrompt(value: string) {
  const normalized = value.trim().replace(/\s+/gu, " ");
  if (!normalized || normalized.length > 4_000) throw new ProjectError("generation_state_invalid");
  return normalized;
}

function takeGraphemes(value: string, count: number) {
  const Segmenter = Intl.Segmenter;
  if (Segmenter) {
    return [...new Segmenter("zh-CN", { granularity: "grapheme" }).segment(value)]
      .slice(0, count)
      .map((entry) => entry.segment)
      .join("");
  }
  return Array.from(value).slice(0, count).join("");
}

function localDate(timestamp: number) {
  const date = new Date(timestamp);
  return [date.getFullYear(), date.getMonth() + 1, date.getDate()]
    .map((part, index) => index === 0 ? String(part) : String(part).padStart(2, "0"))
    .join("-");
}

export function defaultProjectName(prompt: string, timestamp: number) {
  const summary = takeGraphemes(normalizePrompt(prompt), 24) || "未命名创作";
  return `${summary} ${localDate(timestamp)}`;
}

function normalizeProjectName(value: string) {
  const normalized = value.trim().replace(/\s+/gu, " ");
  if (!normalized || [...normalized].length > 80) throw new ProjectError("project_name_invalid");
  return normalized;
}

function iso(timestamp: number) {
  return new Date(timestamp).toISOString();
}

export function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width: number }, assetId: string | null): CanvasState {
  return {
    background: {
      adjustments: {
        brightness: 0,
        contrast: 0,
        crop: null,
        filter: "none",
        fit: "fill",
        saturation: 0,
        sharpness: 0,
        temperature: 0,
      },
      asset_id: assetId,
    },
    elements: [],
    pixel_height: pixels.height,
    pixel_width: pixels.width,
    ratio,
    schema_version: 1,
  };
}

export function stableJson(value: unknown): string {
  if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
  if (value && typeof value === "object") {
    return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
      .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
  }
  return JSON.stringify(value);
}

export class ProjectService {
  readonly database: BetterSqlite3.Database;
  private readonly clock: () => number;

  constructor(input: { clock?: () => number; databasePath: string }) {
    this.clock = input.clock ?? Date.now;
    const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
    this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
    this.database.pragma("journal_mode = WAL");
    this.database.pragma("foreign_keys = ON");
    this.database.pragma("synchronous = FULL");
    this.database.pragma("busy_timeout = 5000");
    this.migrate();
  }

  close() {
    this.database.close();
  }

  createProjectForGeneration(input: {
    ownerId: string;
    prompt: string;
    ratio: ProjectRatio;
    status: Exclude<GenerationStatus, "succeeded">;
  }) {
    if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid");
    const prompt = normalizePrompt(input.prompt);
    const projectId = randomUUID();
    const generationId = randomUUID();
    const now = this.clock();
    const pixels = ratioPixels[input.ratio];
    const transaction = this.database.transaction(() => {
      const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
        .get(input.ownerId) as { count: number };
      if (active.count >= projectLimit) throw new ProjectError("project_active_limit");
      this.database.prepare(`
        INSERT INTO projects (
          project_id, owner_id, name, draft_prompt, ratio, pixel_width, pixel_height,
          status, state_version, current_image_id, created_at, updated_at, deleted_at, purge_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?, NULL, NULL)
      `).run(
        projectId, input.ownerId, defaultProjectName(prompt, now), prompt, input.ratio,
        pixels.width, pixels.height, now, now,
      );
      this.insertProjectState({
        canvasState: defaultCanvasState(input.ratio, pixels, null),
        name: defaultProjectName(prompt, now),
        projectId,
        stateVersion: 1,
      }, now);
      this.insertGeneration({ generationId, ownerId: input.ownerId, projectId, prompt, ratio: input.ratio, status: input.status }, now);
    });
    transaction.immediate();
    return { generation: this.readGeneration(generationId), project: this.getProject(input.ownerId, projectId) };
  }

  continueProjectGeneration(input: {
    ownerId: string;
    projectId: string;
    prompt: string;
    ratio: ProjectRatio;
    status: Exclude<GenerationStatus, "succeeded">;
  }) {
    if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid");
    const generationId = randomUUID();
    const prompt = normalizePrompt(input.prompt);
    const now = this.clock();
    const transaction = this.database.transaction(() => {
      const project = this.readOwnedProject(input.ownerId, input.projectId);
      if (project.status !== "active") throw new ProjectError("project_not_found");
      if (project.ratio !== input.ratio) throw new ProjectError("project_ratio_fixed");
      if (this.successfulImageCount(input.projectId) >= historyLimit) throw new ProjectError("project_history_limit");
      this.insertGeneration({ generationId, ownerId: input.ownerId, projectId: input.projectId, prompt, ratio: project.ratio, status: input.status }, now);
      this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?")
        .run(prompt, now, input.projectId);
    });
    transaction.immediate();
    return this.readGeneration(generationId);
  }

  retryFailedDraft(input: { ownerId: string; projectId: string; prompt: string }) {
    const project = this.getProject(input.ownerId, input.projectId);
    const latest = project.generations.at(-1);
    if (project.status !== "failed_empty" || !latest || !["failed", "rejected"].includes(latest.status)) {
      throw new ProjectError("project_retry_not_allowed");
    }
    return this.continueProjectGeneration({
      ownerId: input.ownerId,
      projectId: input.projectId,
      prompt: input.prompt,
      ratio: project.ratio,
      status: "queued",
    });
  }

  recordSuccessfulImage(input: { generationId: string; imageId: string }) {
    const now = this.clock();
    const transaction = this.database.transaction(() => {
      const generation = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?")
        .get(input.generationId) as GenerationRow | undefined;
      if (!generation || !["queued", "running"].includes(generation.status)) throw new ProjectError("generation_state_invalid");
      if (this.successfulImageCount(generation.project_id) >= historyLimit) throw new ProjectError("project_history_limit");
      const project = this.database.prepare("SELECT * FROM projects WHERE project_id = ?").get(generation.project_id) as ProjectRow;
      const currentState = this.readProjectState(generation.project_id);
      const nextCanvas = structuredClone(currentState.canvasState);
      if (project.current_image_id === null) nextCanvas.background.asset_id = input.imageId;
      this.database.prepare(`
        UPDATE generation_jobs SET status = 'succeeded', error_category = NULL, updated_at = ? WHERE generation_id = ?
      `).run(now, input.generationId);
      this.database.prepare(`
        INSERT INTO project_images (image_id, project_id, generation_id, created_at) VALUES (?, ?, ?, ?)
      `).run(input.imageId, generation.project_id, input.generationId, now);
      this.database.prepare(`
        UPDATE projects
        SET current_image_id = COALESCE(current_image_id, ?), updated_at = ?, state_version = state_version + 1
        WHERE project_id = ?
      `).run(input.imageId, now, generation.project_id);
      this.insertProjectState({
        canvasState: nextCanvas,
        name: project.name,
        projectId: generation.project_id,
        stateVersion: project.state_version + 1,
      }, now);
    });
    transaction.immediate();
  }

  markGenerationFailed(generationId: string, errorCategory: string) {
    if (!generationErrorCategories.has(errorCategory)) throw new ProjectError("generation_state_invalid");
    const status = errorCategory === "safety_rejected" ? "rejected" : "failed";
    const changed = this.database.prepare(`
      UPDATE generation_jobs SET status = ?, error_category = ?, updated_at = ?
      WHERE generation_id = ? AND status IN ('queued', 'running')
    `).run(status, errorCategory, this.clock(), generationId);
    if (changed.changes !== 1) throw new ProjectError("generation_state_invalid");
  }

  renameProject(ownerId: string, projectId: string, name: string) {
    const normalized = normalizeProjectName(name);
    const now = this.clock();
    const transaction = this.database.transaction(() => {
      const project = this.readOwnedProject(ownerId, projectId);
      if (project.status !== "active") throw new ProjectError("project_not_found");
      const currentState = this.readProjectState(projectId);
      this.database.prepare(`
        UPDATE projects SET name = ?, updated_at = ?, state_version = state_version + 1
        WHERE owner_id = ? AND project_id = ? AND status = 'active'
      `).run(normalized, now, ownerId, projectId);
      this.insertProjectState({
        canvasState: currentState.canvasState,
        name: normalized,
        projectId,
        stateVersion: project.state_version + 1,
      }, now);
    });
    transaction.immediate();
    return { name: normalized, stateVersion: this.readOwnedProject(ownerId, projectId).state_version };
  }

  saveProjectState(input: {
    expectedStateVersion: number;
    idempotencyKey: string;
    ownerId: string;
    projectId: string;
    state: ProjectEditableState;
  }) {
    if (!Number.isSafeInteger(input.expectedStateVersion) || input.expectedStateVersion < 1 || !isProjectEditableState(input.state)) {
      throw new ProjectError("project_state_invalid");
    }
    const name = normalizeProjectName(input.state.name);
    const state = { canvas_state: structuredClone(input.state.canvas_state), name } satisfies ProjectEditableState;
    const requestHash = createHash("sha256").update(stableJson({ expected: input.expectedStateVersion, state })).digest("hex");
    const now = this.clock();
    let result!: { stateVersion: number };
    const transaction = this.database.transaction(() => {
      const replay = this.database.prepare(`
        SELECT request_hash, response_state_version FROM project_state_idempotency
        WHERE owner_id = ? AND project_id = ? AND idempotency_key = ?
      `).get(input.ownerId, input.projectId, input.idempotencyKey) as { request_hash: string; response_state_version: number } | undefined;
      if (replay) {
        if (replay.request_hash !== requestHash) throw new ProjectError("project_state_idempotency_conflict");
        result = { stateVersion: replay.response_state_version };
        return;
      }
      const project = this.readOwnedProject(input.ownerId, input.projectId);
      if (project.status !== "active") throw new ProjectError("project_not_found");
      if (project.state_version !== input.expectedStateVersion) {
        throw new ProjectError("project_state_conflict", project.state_version);
      }
      const canvas = state.canvas_state;
      if (canvas.ratio !== project.ratio || canvas.pixel_width !== project.pixel_width || canvas.pixel_height !== project.pixel_height) {
        throw new ProjectError("project_state_invalid");
      }
      if (canvas.background.asset_id) {
        const owned = this.database.prepare("SELECT 1 FROM project_images WHERE project_id = ? AND image_id = ?")
          .get(input.projectId, canvas.background.asset_id);
        if (!owned) throw new ProjectError("project_state_invalid");
      }
      const nextVersion = project.state_version + 1;
      const changed = this.database.prepare(`
        UPDATE projects SET name = ?, state_version = ?, updated_at = ?
        WHERE project_id = ? AND owner_id = ? AND state_version = ? AND status = 'active'
      `).run(name, nextVersion, now, input.projectId, input.ownerId, input.expectedStateVersion);
      if (changed.changes !== 1) {
        const latest = this.readOwnedProject(input.ownerId, input.projectId).state_version;
        throw new ProjectError("project_state_conflict", latest);
      }
      this.insertProjectState({ canvasState: canvas, name, projectId: input.projectId, stateVersion: nextVersion }, now);
      this.rebuildProjectStickerReferences(input.projectId, canvas, now);
      this.database.prepare(`
        INSERT INTO project_state_idempotency (
          owner_id, project_id, idempotency_key, request_hash, response_state_version, created_at
        ) VALUES (?, ?, ?, ?, ?, ?)
      `).run(input.ownerId, input.projectId, input.idempotencyKey, requestHash, nextVersion, now);
      result = { stateVersion: nextVersion };
    });
    transaction.immediate();
    return result;
  }

  private rebuildProjectStickerReferences(projectId: string, canvas: CanvasState, now: number) {
    if (!this.tableExists("project_sticker_asset_refs")) return;
    this.database.prepare("DELETE FROM project_sticker_asset_refs WHERE project_id = ?").run(projectId);
    const insert = this.database.prepare(`
      INSERT INTO project_sticker_asset_refs (reference_id, project_id, stable_id, resource_version, created_at)
      VALUES (?, ?, ?, ?, ?)
    `);
    for (const element of canvas.elements) {
      if (element.type !== "static_sticker") continue;
      insert.run(
        `project:${projectId}:sticker:${element.element_id}`,
        projectId,
        element.template_or_asset_id,
        element.resource_version,
        now,
      );
    }
  }

  trashFailedEmpty(ownerId: string, projectIds: string[]) {
    const uniqueIds = [...new Set(projectIds)];
    if (uniqueIds.length === 0 || uniqueIds.length > projectLimit) throw new ProjectError("generation_state_invalid");
    const trashedProjectIds: string[] = [];
    const ignoredProjectIds: string[] = [];
    const now = this.clock();
    const transaction = this.database.transaction(() => {
      for (const projectId of uniqueIds) {
        const row = this.database.prepare(`
          SELECT p.project_id,
            (SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count,
            (SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status
          FROM projects p WHERE p.owner_id = ? AND p.project_id = ? AND p.status = 'active'
        `).get(ownerId, projectId) as { image_count: number; latest_status: string | null; project_id: string } | undefined;
        if (!row || row.image_count !== 0 || !row.latest_status || !["failed", "rejected"].includes(row.latest_status)) {
          ignoredProjectIds.push(projectId);
          continue;
        }
        this.database.prepare(`
          UPDATE projects SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1
          WHERE project_id = ?
        `).run(now, now + trashRetentionMilliseconds, now, projectId);
        trashedProjectIds.push(projectId);
      }
    });
    transaction.immediate();
    return { ignoredProjectIds, trashedProjectIds };
  }

  trashProject(ownerId: string, projectId: string) {
    const now = this.clock();
    let purged = false;
    let result!: ReturnType<ProjectService["projectSummary"]>;
    const transaction = this.database.transaction(() => {
      const project = this.readOwnedProject(ownerId, projectId);
      if (project.status === "purged") throw new ProjectError("project_not_found");
      if (project.status === "trashed") {
        if (project.purge_at !== null && project.purge_at <= now) {
          this.transitionToPurged(project, now);
          purged = true;
          return;
        }
        result = this.projectSummary(project, this.successfulImageCount(projectId), this.latestGenerationStatus(projectId));
        return;
      }
      this.database.prepare(`
        UPDATE projects
        SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1
        WHERE project_id = ? AND owner_id = ? AND status = 'active'
      `).run(now, now + trashRetentionMilliseconds, now, projectId, ownerId);
      const updated = this.readOwnedProject(ownerId, projectId);
      result = this.projectSummary(updated, this.successfulImageCount(projectId), this.latestGenerationStatus(projectId));
    });
    transaction.immediate();
    if (purged) throw new ProjectError("project_not_found");
    return result;
  }

  restoreProject(ownerId: string, projectId: string) {
    const now = this.clock();
    let purged = false;
    const transaction = this.database.transaction(() => {
      const project = this.readOwnedProject(ownerId, projectId);
      if (project.status === "purged") throw new ProjectError("project_not_found");
      if (project.status === "active") return;
      if (project.purge_at !== null && project.purge_at <= now) {
        this.transitionToPurged(project, now);
        purged = true;
        return;
      }
      const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
        .get(ownerId) as { count: number };
      if (active.count >= projectLimit) throw new ProjectError("project_active_limit");
      this.database.prepare(`
        UPDATE projects
        SET status = 'active', deleted_at = NULL, purge_at = NULL, updated_at = ?, state_version = state_version + 1
        WHERE project_id = ? AND owner_id = ? AND status = 'trashed'
      `).run(now, projectId, ownerId);
    });
    transaction.immediate();
    if (purged) throw new ProjectError("project_not_found");
    return this.getProject(ownerId, projectId);
  }

  purgeProject(ownerId: string, projectId: string) {
    const now = this.clock();
    const transaction = this.database.transaction(() => {
      const project = this.readOwnedProject(ownerId, projectId);
      if (project.status !== "trashed") throw new ProjectError("project_not_found");
      this.transitionToPurged(project, now);
    });
    transaction.immediate();
    return { projectId, status: "purged" as const };
  }

  linkManagedResource(ownerId: string, projectId: string, managedFileId: string, resourceKind: ProjectManagedResourceKind) {
    const project = this.readOwnedProject(ownerId, projectId);
    if (project.status === "purged" || !["derived", "export", "generated", "reference"].includes(resourceKind)) {
      throw new ProjectError("project_not_found");
    }
    if (!this.tableExists("managed_files") || !this.tableExists("project_asset_refs")) {
      throw new ProjectError("project_state_invalid");
    }
    const managed = this.database.prepare(`
      SELECT file_id FROM managed_files WHERE file_id = ? AND owner_ref = ? AND status = 'committed'
    `).get(managedFileId, ownerId);
    if (!managed) throw new ProjectError("project_state_invalid");
    const now = this.clock();
    const transaction = this.database.transaction(() => {
      this.database.prepare(`
        INSERT OR IGNORE INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at)
        VALUES (?, ?, ?, ?)
      `).run(projectId, managedFileId, resourceKind, now);
      this.database.prepare(`
        INSERT OR IGNORE INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at)
        VALUES (?, ?, 'project', ?)
      `).run(`project:${projectId}:${managedFileId}`, managedFileId, iso(now));
    });
    transaction.immediate();
  }

  listProjects(ownerId: string, status: "active" | "trashed") {
    this.purgeExpiredProjects(ownerId);
    const rows = this.database.prepare(`
      SELECT p.*,
        (SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count,
        (SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status
      FROM projects p
      WHERE p.owner_id = ? AND p.status = ?
      ORDER BY p.updated_at DESC, p.project_id DESC
    `).all(ownerId, status) as Array<ProjectRow & { image_count: number; latest_status: string | null }>;
    return rows.map((row) => this.projectSummary(row, row.image_count, row.latest_status));
  }

  getProject(ownerId: string, projectId: string) {
    this.purgeExpiredProjects(ownerId, projectId);
    const row = this.readOwnedProject(ownerId, projectId);
    if (row.status === "purged") throw new ProjectError("project_not_found");
    const generations = this.database.prepare(`
      SELECT * FROM generation_jobs WHERE project_id = ? ORDER BY created_at, rowid
    `).all(projectId) as GenerationRow[];
    const images = this.database.prepare(`
      SELECT image_id, generation_id, created_at FROM project_images WHERE project_id = ? ORDER BY created_at, rowid
    `).all(projectId) as ImageRow[];
    const latestExports = this.database.prepare(`
      SELECT export_id, format, sha256, byte_size, pixel_width, pixel_height, state_version, created_at
      FROM latest_exports WHERE project_id = ? ORDER BY format
    `).all(projectId) as LatestExportRow[];
    const summary = this.projectSummary(row, images.length, generations.at(-1)?.status ?? null);
    const projectState = this.readProjectState(projectId);
    return {
      ...summary,
      canvasState: projectState.canvasState,
      createdAt: iso(row.created_at),
      draftPrompt: row.draft_prompt,
      generations: generations.map((generation) => this.generationView(generation)),
      images: images.map((image) => ({ createdAt: iso(image.created_at), generationId: image.generation_id, imageId: image.image_id })),
      latestExports: latestExports.map((item) => ({
        byteSize: item.byte_size,
        createdAt: iso(item.created_at),
        downloadUrl: `/api/v1/projects/${projectId}/latest-exports/${item.format}`,
        exportId: item.export_id,
        format: item.format,
        pixelHeight: item.pixel_height,
        pixelWidth: item.pixel_width,
        sha256: item.sha256,
        stateVersion: item.state_version,
      })),
      pixelHeight: row.pixel_height,
      pixelWidth: row.pixel_width,
      saveStatus: "saved" as const,
    };
  }

  activeProjectCount(ownerId: string) {
    const row = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
      .get(ownerId) as { count: number };
    return row.count;
  }

  private insertGeneration(input: {
    generationId: string;
    ownerId: string;
    projectId: string;
    prompt: string;
    ratio: ProjectRatio;
    status: Exclude<GenerationStatus, "succeeded">;
  }, now: number) {
    const errorCategory = input.status === "failed" ? "upstream_failed" : input.status === "rejected" ? "safety_rejected" : null;
    this.database.prepare(`
      INSERT INTO generation_jobs (
        generation_id, owner_id, project_id, prompt, ratio, status, error_category, created_at, updated_at
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).run(input.generationId, input.ownerId, input.projectId, input.prompt, input.ratio, input.status, errorCategory, now, now);
  }

  private readGeneration(generationId: string) {
    const row = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?").get(generationId) as GenerationRow | undefined;
    if (!row) throw new ProjectError("generation_state_invalid");
    return this.generationView(row);
  }

  private generationView(row: GenerationRow) {
    return {
      createdAt: iso(row.created_at),
      errorCategory: row.error_category,
      generationId: row.generation_id,
      projectId: row.project_id,
      prompt: row.prompt,
      ratio: row.ratio,
      status: row.status,
      updatedAt: iso(row.updated_at),
    };
  }

  private readOwnedProject(ownerId: string, projectId: string) {
    const row = this.database.prepare("SELECT * FROM projects WHERE owner_id = ? AND project_id = ?")
      .get(ownerId, projectId) as ProjectRow | undefined;
    if (!row) throw new ProjectError("project_not_found");
    return row;
  }

  private insertProjectState(input: { canvasState: CanvasState; name: string; projectId: string; stateVersion: number }, now: number) {
    this.database.prepare(`
      INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at)
      VALUES (?, ?, ?, ?, ?)
    `).run(input.projectId, input.stateVersion, input.name, stableJson(input.canvasState), now);
  }

  private readProjectState(projectId: string) {
    const row = this.database.prepare(`
      SELECT project_id, state_version, name, canvas_json, created_at
      FROM project_states WHERE project_id = ? ORDER BY state_version DESC LIMIT 1
    `).get(projectId) as ProjectStateRow | undefined;
    if (!row) throw new ProjectError("project_state_invalid");
    const canvasState: unknown = JSON.parse(row.canvas_json);
    if (!isProjectEditableState({ canvas_state: canvasState, name: row.name })) throw new ProjectError("project_state_invalid");
    return { canvasState, name: row.name, stateVersion: row.state_version } as {
      canvasState: CanvasState; name: string; stateVersion: number;
    };
  }

  private successfulImageCount(projectId: string) {
    const row = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?")
      .get(projectId) as { count: number };
    return row.count;
  }

  private latestGenerationStatus(projectId: string) {
    const row = this.database.prepare(`
      SELECT status FROM generation_jobs WHERE project_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1
    `).get(projectId) as { status: string } | undefined;
    return row?.status ?? null;
  }

  private tableExists(name: string) {
    return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
  }

  private purgeExpiredProjects(ownerId: string, projectId?: string) {
    const now = this.clock();
    const transaction = this.database.transaction(() => {
      const rows = this.database.prepare(`
        SELECT * FROM projects
        WHERE owner_id = ? AND status = 'trashed' AND purge_at <= ? AND (? IS NULL OR project_id = ?)
        ORDER BY purge_at, project_id
      `).all(ownerId, now, projectId ?? null, projectId ?? null) as ProjectRow[];
      for (const row of rows) this.transitionToPurged(row, now);
    });
    transaction.immediate();
  }

  private transitionToPurged(project: ProjectRow, now: number) {
    const changed = this.database.prepare(`
      UPDATE projects SET status = 'purged', updated_at = ?, state_version = state_version + 1
      WHERE project_id = ? AND status = 'trashed'
    `).run(now, project.project_id);
    if (changed.changes !== 1) return;
    this.queueManagedProjectFiles(project.project_id, now);
    this.database.prepare(`
      INSERT OR IGNORE INTO project_cleanup_queue (
        cleanup_id, project_id, owner_id, resource_scope_json, status, created_at, completed_at, last_error
      ) VALUES (?, ?, ?, ?, 'pending', ?, NULL, NULL)
    `).run(
      randomUUID(), project.project_id, project.owner_id,
      stableJson(["project_state", "generation", "generated_image", "reference", "location", "latest_export"]), now,
    );
  }

  private queueManagedProjectFiles(projectId: string, now: number) {
    if (!["managed_files", "file_cleanup_queue", "project_asset_refs"].every((table) => this.tableExists(table))) return;
    const files = this.database.prepare(`
      SELECT mf.file_id, mf.relative_path, mf.byte_size
      FROM project_resource_files prf
      JOIN managed_files mf ON mf.file_id = prf.managed_file_id
      WHERE prf.project_id = ? AND mf.status = 'committed'
      ORDER BY mf.file_id
    `).all(projectId) as Array<{ byte_size: number; file_id: string; relative_path: string }>;
    for (const file of files) {
      this.database.prepare("DELETE FROM project_asset_refs WHERE reference_id = ?")
        .run(`project:${projectId}:${file.file_id}`);
      const otherProject = this.database.prepare(`
        SELECT 1 FROM project_resource_files prf
        JOIN projects p ON p.project_id = prf.project_id
        WHERE prf.managed_file_id = ? AND prf.project_id <> ? AND p.status <> 'purged'
        LIMIT 1
      `).get(file.file_id, projectId);
      const otherReference = this.database.prepare("SELECT 1 FROM project_asset_refs WHERE managed_file_id = ? LIMIT 1").get(file.file_id);
      if (otherProject || otherReference) continue;
      this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ? AND status = 'committed'")
        .run(iso(now), file.file_id);
      this.database.prepare(`
        INSERT OR IGNORE INTO file_cleanup_queue (
          cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed,
          reason, status, created_at, completed_at, last_error
        ) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL)
      `).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, iso(now));
    }
  }

  private projectSummary(row: ProjectRow, imageCount: number, latestStatus: string | null) {
    const status: ProjectViewStatus = row.status === "trashed"
      ? "trashed"
      : imageCount === 0 && latestStatus && ["failed", "rejected"].includes(latestStatus)
        ? "failed_empty"
        : "active";
    return {
      currentImageId: row.current_image_id,
      deletedAt: row.deleted_at === null ? null : iso(row.deleted_at),
      name: row.name,
      projectId: row.project_id,
      purgeAt: row.purge_at === null ? null : iso(row.purge_at),
      ratio: row.ratio,
      stateVersion: row.state_version,
      status,
      successfulImageCount: imageCount,
      updatedAt: iso(row.updated_at),
    };
  }

  private migrate() {
    this.database.exec(`
      CREATE TABLE IF NOT EXISTS projects (
        project_id TEXT PRIMARY KEY,
        owner_id TEXT NOT NULL,
        name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
        draft_prompt TEXT NOT NULL CHECK (length(draft_prompt) BETWEEN 1 AND 4000),
        ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')),
        pixel_width INTEGER NOT NULL,
        pixel_height INTEGER NOT NULL,
        status TEXT NOT NULL CHECK (status IN ('active', 'trashed', 'purged')),
        state_version INTEGER NOT NULL CHECK (state_version >= 1),
        current_image_id TEXT,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL,
        deleted_at INTEGER,
        purge_at INTEGER,
        UNIQUE(project_id, ratio),
        CHECK (
          (ratio = '3:4' AND pixel_width = 1080 AND pixel_height = 1440) OR
          (ratio = '1:1' AND pixel_width = 1080 AND pixel_height = 1080) OR
          (ratio = '4:3' AND pixel_width = 1440 AND pixel_height = 1080) OR
          (ratio = '9:16' AND pixel_width = 1080 AND pixel_height = 1920)
        ),
        CHECK (
          (status = 'active' AND deleted_at IS NULL AND purge_at IS NULL) OR
          (status = 'trashed' AND deleted_at IS NOT NULL AND purge_at = deleted_at + ${trashRetentionMilliseconds}) OR
          (status = 'purged' AND deleted_at IS NOT NULL AND purge_at IS NOT NULL)
        )
      );
      CREATE INDEX IF NOT EXISTS projects_owner_status_updated ON projects(owner_id, status, updated_at DESC);
      DROP TRIGGER IF EXISTS projects_active_insert_limit;
      CREATE TRIGGER projects_active_insert_limit
        BEFORE INSERT ON projects
        WHEN NEW.status = 'active' AND (
          SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active'
        ) >= ${projectLimit}
        BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END;
      CREATE TABLE IF NOT EXISTS project_states (
        project_id TEXT NOT NULL,
        state_version INTEGER NOT NULL CHECK (state_version >= 1),
        name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
        canvas_json TEXT NOT NULL CHECK (json_valid(canvas_json)),
        created_at INTEGER NOT NULL,
        PRIMARY KEY (project_id, state_version),
        FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
      );
      CREATE INDEX IF NOT EXISTS project_states_latest ON project_states(project_id, state_version DESC);
      CREATE TABLE IF NOT EXISTS project_resource_files (
        project_id TEXT NOT NULL,
        managed_file_id TEXT NOT NULL,
        resource_kind TEXT NOT NULL CHECK (resource_kind IN ('derived', 'export', 'generated', 'reference')),
        created_at INTEGER NOT NULL,
        PRIMARY KEY (project_id, managed_file_id),
        FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
      );
      CREATE INDEX IF NOT EXISTS project_resource_files_managed ON project_resource_files(managed_file_id, project_id);
      CREATE TABLE IF NOT EXISTS project_sticker_asset_refs (
        reference_id TEXT PRIMARY KEY,
        project_id TEXT NOT NULL,
        stable_id TEXT NOT NULL,
        resource_version TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
      );
      CREATE INDEX IF NOT EXISTS project_sticker_asset_refs_lookup
        ON project_sticker_asset_refs (stable_id, resource_version);
      CREATE TABLE IF NOT EXISTS latest_exports (
        project_id TEXT NOT NULL,
        format TEXT NOT NULL CHECK (format IN ('jpg', 'png')),
        export_id TEXT NOT NULL UNIQUE,
        managed_file_id TEXT NOT NULL,
        state_version INTEGER NOT NULL CHECK (state_version >= 1),
        sha256 TEXT NOT NULL CHECK (length(sha256) = 64),
        byte_size INTEGER NOT NULL CHECK (byte_size > 0),
        pixel_width INTEGER NOT NULL CHECK (pixel_width > 0),
        pixel_height INTEGER NOT NULL CHECK (pixel_height > 0),
        created_at INTEGER NOT NULL,
        PRIMARY KEY (project_id, format),
        FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
      );
      CREATE TABLE IF NOT EXISTS project_cleanup_queue (
        cleanup_id TEXT PRIMARY KEY,
        project_id TEXT NOT NULL UNIQUE,
        owner_id TEXT NOT NULL,
        resource_scope_json TEXT NOT NULL CHECK (json_valid(resource_scope_json)),
        status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'failed')),
        created_at INTEGER NOT NULL,
        completed_at INTEGER,
        last_error TEXT
      );
      CREATE INDEX IF NOT EXISTS project_cleanup_queue_status_created ON project_cleanup_queue(status, created_at, project_id);
      CREATE TABLE IF NOT EXISTS project_state_idempotency (
        owner_id TEXT NOT NULL,
        project_id TEXT NOT NULL,
        idempotency_key TEXT NOT NULL,
        request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
        response_state_version INTEGER NOT NULL CHECK (response_state_version >= 2),
        created_at INTEGER NOT NULL,
        PRIMARY KEY (owner_id, project_id, idempotency_key),
        FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
      );
      DROP TRIGGER IF EXISTS projects_active_restore_limit;
      CREATE TRIGGER projects_active_restore_limit
        BEFORE UPDATE OF status ON projects
        WHEN OLD.status <> 'active' AND NEW.status = 'active' AND (
          SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active'
        ) >= ${projectLimit}
        BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END;
      CREATE TABLE IF NOT EXISTS generation_jobs (
        generation_id TEXT PRIMARY KEY,
        owner_id TEXT NOT NULL,
        project_id TEXT NOT NULL,
        prompt TEXT NOT NULL CHECK (length(prompt) BETWEEN 1 AND 4000),
        ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')),
        status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'rejected')),
        model_id TEXT,
        model_config_version INTEGER,
        confirmed_credit_cost INTEGER CHECK (confirmed_credit_cost IS NULL OR confirmed_credit_cost > 0),
        reserved_credits INTEGER NOT NULL DEFAULT 0 CHECK (reserved_credits >= 0),
        final_credit_state TEXT CHECK (final_credit_state IS NULL OR final_credit_state IN ('committed', 'released')),
        finished_at INTEGER,
        error_category TEXT CHECK (error_category IS NULL OR error_category IN (
          'upstream_timeout', 'upstream_failed', 'safety_rejected', 'model_disabled',
          'gateway_balance_insufficient', 'gateway_contract_invalid', 'reference_invalid',
          'unknown_retryable', 'unknown_non_retryable'
        )),
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL,
        UNIQUE(generation_id, project_id),
        FOREIGN KEY (project_id, ratio) REFERENCES projects(project_id, ratio) ON DELETE CASCADE,
        CHECK (
          (status IN ('queued', 'running', 'succeeded') AND error_category IS NULL) OR
          (status IN ('failed', 'rejected') AND error_category IS NOT NULL)
        )
      );
      CREATE INDEX IF NOT EXISTS generation_jobs_project_created ON generation_jobs(project_id, created_at, generation_id);
      CREATE TABLE IF NOT EXISTS project_images (
        image_id TEXT PRIMARY KEY,
        project_id TEXT NOT NULL,
        generation_id TEXT NOT NULL UNIQUE,
        created_at INTEGER NOT NULL,
        FOREIGN KEY (generation_id, project_id) REFERENCES generation_jobs(generation_id, project_id) ON DELETE CASCADE
      );
      CREATE INDEX IF NOT EXISTS project_images_project_created ON project_images(project_id, created_at, image_id);
      DROP TRIGGER IF EXISTS projects_ratio_immutable;
      CREATE TRIGGER projects_ratio_immutable
        BEFORE UPDATE OF ratio, pixel_width, pixel_height ON projects
        WHEN NEW.ratio <> OLD.ratio OR NEW.pixel_width <> OLD.pixel_width OR NEW.pixel_height <> OLD.pixel_height
        BEGIN SELECT RAISE(ABORT, 'project_ratio_fixed'); END;
      DROP TRIGGER IF EXISTS project_images_history_limit;
      CREATE TRIGGER project_images_history_limit
        BEFORE INSERT ON project_images
        WHEN (SELECT COUNT(*) FROM project_images WHERE project_id = NEW.project_id) >= ${historyLimit}
        BEGIN SELECT RAISE(ABORT, 'project_history_limit'); END;
    `);
    const missingStates = this.database.prepare(`
      SELECT p.* FROM projects p
      WHERE NOT EXISTS (SELECT 1 FROM project_states s WHERE s.project_id = p.project_id)
    `).all() as ProjectRow[];
    const insertBackfill = this.database.transaction(() => {
      for (const project of missingStates) {
        this.insertProjectState({
          canvasState: defaultCanvasState(project.ratio, { height: project.pixel_height, width: project.pixel_width }, project.current_image_id),
          name: project.name,
          projectId: project.project_id,
          stateVersion: project.state_version,
        }, project.updated_at);
      }
    });
    insertBackfill.immediate();
  }
}
