import type { DatabaseSync } from "node:sqlite";
import { createAiRequest, ensureAiRequestsSchema, waitForAiRequest } from "../core/aiRequests.ts";

export const BUSINESS_SCOPE = "business";
export const OWNER_SCOPE = "owner";
export const CONTACT_SCOPE_PREFIX = "contact:";

const now = () => new Date().toISOString();
const id = (prefix: string) => `${prefix}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`;

export type MemoryScope = typeof BUSINESS_SCOPE | typeof OWNER_SCOPE | `${typeof CONTACT_SCOPE_PREFIX}${string}`;

export type MemoryEntry = {
  id: string;
  scope: MemoryScope;
  body: string;
  created_at: string;
  updated_at: string;
};

export type MemoryUsage = {
  current: number;
  limit: number;
  percent: number;
  label: string;
};

export type MemoryStoreOptions = {
  businessCharLimit?: number;
  ownerCharLimit?: number;
  contactCharLimit?: number;
};

export type MemoryCompactionAudit = {
  id: number;
  scope: MemoryScope;
  before_count: number;
  after_count: number;
  before_chars: number;
  after_chars: number;
  at: string;
};

export type MemorySearchResult = {
  kind: "message" | "memory";
  id: string | number;
  scope?: string;
  session_id?: string;
  role?: string;
  snippet: string;
  created_at?: string;
  updated_at?: string;
};

const DEFAULT_BUSINESS_LIMIT = 2200;
const DEFAULT_OWNER_LIMIT = 1375;
const DEFAULT_CONTACT_LIMIT = 1375;

export function normalizeScope(scope: string | undefined): MemoryScope {
  const raw = (scope ?? BUSINESS_SCOPE).trim();
  if (raw === BUSINESS_SCOPE || raw === OWNER_SCOPE) return raw;
  if (raw.startsWith(CONTACT_SCOPE_PREFIX) && raw.slice(CONTACT_SCOPE_PREFIX.length).trim()) {
    return `${CONTACT_SCOPE_PREFIX}${raw.slice(CONTACT_SCOPE_PREFIX.length).trim()}` as MemoryScope;
  }
  throw new Error("scope must be 'business', 'owner', or 'contact:<contact_id>'");
}

function ftsEscape(value: string): string {
  return `"${value.replace(/"/g, '""')}"`;
}

export class WorkMemoryStore {
  private readonly db: DatabaseSync;
  private readonly businessCharLimit: number;
  private readonly ownerCharLimit: number;
  private readonly contactCharLimit: number;

  constructor(db: DatabaseSync, options: MemoryStoreOptions = {}) {
    this.db = db;
    this.businessCharLimit = options.businessCharLimit ?? DEFAULT_BUSINESS_LIMIT;
    this.ownerCharLimit = options.ownerCharLimit ?? DEFAULT_OWNER_LIMIT;
    this.contactCharLimit = options.contactCharLimit ?? DEFAULT_CONTACT_LIMIT;
    ensureMemorySchema(db);
  }

  add(scopeInput: string | undefined, bodyInput: string): { entry: MemoryEntry; entries: MemoryEntry[]; usage: MemoryUsage; added: boolean } {
    const scope = normalizeScope(scopeInput);
    const body = bodyInput.trim();
    if (!body) throw new Error("content cannot be empty");

    const entries = this.list(scope);
    if (entries.some((entry) => entry.body === body)) {
      return { entry: entries.find((entry) => entry.body === body)!, entries, usage: this.usage(scope, entries), added: false };
    }

    const nextBodies = [...entries.map((entry) => entry.body), body];
    this.assertBudget(scope, nextBodies, `Adding this entry (${body.length} chars) would exceed the limit.`);

    const ts = now();
    const entry: MemoryEntry = { id: id("wm"), scope, body, created_at: ts, updated_at: ts };
    this.db.prepare(`
      INSERT INTO work_memory_entries(id, scope, body, created_at, updated_at)
      VALUES (?, ?, ?, ?, ?)
    `).run(entry.id, entry.scope, entry.body, entry.created_at, entry.updated_at);

    const updated = this.list(scope);
    return { entry, entries: updated, usage: this.usage(scope, updated), added: true };
  }

