import { readFile } from "node:fs/promises";
import postgres from "postgres";

import { applyInstanceAction, cloneInstance, createInstance, summarizeInstance } from "./domain";
import type { HistoryEntry, Registry, TodoAction, TodoInstance } from "./types";

export interface TodoSnapshotEntry {
  instance: TodoInstance;
  history: HistoryEntry[];
  source: Record<string, unknown>;
}

export interface TodoReminderNotification {
  instanceId: string;
  todoId: string;
  reminderAt: string;
  target: string;
  title: string;
  message: string;
  responsePreview: string;
  sentAt: string;
}

export interface TodoSnapshot {
  schemaVersion: 1;
  generatedAt: string;
  instances: TodoSnapshotEntry[];
  reminderNotifications: TodoReminderNotification[];
}

function nowIso(): string {
  return new Date().toISOString();
}

function asRecord(value: unknown): Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}

function storageBasePath(): string {
  return (process.env.TODO_NOTE_GIT_BASE_PATH || "todo-note").replace(/^\/+|\/+$/gu, "");
}

function storageRepo(): string {
  return process.env.TODO_NOTE_GIT_REPO || "pikasTech/decision-center-data";
}

function instancePath(instanceId: string): string {
  if (process.env.TODO_NOTE_STORAGE_PRIMARY === "github-repo") {
    return `github://${storageRepo()}/${storageBasePath()}/instances/${instanceId}.json`;
  }
  return `pg://todo-note/instances/${instanceId}`;
}

function historyPath(instanceId: string): string {
  if (process.env.TODO_NOTE_STORAGE_PRIMARY === "github-repo") {
    return `github://${storageRepo()}/${storageBasePath()}/history/${instanceId}.json`;
  }
  return `pg://todo-note/history/${instanceId}`;
}

function normalizeInstance(value: unknown): TodoInstance {
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
    throw new Error("Todo instance is not an object");
  }
  const record = value as Partial<TodoInstance>;
  if (typeof record.id !== "string" || !record.id || typeof record.name !== "string" || !Array.isArray(record.todos)) {
    throw new Error("Todo instance is missing required fields");
  }
  return {
    id: record.id,
    name: record.name,
    filePath: instancePath(record.id),
    historyPath: historyPath(record.id),
    createdAt: typeof record.createdAt === "string" ? record.createdAt : nowIso(),
    updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : nowIso(),
    historyPointer: typeof record.historyPointer === "number" && Number.isInteger(record.historyPointer) ? record.historyPointer : 0,
    todos: record.todos,
  };
}

function normalizeHistoryEntry(value: unknown): HistoryEntry {
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Todo history entry is not an object");
  const record = value as Partial<HistoryEntry>;
  if (!Number.isInteger(record.sequence) || typeof record.timestamp !== "string" || (record.kind !== "snapshot" && record.kind !== "command") || typeof record.action !== "string") {
    throw new Error("Todo history entry is missing required fields");
  }
  return {
    sequence: Number(record.sequence),
    timestamp: record.timestamp,
    kind: record.kind,
    action: record.action,
    ...(record.snapshot === undefined ? {} : { snapshot: normalizeInstance(record.snapshot) }),
    ...(record.detail === undefined ? {} : { detail: asRecord(record.detail) }),
  };
}

export function normalizeTodoReminderNotification(value: unknown): TodoReminderNotification {
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Todo reminder notification is not an object");
  const record = value as Partial<TodoReminderNotification>;
  for (const key of ["instanceId", "todoId", "reminderAt", "target", "title", "message", "responsePreview", "sentAt"] as const) {
    if (typeof record[key] !== "string") throw new Error(`Todo reminder notification ${key} must be a string`);
  }
  if (!record.instanceId || !record.todoId || !record.reminderAt || !record.target || !record.sentAt) {
    throw new Error("Todo reminder notification is missing required fields");
  }
  return record as TodoReminderNotification;
}