  async addWithCompaction(scopeInput: string | undefined, bodyInput: string): Promise<{
    entry: MemoryEntry;
    entries: MemoryEntry[];
    usage: MemoryUsage;
    added: boolean;
    compacted: boolean;
  }> {
    const result = this.addWithoutBudgetCheck(scopeInput, bodyInput);
    if (!result.added) return { ...result, compacted: false };

    const usage = this.usage(result.entry.scope, result.entries);
    if (usage.current <= usage.limit) return { ...result, usage, compacted: false };

    this.markScopeDirty(result.entry.scope, true);
    const compacted = await this.compactScope(result.entry.scope);
    const entries = this.list(result.entry.scope);
    return { entry: result.entry, entries, usage: this.usage(result.entry.scope, entries), added: true, compacted };
  }

  replace(scopeInput: string | undefined, oldTextInput: string, bodyInput: string): { entry: MemoryEntry; entries: MemoryEntry[]; usage: MemoryUsage } {
    const scope = normalizeScope(scopeInput);
    const oldText = oldTextInput.trim();
    const body = bodyInput.trim();
    if (!oldText) throw new Error("old_text cannot be empty");
    if (!body) throw new Error("content cannot be empty. Use remove to delete entries.");

    const entries = this.list(scope);
    const match = this.uniqueSubstringMatch(entries, oldText);
    const nextBodies = entries.map((entry) => entry.id === match.id ? body : entry.body);
    this.assertBudget(scope, nextBodies, "Replacement would exceed the memory limit.");

    const ts = now();
    this.db.prepare(`
      UPDATE work_memory_entries SET body=?, updated_at=? WHERE id=?
    `).run(body, ts, match.id);

    const updatedEntry = this.db.prepare(`
      SELECT id, scope, body, created_at, updated_at FROM work_memory_entries WHERE id=?
    `).get(match.id) as MemoryEntry;
    const updated = this.list(scope);
    return { entry: updatedEntry, entries: updated, usage: this.usage(scope, updated) };
  }

  remove(scopeInput: string | undefined, oldTextInput: string): { removed: MemoryEntry; entries: MemoryEntry[]; usage: MemoryUsage } {
    const scope = normalizeScope(scopeInput);
    const oldText = oldTextInput.trim();
    if (!oldText) throw new Error("old_text cannot be empty");

    const entries = this.list(scope);
    const match = this.uniqueSubstringMatch(entries, oldText);
    this.db.prepare(`DELETE FROM work_memory_entries WHERE id=?`).run(match.id);
    const updated = this.list(scope);
    return { removed: match, entries: updated, usage: this.usage(scope, updated) };
  }

  read(scopeInput?: string): { scope: MemoryScope; entries: MemoryEntry[]; usage: MemoryUsage } {
    const scope = normalizeScope(scopeInput);
    const entries = this.list(scope);
    return { scope, entries, usage: this.usage(scope, entries) };
  }

  compactions(scopeInput?: string): MemoryCompactionAudit[] {
    const params: unknown[] = [];
    let where = "";
    if (scopeInput) {
      where = "WHERE scope=?";
      params.push(normalizeScope(scopeInput));
    }
    return this.db.prepare(`
      SELECT id, scope, before_count, after_count, before_chars, after_chars, at
      FROM memory_compactions
      ${where}
      ORDER BY id ASC
    `).all(...params) as MemoryCompactionAudit[];
  }

  list(scopeInput: string): MemoryEntry[] {
    const scope = normalizeScope(scopeInput);
    return this.db.prepare(`
      SELECT id, scope, body, created_at, updated_at
      FROM work_memory_entries
      WHERE scope=?
      ORDER BY created_at ASC, id ASC
    `).all(scope) as MemoryEntry[];
  }

  getSnapshot(scopes: string[]): Map<MemoryScope, MemoryEntry[]> {
    const result = new Map<MemoryScope, MemoryEntry[]>();
    for (const scopeInput of scopes) {
      const scope = normalizeScope(scopeInput);
      result.set(scope, this.list(scope));
    }
    return result;
  }

  search(query: string, options: { limit?: number; scope?: string } = {}): MemorySearchResult[] {
    const q = query.trim();
    if (!q) throw new Error("query cannot be empty");
    const limit = Math.max(1, Math.min(options.limit ?? 20, 100));
    const escaped = ftsEscape(q);
    const results: MemorySearchResult[] = [];

    const messageRows = this.db.prepare(`
      SELECT 'message' AS kind, m.id, m.session_id, m.role,
             snippet(work_agent_messages_fts, 0, '>>>', '<<<', '...', 32) AS snippet,
             s.created_at, s.updated_at
      FROM work_agent_messages_fts
      JOIN work_agent_messages m ON m.id = work_agent_messages_fts.rowid
      LEFT JOIN work_agent_sessions s ON s.id = m.session_id
      WHERE work_agent_messages_fts MATCH ? AND m.active=1
      ORDER BY rank
      LIMIT ?
    `).all(escaped, limit) as MemorySearchResult[];
    results.push(...messageRows);

    const params: unknown[] = [escaped];
    let scopeWhere = "";
    if (options.scope) {
      scopeWhere = "AND e.scope=?";
      params.push(normalizeScope(options.scope));
    }
    params.push(limit);
    const memoryRows = this.db.prepare(`
      SELECT 'memory' AS kind, e.id, e.scope,
             snippet(work_memory_entries_fts, 1, '>>>', '<<<', '...', 32) AS snippet,
             e.created_at, e.updated_at
      FROM work_memory_entries_fts
      JOIN work_memory_entries e ON e.id = work_memory_entries_fts.entry_id
      WHERE work_memory_entries_fts MATCH ? ${scopeWhere}
      ORDER BY rank
      LIMIT ?
    `).all(...params) as MemorySearchResult[];
    results.push(...memoryRows);

    return results.slice(0, limit);
  }

  createSession(a: {
    id?: string;
    source: string;
    parent_session_id?: string | null;
    model?: string | null;
    input_tokens?: number;
    output_tokens?: number;
    cache_read_tokens?: number;
    cache_write_tokens?: number;
    reasoning_tokens?: number;
  }) {
    const sessionId = a.id ?? id("was");
    const ts = now();
    this.db.prepare(`
      INSERT INTO work_agent_sessions(
        id, source, parent_session_id, model, input_tokens, output_tokens,
        cache_read_tokens, cache_write_tokens, reasoning_tokens, created_at, updated_at
      )
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      ON CONFLICT(id) DO UPDATE SET
        source=excluded.source,
        parent_session_id=excluded.parent_session_id,
        model=excluded.model,
        input_tokens=excluded.input_tokens,
        output_tokens=excluded.output_tokens,
        cache_read_tokens=excluded.cache_read_tokens,
        cache_write_tokens=excluded.cache_write_tokens,
        reasoning_tokens=excluded.reasoning_tokens,
        updated_at=excluded.updated_at
    `).run(
      sessionId,
      a.source,
      a.parent_session_id ?? null,
      a.model ?? null,
      a.input_tokens ?? 0,
      a.output_tokens ?? 0,
      a.cache_read_tokens ?? 0,
      a.cache_write_tokens ?? 0,
      a.reasoning_tokens ?? 0,
      ts,
      ts,
    );
    return this.db.prepare(`SELECT * FROM work_agent_sessions WHERE id=?`).get(sessionId);
  }

  addMessage(a: { session_id: string; role: string; content?: string | null; tool_calls_json?: unknown; active?: boolean }) {
    const toolCalls = typeof a.tool_calls_json === "string"
      ? a.tool_calls_json
      : a.tool_calls_json == null
        ? null
        : JSON.stringify(a.tool_calls_json);
    const ts = now();
    const result = this.db.prepare(`
      INSERT INTO work_agent_messages(session_id, role, content, tool_calls_json, active, created_at)
      VALUES (?, ?, ?, ?, ?, ?)
    `).run(a.session_id, a.role, a.content ?? null, toolCalls, a.active === false ? 0 : 1, ts);
    this.db.prepare(`UPDATE work_agent_sessions SET updated_at=? WHERE id=?`).run(ts, a.session_id);
    return this.db.prepare(`SELECT * FROM work_agent_messages WHERE id=?`).get(Number(result.lastInsertRowid));
  }