export function normalizeTodoSnapshotEntry(value: unknown): TodoSnapshotEntry {
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Todo snapshot entry is not an object");
  const entry = value as Partial<TodoSnapshotEntry>;
  return {
    instance: normalizeInstance(entry.instance),
    history: Array.isArray(entry.history) ? entry.history.map(normalizeHistoryEntry) : [],
    source: asRecord(entry.source),
  };
}

export function normalizeTodoSnapshot(value: unknown): TodoSnapshot {
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Todo snapshot is not an object");
  const record = value as Partial<TodoSnapshot>;
  if (record.schemaVersion !== 1 || !Array.isArray(record.instances)) throw new Error("Todo snapshot schemaVersion must be 1");
  const seen = new Set<string>();
  const instances = record.instances.map((rawEntry) => {
    const entry = normalizeTodoSnapshotEntry(rawEntry);
    const instance = entry.instance;
    if (seen.has(instance.id)) throw new Error(`Duplicate Todo instance: ${instance.id}`);
    seen.add(instance.id);
    return entry;
  });
  return {
    schemaVersion: 1,
    generatedAt: typeof record.generatedAt === "string" ? record.generatedAt : nowIso(),
    instances,
    reminderNotifications: Array.isArray(record.reminderNotifications) ? record.reminderNotifications.map(normalizeTodoReminderNotification) : [],
  };
}

export class PostgresTodoRepository {
  private readonly sql: postgres.Sql;

  constructor(databaseUrl: string) {
    this.sql = postgres(databaseUrl, {
      max: 4,
      idle_timeout: 20,
      connect_timeout: 10,
      connection: { application_name: "unidesk-todo-note" },
    });
  }

  async close(): Promise<void> {
    await this.sql.end({ timeout: 5 });
  }

  async ensureReady(): Promise<void> {
    await this.sql`
      CREATE TABLE IF NOT EXISTS todo_note_instances (
        id TEXT PRIMARY KEY,
        name TEXT NOT NULL,
        file_path TEXT NOT NULL,
        history_path TEXT NOT NULL,
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        history_pointer INTEGER NOT NULL DEFAULT 0,
        todos JSONB NOT NULL DEFAULT '[]'::jsonb,
        source JSONB NOT NULL DEFAULT '{}'::jsonb
      )
    `;
    await this.sql`
      CREATE TABLE IF NOT EXISTS todo_note_history (
        id BIGSERIAL PRIMARY KEY,
        instance_id TEXT NOT NULL REFERENCES todo_note_instances(id) ON DELETE CASCADE,
        sequence INTEGER NOT NULL,
        timestamp TEXT NOT NULL,
        kind TEXT NOT NULL,
        action TEXT NOT NULL,
        snapshot JSONB,
        detail JSONB,
        created_at TIMESTAMPTZ NOT NULL DEFAULT now()
      )
    `;
    await this.sql`CREATE INDEX IF NOT EXISTS idx_todo_note_history_instance_sequence ON todo_note_history(instance_id, sequence)`;
    await this.sql`CREATE INDEX IF NOT EXISTS idx_todo_note_instances_updated_at ON todo_note_instances(updated_at DESC)`;
    await this.sql`
      CREATE TABLE IF NOT EXISTS todo_note_reminder_notifications (
        id BIGSERIAL PRIMARY KEY,
        instance_id TEXT NOT NULL,
        todo_id TEXT NOT NULL,
        reminder_at TEXT NOT NULL,
        target TEXT NOT NULL,
        title TEXT NOT NULL,
        message TEXT NOT NULL,
        response_preview TEXT NOT NULL DEFAULT '',
        sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
        UNIQUE (instance_id, todo_id, reminder_at)
      )
    `;
    await this.sql`CREATE INDEX IF NOT EXISTS idx_todo_note_reminder_notifications_sent_at ON todo_note_reminder_notifications(sent_at DESC)`;
  }

  async ping(): Promise<void> {
    await this.sql`SELECT 1`;
  }