  private uniqueSubstringMatch(entries: MemoryEntry[], oldText: string): MemoryEntry {
    const matches = entries.filter((entry) => entry.body.includes(oldText));
    if (matches.length === 0) throw new Error(`No entry matched '${oldText}'.`);
    const uniqueBodies = new Set(matches.map((entry) => entry.body));
    if (matches.length > 1 && uniqueBodies.size > 1) {
      const previews = matches.map((entry) => entry.body.slice(0, 80) + (entry.body.length > 80 ? "..." : ""));
      const err = new Error(`Multiple entries matched '${oldText}'. Be more specific.`);
      (err as Error & { matches?: string[] }).matches = previews;
      throw err;
    }
    return matches[0];
  }

  private addWithoutBudgetCheck(scopeInput: string | undefined, bodyInput: string): {
    entry: MemoryEntry;
    entries: MemoryEntry[];
    usage: MemoryUsage;
    added: boolean;
  } {
    const scope = normalizeScope(scopeInput);
    const body = bodyInput.trim();
    if (!body) throw new Error("content cannot be empty");

    const entries = this.list(scope);
    const existing = entries.find((entry) => entry.body === body);
    if (existing) return { entry: existing, entries, usage: this.usage(scope, entries), added: false };

    const ts = now();
    const entry: MemoryEntry = { id: id("wm"), scope, body, created_at: ts, updated_at: ts };
    this.db.prepare(`
      INSERT INTO work_memory_entries(id, scope, body, created_at, updated_at)
      VALUES (?, ?, ?, ?, ?)
    `).run(entry.id, entry.scope, entry.body, entry.created_at, entry.updated_at);

    const updated = this.list(scope);
    return { entry, entries: updated, usage: this.usage(scope, updated), added: true };
  }

  private async compactScope(scope: MemoryScope): Promise<boolean> {
    const beforeEntries = this.list(scope);
    const beforeChars = this.entryChars(beforeEntries);
    const limit = this.limitFor(scope);
    const prompt = this.buildCompactionPrompt(scope, beforeEntries, limit);
    const request = createAiRequest(this.db, {
      instructions: [
        "You compact durable memory facts for DewLink.",
        "Return only plain text, one fact per line.",
        "Never drop a fact that appears only once.",
        "Merge duplicates and strict supersets.",
        "Keep contact-specific wording exactly scoped to the contact when present.",
      ].join("\n"),
      prompt,
    });

    let row;
    try {
      row = await waitForAiRequest(this.db, request.id, { timeoutMs: 30_000, pollMs: 250 });
    } catch (error: any) {
      console.warn(`[memory] Compaction failed for ${scope}: ${error?.message ?? String(error)}`);
      return false;
    }

    if (row.status !== "done" || !row.response) {
      console.warn(`[memory] Compaction failed for ${scope}: ${row.error ?? "AI request did not complete."}`);
      return false;
    }

    const compactedBodies = this.parseCompactedFacts(row.response);
    const afterChars = compactedBodies.join("\n§\n").length;
    if (!compactedBodies.length || afterChars > limit) {
      console.warn(`[memory] Ignoring malformed compaction for ${scope}: ${compactedBodies.length} facts, ${afterChars}/${limit} chars.`);
      return false;
    }

    this.replaceScopeEntries(scope, compactedBodies, {
      beforeCount: beforeEntries.length,
      beforeChars,
      afterChars,
    });
    this.markScopeDirty(scope, false);
    return true;
  }

  private buildCompactionPrompt(scope: MemoryScope, entries: MemoryEntry[], limit: number): string {
    const body = entries.map((entry, index) => `${index + 1}. ${entry.body}`).join("\n");
    return [
      `Scope: ${scope}`,
      `Character budget: ${limit}`,
      "",
      "Rewrite ALL memory entries below into a smaller deduplicated set of durable facts.",
      "Hard rules:",
      "- NEVER drop a fact that appears only once.",
      "- Merge duplicates and entries where one fact strictly contains another.",
      "- Preserve names, dates, constraints, approvals, contact preferences, and contact-specific wording.",
      "- Return one fact per line, no bullets, no numbering, no JSON, no commentary.",
      "",
      "Entries:",
      body,
    ].join("\n");
  }

  private parseCompactedFacts(response: string): string[] {
    const lines = response.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
    if (!lines.length) return [];
    if (lines.some((line) => line.startsWith("{") || line.startsWith("[") || line.endsWith("}"))) return [];
    return lines;
  }

  private replaceScopeEntries(scope: MemoryScope, bodies: string[], audit: { beforeCount: number; beforeChars: number; afterChars: number }): void {
    const ts = now();
    this.db.exec("BEGIN");
    try {
      this.db.prepare(`DELETE FROM work_memory_entries WHERE scope=?`).run(scope);
      const insert = this.db.prepare(`
        INSERT INTO work_memory_entries(id, scope, body, created_at, updated_at)
        VALUES (?, ?, ?, ?, ?)
      `);
      for (const body of bodies) {
        insert.run(id("wm"), scope, body, ts, ts);
      }
      this.db.prepare(`
        INSERT INTO memory_compactions(scope, before_count, after_count, before_chars, after_chars, at)
        VALUES (?, ?, ?, ?, ?, ?)
      `).run(scope, audit.beforeCount, bodies.length, audit.beforeChars, audit.afterChars, ts);
      this.db.exec("COMMIT");
    } catch (error) {
      this.db.exec("ROLLBACK");
      throw error;
    }
  }

  private markScopeDirty(scope: MemoryScope, dirty: boolean): void {
    const ts = now();
    this.db.prepare(`
      INSERT INTO memory_scope_state(scope, dirty, updated_at)
      VALUES (?, ?, ?)
      ON CONFLICT(scope) DO UPDATE SET dirty=excluded.dirty, updated_at=excluded.updated_at
    `).run(scope, dirty ? 1 : 0, ts);
  }

  private usage(scope: MemoryScope, entries: MemoryEntry[]): MemoryUsage {
    const current = this.entryChars(entries);
    const limit = this.limitFor(scope);
    const percent = limit > 0 ? Math.min(100, Math.floor((current / limit) * 100)) : 0;
    return { current, limit, percent, label: `${percent}% - ${current.toLocaleString()}/${limit.toLocaleString()} chars` };
  }

  private assertBudget(scope: MemoryScope, bodies: string[], prefix: string): void {
    const limit = this.limitFor(scope);
    const total = bodies.join("\n§\n").length;
    if (total <= limit) return;
    throw new Error(`${prefix} Memory at ${total.toLocaleString()}/${limit.toLocaleString()} chars. Consolidate overlapping entries or remove stale entries, then retry.`);
  }

  private entryChars(entries: MemoryEntry[]): number {
    return entries.map((entry) => entry.body).join("\n§\n").length;
  }

  private limitFor(scope: MemoryScope): number {
    if (scope === BUSINESS_SCOPE) return this.businessCharLimit;
    if (scope === OWNER_SCOPE) return this.ownerCharLimit;
    return this.contactCharLimit;
  }
}