  async listInstances(): Promise<Registry> {
    const rows = await this.sql<Array<Record<string, unknown>>>`
      SELECT id, name, file_path, history_path, created_at, updated_at, history_pointer, todos
      FROM todo_note_instances
      ORDER BY created_at ASC, id ASC
    `;
    return { instances: rows.map((row) => summarizeInstance(this.instanceFromRow(row))) };
  }

  async createInstance(name: string): Promise<TodoInstance> {
    const createdAt = nowIso();
    const instance = createInstance(name, "", "", createdAt);
    instance.filePath = instancePath(instance.id);
    instance.historyPath = historyPath(instance.id);
    await this.sql.begin(async (tx) => {
      await this.upsertInstance(tx, instance, { createdBy: "unidesk-todo-note" });
      await this.insertHistory(tx, instance.id, {
        sequence: 0,
        timestamp: createdAt,
        kind: "snapshot",
        action: "create-instance",
        snapshot: cloneInstance(instance),
      });
    });
    return instance;
  }

  async getInstance(instanceId: string): Promise<TodoInstance> {
    const rows = await this.sql<Array<Record<string, unknown>>>`
      SELECT id, name, file_path, history_path, created_at, updated_at, history_pointer, todos
      FROM todo_note_instances
      WHERE id = ${instanceId}
      LIMIT 1
    `;
    if (!rows[0]) throw new Error(`Instance not found: ${instanceId}`);
    return this.instanceFromRow(rows[0]);
  }

  async deleteInstance(instanceId: string): Promise<void> {
    const result = await this.sql`DELETE FROM todo_note_instances WHERE id = ${instanceId}`;
    if (result.count === 0) throw new Error(`Instance not found: ${instanceId}`);
  }

  async applyAction(instanceId: string, action: TodoAction): Promise<TodoInstance> {
    const instance = await this.getInstance(instanceId);
    const updatedAt = nowIso();
    const updated = applyInstanceAction(instance, action, updatedAt);
    updated.historyPointer = instance.historyPointer + 1;
    updated.filePath = instancePath(updated.id);
    updated.historyPath = historyPath(updated.id);
    await this.sql.begin(async (tx) => {
      await this.upsertInstance(tx, updated, { updatedBy: "action", action: action.type });
      await this.insertHistory(tx, updated.id, {
        sequence: updated.historyPointer,
        timestamp: updatedAt,
        kind: "snapshot",
        action: action.type,
        snapshot: cloneInstance(updated),
      });
    });
    return updated;
  }

  async undo(instanceId: string): Promise<TodoInstance> {
    const instance = await this.getInstance(instanceId);
    if (instance.historyPointer === 0) return instance;
    const snapshot = await this.snapshotAt(instanceId, instance.historyPointer - 1);
    if (!snapshot) throw new Error(`Undo snapshot missing for ${instanceId}`);
    snapshot.historyPointer = instance.historyPointer - 1;
    snapshot.filePath = instancePath(snapshot.id);
    snapshot.historyPath = historyPath(snapshot.id);
    await this.sql.begin(async (tx) => {
      await this.upsertInstance(tx, snapshot, { updatedBy: "undo" });
      await this.insertHistory(tx, snapshot.id, {
        sequence: instance.historyPointer,
        timestamp: nowIso(),
        kind: "command",
        action: "undo",
        detail: { from: instance.historyPointer, to: snapshot.historyPointer },
      });
    });
    return snapshot;
  }

  async redo(instanceId: string): Promise<TodoInstance> {
    const instance = await this.getInstance(instanceId);
    const snapshot = await this.snapshotAt(instanceId, instance.historyPointer + 1);
    if (!snapshot) return instance;
    snapshot.historyPointer = instance.historyPointer + 1;
    snapshot.filePath = instancePath(snapshot.id);
    snapshot.historyPath = historyPath(snapshot.id);
    await this.sql.begin(async (tx) => {
      await this.upsertInstance(tx, snapshot, { updatedBy: "redo" });
      await this.insertHistory(tx, snapshot.id, {
        sequence: snapshot.historyPointer,
        timestamp: nowIso(),
        kind: "command",
        action: "redo",
        detail: { from: instance.historyPointer, to: snapshot.historyPointer },
      });
    });
    return snapshot;
  }