export function ensureMemorySchema(db: DatabaseSync): void {
  ensureAiRequestsSchema(db);
  db.exec(`
    CREATE TABLE IF NOT EXISTS work_agent_sessions (
      id TEXT PRIMARY KEY,
      source TEXT NOT NULL,
      parent_session_id TEXT REFERENCES work_agent_sessions(id),
      model TEXT,
      input_tokens INTEGER NOT NULL DEFAULT 0,
      output_tokens INTEGER NOT NULL DEFAULT 0,
      cache_read_tokens INTEGER NOT NULL DEFAULT 0,
      cache_write_tokens INTEGER NOT NULL DEFAULT 0,
      reasoning_tokens INTEGER NOT NULL DEFAULT 0,
      created_at TEXT NOT NULL,
      updated_at TEXT NOT NULL
    );

    CREATE TABLE IF NOT EXISTS work_agent_messages (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      session_id TEXT NOT NULL REFERENCES work_agent_sessions(id) ON DELETE CASCADE,
      role TEXT NOT NULL,
      content TEXT,
      tool_calls_json TEXT,
      active INTEGER NOT NULL DEFAULT 1,
      created_at TEXT NOT NULL
    );

    CREATE TABLE IF NOT EXISTS work_memory_entries (
      id TEXT PRIMARY KEY,
      scope TEXT NOT NULL CHECK(scope='business' OR scope='owner' OR scope LIKE 'contact:%'),
      body TEXT NOT NULL,
      created_at TEXT NOT NULL,
      updated_at TEXT NOT NULL
    );

    CREATE TABLE IF NOT EXISTS memory_scope_state (
      scope TEXT PRIMARY KEY CHECK(scope='business' OR scope='owner' OR scope LIKE 'contact:%'),
      dirty INTEGER NOT NULL DEFAULT 0 CHECK(dirty IN (0,1)),
      updated_at TEXT NOT NULL
    );

    CREATE TABLE IF NOT EXISTS memory_compactions (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      scope TEXT NOT NULL CHECK(scope='business' OR scope='owner' OR scope LIKE 'contact:%'),
      before_count INTEGER NOT NULL,
      after_count INTEGER NOT NULL,
      before_chars INTEGER NOT NULL,
      after_chars INTEGER NOT NULL,
      at TEXT NOT NULL
    );

    CREATE INDEX IF NOT EXISTS ix_work_agent_sessions_source ON work_agent_sessions(source, updated_at);
    CREATE INDEX IF NOT EXISTS ix_work_agent_sessions_parent ON work_agent_sessions(parent_session_id);
    CREATE INDEX IF NOT EXISTS ix_work_agent_messages_session ON work_agent_messages(session_id, active, created_at);
    CREATE INDEX IF NOT EXISTS ix_work_memory_entries_scope ON work_memory_entries(scope, updated_at);
    CREATE INDEX IF NOT EXISTS ix_memory_compactions_scope_at ON memory_compactions(scope, at);

    CREATE VIRTUAL TABLE IF NOT EXISTS work_agent_messages_fts USING fts5(
      content,
      tokenize='trigram'
    );

    CREATE TRIGGER IF NOT EXISTS work_agent_messages_fts_insert AFTER INSERT ON work_agent_messages BEGIN
      INSERT INTO work_agent_messages_fts(rowid, content) VALUES (
        new.id,
        COALESCE(new.content, '') || ' ' || COALESCE(new.tool_calls_json, '')
      );
    END;

    CREATE TRIGGER IF NOT EXISTS work_agent_messages_fts_delete AFTER DELETE ON work_agent_messages BEGIN
      DELETE FROM work_agent_messages_fts WHERE rowid = old.id;
    END;

    CREATE TRIGGER IF NOT EXISTS work_agent_messages_fts_update AFTER UPDATE ON work_agent_messages BEGIN
      DELETE FROM work_agent_messages_fts WHERE rowid = old.id;
      INSERT INTO work_agent_messages_fts(rowid, content) VALUES (
        new.id,
        COALESCE(new.content, '') || ' ' || COALESCE(new.tool_calls_json, '')
      );
    END;

    CREATE VIRTUAL TABLE IF NOT EXISTS work_memory_entries_fts USING fts5(
      entry_id UNINDEXED,
      content,
      tokenize='trigram'
    );

    CREATE TRIGGER IF NOT EXISTS work_memory_entries_fts_insert AFTER INSERT ON work_memory_entries BEGIN
      INSERT INTO work_memory_entries_fts(entry_id, content) VALUES (new.id, new.body);
    END;

    CREATE TRIGGER IF NOT EXISTS work_memory_entries_fts_delete AFTER DELETE ON work_memory_entries BEGIN
      DELETE FROM work_memory_entries_fts WHERE entry_id = old.id;
    END;

    CREATE TRIGGER IF NOT EXISTS work_memory_entries_fts_update AFTER UPDATE ON work_memory_entries BEGIN
      DELETE FROM work_memory_entries_fts WHERE entry_id = old.id;
      INSERT INTO work_memory_entries_fts(entry_id, content) VALUES (new.id, new.body);
    END;
  `);
}