  async exportSnapshot(): Promise<TodoSnapshot> {
    const instanceRows = await this.sql<Array<Record<string, unknown>>>`
      SELECT id, name, file_path, history_path, created_at, updated_at, history_pointer, todos, source
      FROM todo_note_instances
      ORDER BY created_at ASC, id ASC
    `;
    const historyRows = await this.sql<Array<Record<string, unknown>>>`
      SELECT instance_id, sequence, timestamp, kind, action, snapshot, detail
      FROM todo_note_history
      ORDER BY instance_id ASC, id ASC
    `;
    const historyByInstance = new Map<string, HistoryEntry[]>();
    for (const row of historyRows) {
      const instanceId = String(row.instance_id);
      const entries = historyByInstance.get(instanceId) ?? [];
      entries.push(normalizeHistoryEntry({
        sequence: Number(row.sequence),
        timestamp: String(row.timestamp),
        kind: row.kind,
        action: row.action,
        ...(row.snapshot === null ? {} : { snapshot: row.snapshot }),
        ...(row.detail === null ? {} : { detail: row.detail }),
      }));
      historyByInstance.set(instanceId, entries);
    }
    return {
      schemaVersion: 1,
      generatedAt: nowIso(),
      instances: instanceRows.map((row) => ({
        instance: this.instanceFromRow(row),
        history: historyByInstance.get(String(row.id)) ?? [],
        source: asRecord(row.source),
      })),
      reminderNotifications: await this.listReminderNotifications(),
    };
  }

  async listReminderNotifications(): Promise<TodoReminderNotification[]> {
    const rows = await this.sql<Array<Record<string, unknown>>>`
      SELECT instance_id, todo_id, reminder_at, target, title, message, response_preview, sent_at::text AS sent_at
      FROM todo_note_reminder_notifications
      ORDER BY id ASC
    `;
    return rows.map((row) => normalizeTodoReminderNotification({
      instanceId: row.instance_id,
      todoId: row.todo_id,
      reminderAt: row.reminder_at,
      target: row.target,
      title: row.title,
      message: row.message,
      responsePreview: row.response_preview,
      sentAt: row.sent_at,
    }));
  }

  async replaceSnapshot(
    snapshot: unknown,
    source: Record<string, unknown> = {},
    options: { preserveReminderNotifications?: boolean; trustedSnapshot?: boolean } = {},
  ): Promise<TodoSnapshot> {
    const normalized = options.trustedSnapshot === true ? snapshot as TodoSnapshot : normalizeTodoSnapshot(snapshot);
    if (normalized.schemaVersion !== 1 || !Array.isArray(normalized.instances) || !Array.isArray(normalized.reminderNotifications)) {
      throw new Error("Trusted Todo snapshot has an unsupported schema");
    }
    await this.sql.begin(async (tx) => {
      await tx`DELETE FROM todo_note_history`;
      await tx`DELETE FROM todo_note_instances`;
      for (const entry of normalized.instances) {
        await this.upsertInstance(tx, entry.instance, { ...entry.source, ...source });
        for (const historyEntry of entry.history) await this.insertHistory(tx, entry.instance.id, historyEntry);
        if (!entry.history.some((item) => item.kind === "snapshot" && item.sequence === entry.instance.historyPointer)) {
          await this.insertHistory(tx, entry.instance.id, {
            sequence: entry.instance.historyPointer,
            timestamp: entry.instance.updatedAt,
            kind: "snapshot",
            action: "migration-latest",
            snapshot: cloneInstance(entry.instance),
          });
        }
      }
      if (options.preserveReminderNotifications !== true) {
        await tx`DELETE FROM todo_note_reminder_notifications`;
        for (const notification of normalized.reminderNotifications) await this.insertReminderNotification(tx, notification);
      }
    });
    if (options.preserveReminderNotifications === true) return await this.exportSnapshot();
    return normalized;
  }

  async readLogs(): Promise<string> {
    const logPath = process.env.TODO_NOTE_LOG_PATH || process.env.LOG_FILE;
    if (!logPath) return "";
    try {
      return await readFile(logPath, "utf8");
    } catch {
      return "";
    }
  }

  private instanceFromRow(row: Record<string, unknown>): TodoInstance {
    return normalizeInstance({
      id: row.id,
      name: row.name,
      createdAt: row.created_at,
      updatedAt: row.updated_at,
      historyPointer: Number(row.history_pointer),
      todos: row.todos,
    });
  }

  private async snapshotAt(instanceId: string, sequence: number): Promise<TodoInstance | null> {
    const rows = await this.sql<Array<{ snapshot: unknown }>>`
      SELECT snapshot
      FROM todo_note_history
      WHERE instance_id = ${instanceId} AND sequence = ${sequence} AND kind = 'snapshot' AND snapshot IS NOT NULL
      ORDER BY id DESC
      LIMIT 1
    `;
    return rows[0]?.snapshot ? normalizeInstance(rows[0].snapshot) : null;
  }

  private async upsertInstance(tx: postgres.TransactionSql, instance: TodoInstance, source: Record<string, unknown>): Promise<void> {
    const normalized = normalizeInstance(instance);
    await tx`
      INSERT INTO todo_note_instances (id, name, file_path, history_path, created_at, updated_at, history_pointer, todos, source)
      VALUES (${normalized.id}, ${normalized.name}, ${normalized.filePath}, ${normalized.historyPath}, ${normalized.createdAt}, ${normalized.updatedAt}, ${normalized.historyPointer}, ${tx.json(normalized.todos as never)}, ${tx.json(source as never)})
      ON CONFLICT (id) DO UPDATE SET
        name = EXCLUDED.name,
        file_path = EXCLUDED.file_path,
        history_path = EXCLUDED.history_path,
        created_at = EXCLUDED.created_at,
        updated_at = EXCLUDED.updated_at,
        history_pointer = EXCLUDED.history_pointer,
        todos = EXCLUDED.todos,
        source = EXCLUDED.source
    `;
  }

  private async insertHistory(tx: postgres.TransactionSql, instanceId: string, entry: HistoryEntry): Promise<void> {
    const normalized = normalizeHistoryEntry(entry);
    await tx`
      INSERT INTO todo_note_history (instance_id, sequence, timestamp, kind, action, snapshot, detail)
      VALUES (${instanceId}, ${normalized.sequence}, ${normalized.timestamp}, ${normalized.kind}, ${normalized.action}, ${normalized.snapshot === undefined ? null : tx.json(normalized.snapshot as never)}, ${normalized.detail === undefined ? null : tx.json(normalized.detail as never)})
    `;
  }

  private async insertReminderNotification(tx: postgres.TransactionSql, value: TodoReminderNotification): Promise<void> {
    const notification = normalizeTodoReminderNotification(value);
    await tx`
      INSERT INTO todo_note_reminder_notifications (instance_id, todo_id, reminder_at, target, title, message, response_preview, sent_at)
      VALUES (${notification.instanceId}, ${notification.todoId}, ${notification.reminderAt}, ${notification.target}, ${notification.title}, ${notification.message}, ${notification.responsePreview}, ${notification.sentAt})
      ON CONFLICT (instance_id, todo_id, reminder_at) DO UPDATE SET
        target = EXCLUDED.target,
        title = EXCLUDED.title,
        message = EXCLUDED.message,
        response_preview = EXCLUDED.response_preview,
        sent_at = EXCLUDED.sent_at
    `;
  }
}
