import sqlite3 from "sqlite3";
import type { DatabaseAdapter } from "../db.js";
import { hmacHex } from "../privacy.js";
import { isAllowedMcpServer } from "../repo-catalog.js";
import { FileImportError } from "./file-import-contract.js";

/**
 * Cursor does not store chat sessions as JSON/JSONL files like the other clients. Instead each
 * root (global storage, and each workspace storage folder) contains a single SQLite file named
 * literally `state.vscdb`, with a generic key/value table `cursorDiskKV` (key TEXT PRIMARY KEY,
 * value TEXT holding JSON). Sessions live under `composerData:<composerId>` keys and individual
 * messages under `bubbleId:<composerId>:<bubbleId>` keys.
 *
 * IMPORTANT: this shape is reverse-engineered from public community sources, not an official
 * Cursor API/schema. It is known to have shifted across Cursor releases in the past and may shift
 * again. Every read in this file must degrade gracefully (skip the row/file, emit a warning) when
 * a field is missing or has an unexpected type -- never throw on unexpected shapes.
 *
 * Token usage does not appear to be available from this local storage, so `token_available`
 * always reports 0/false for Cursor sessions and messages, the same way other adapters behave
 * when their source has no usable token counters.
 *
 * We intentionally do not reuse `SqliteAdapter` from ../db.ts here: that class always opens its
 * database read-write (and db.ts is out of scope for this change), whereas the Cursor `state.vscdb`
 * file may be open live in the running Cursor app, so we need `sqlite3.OPEN_READONLY` to avoid
 * corrupting it or failing to open it due to an exclusive lock. This is the same `sqlite3` package
 * dependency and callback-to-promise wrapping style used by db.ts, just opened in read-only mode.
 */

interface ImportCounters {
  sessions_upserted: number;
  messages_upserted: number;
  runtime_events_upserted: number;
}

interface WarningEntry {
  code: string;
  message_code: string;
  severity: "info" | "warning" | "error";
  source_file_id?: string;
  details?: Record<string, number | string | boolean>;
}

function warn(code: string, sourceFileId: string, details?: WarningEntry["details"]): WarningEntry {
  return { code, message_code: code, severity: "warning", source_file_id: sourceFileId, ...(details ? { details } : {}) };
}

function cleanRole(value: unknown): "user" | "assistant" | "unknown" {
  if (value === "user" || value === "assistant") return value;
  if (value === "ai" || value === "assistant_message" || value === 2) return "assistant";
  if (value === "human" || value === "user_message" || value === 1) return "user";
  return "unknown";
}

function openReadOnly(path: string): Promise<sqlite3.Database> {
  return new Promise((resolvePromise, reject) => {
    const handle: sqlite3.Database = new sqlite3.Database(path, sqlite3.OPEN_READONLY, (err: Error | null) => {
      if (err) {
        reject(err);
        return;
      }
      resolvePromise(handle);
    });
  });
}

function allRows(handle: sqlite3.Database, sql: string): Promise<Array<{ key: string; value: string }>> {
  return new Promise((resolvePromise, reject) => {
    handle.all(sql, (err: Error | null, rows: Array<{ key: string; value: string }>) => {
      if (err) {
        reject(err);
        return;
      }
      resolvePromise(rows ?? []);
    });
  });
}

function closeQuietly(handle: sqlite3.Database): Promise<void> {
  return new Promise((resolvePromise) => {
    handle.close(() => resolvePromise());
  });
}

interface ComposerMeta {
  composerId: string;
  name: string | null;
  createdAt: string | null;
  updatedAt: string | null;
  model: string | null;
}

type ComposerParseResult =
  | { kind: "valid"; meta: ComposerMeta }
  | { kind: "malformed"; composerId: string }
  | { kind: "unrecognized"; composerId: string };

interface BubbleEntry {
  composerId: string;
  bubbleId: string;
  role: "user" | "assistant" | "unknown";
  timestamp: string | null;
  model: string | null;
  mcpCall: { server: string; tool: string } | null;
  originalIndex: number;
}

type BubbleParseResult =
  | { kind: "valid"; bubble: BubbleEntry }
  | { kind: "malformed"; composerId: string }
  | { kind: "unrecognized"; composerId: string }
  | { kind: "invalid_key" };

function toIsoOrNull(value: unknown): string | null {
  if (typeof value === "number" && Number.isFinite(value)) {
    // Timestamps have been observed both in epoch-ms and epoch-seconds form; treat anything
    // below the year-2001-in-ms threshold as seconds.
    const ms = value >= 1e12 ? value : value * 1000;
    const d = new Date(ms);
    return Number.isNaN(d.getTime()) ? null : d.toISOString();
  }
  if (typeof value === "string" && value.length > 0) {
    const d = new Date(value);
    return Number.isNaN(d.getTime()) ? null : d.toISOString();
  }
  return null;
}

function parseComposerData(key: string, raw: string): ComposerParseResult {
  const m = /^composerData:(.+)$/u.exec(key);
  const composerId = m?.[1] ?? "";
  if (!m) return { kind: "unrecognized", composerId };
  try {
    const value = JSON.parse(raw) as unknown;
    if (!value || typeof value !== "object" || Array.isArray(value)) return { kind: "unrecognized", composerId };
    const parsed = value as Record<string, unknown>;
    const name = typeof parsed.name === "string" && parsed.name.trim().length > 0 ? parsed.name.trim().slice(0, 200) : null;
    const createdAt = toIsoOrNull(parsed.createdAt);
    const updatedAt = toIsoOrNull(parsed.lastUpdatedAt ?? parsed.updatedAt);
    const model = typeof parsed.model === "string" && parsed.model.trim().length > 0 ? parsed.model.trim().slice(0, 160) : null;
    if (name === null && createdAt === null && updatedAt === null && model === null) {
      return { kind: "unrecognized", composerId };
    }
    return { kind: "valid", meta: { composerId, name, createdAt, updatedAt, model } };
  } catch {
    return { kind: "malformed", composerId };
  }
}

function parseMcpToolName(name: string): { server: string; tool: string } | null {
  const m = /^mcp__([a-z0-9_]+)__([a-zA-Z0-9_]+)$/u.exec(name);
  if (!m) return null;
  return { server: m[1].replaceAll("_", "-"), tool: m[2] };
}

function extractBestEffortMcpCall(bubble: Record<string, unknown>): { server: string; tool: string } | null {
  // Tool/MCP-call metadata inside a bubble is the least certain part of this schema. Different
  // Cursor versions have used different shapes (observed: a `toolFormerData` object holding a
  // `name`/`tool` field). We only ever emit a runtime_event when we can confidently resolve an
  // `mcp__<server>__<tool>`-shaped name against the repo's MCP allowlist; anything else is
  // silently dropped rather than guessed at.
  const toolFormer =
    bubble.toolFormerData && typeof bubble.toolFormerData === "object" ? (bubble.toolFormerData as Record<string, unknown>) : null;
  const candidateNames: unknown[] = [
    toolFormer?.name,
    toolFormer?.tool,
    bubble.toolName,
    bubble.tool_name
  ];
  for (const candidate of candidateNames) {
    if (typeof candidate === "string" && candidate.length > 0) {
      const parsed = parseMcpToolName(candidate);
      if (parsed) return parsed;
    }
  }
  return null;
}

function parseBubble(key: string, raw: string, originalIndex: number): BubbleParseResult {
  const m = /^bubbleId:([^:]+):(.+)$/u.exec(key);
  if (!m) return { kind: "invalid_key" };
  const composerId = m[1];
  const bubbleId = m[2];
  try {
    const parsed = JSON.parse(raw) as Record<string, unknown>;
    const role = cleanRole(parsed.role ?? parsed.type);
    const tsRaw = parsed.timestamp ?? parsed.createdAt;
    const timestamp = toIsoOrNull(tsRaw);
    const model = typeof parsed.model === "string" && parsed.model.length > 0 ? parsed.model.slice(0, 160) : null;
    const mcpCall = extractBestEffortMcpCall(parsed);
    const hasRecognizedRole = role !== "unknown";
    const hasValidMcpCall = mcpCall !== null && isAllowedMcpServer(mcpCall.server);
    if (!hasRecognizedRole && timestamp === null && model === null && !hasValidMcpCall) return { kind: "unrecognized", composerId };
    return { kind: "valid", bubble: { composerId, bubbleId, role, timestamp, model, mcpCall, originalIndex } };
  } catch {
    // An unparseable bubble carries no reliable role/timestamp at all -- drop it and let the
    // caller count it as a warning, the same way the other adapters skip invalid JSON lines
    // instead of inserting a placeholder row.
    return { kind: "malformed", composerId };
  }
}

export async function importCursorFile(
  context: { db: DatabaseAdapter; sourceFileId: string; machineId: string; hmacSalt: string },
  absolutePath: string
): Promise<{ counters: ImportCounters; warnings: WarningEntry[] }> {
  const counters: ImportCounters = { sessions_upserted: 0, messages_upserted: 0, runtime_events_upserted: 0 };
  const warnings: WarningEntry[] = [];

  let handle: sqlite3.Database;
  try {
    handle = await openReadOnly(absolutePath);
  } catch {
    // Locked (Cursor running and holding the file), corrupt, missing, or otherwise unreadable --
    // never let a single vscdb file abort the whole scan.
    throw new FileImportError("SOURCE_LOCKED_OR_UNREADABLE");
  }

  let rows: Array<{ key: string; value: string }>;
  try {
    rows = await allRows(handle, "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%' OR key LIKE 'bubbleId:%'");
  } catch {
    // Table missing / schema changed under us.
    await closeQuietly(handle);
    throw new FileImportError("SOURCE_SCHEMA_UNSUPPORTED");
  }
  await closeQuietly(handle);

  const composers = new Map<string, ComposerMeta>();
  const bubblesByComposer = new Map<string, BubbleEntry[]>();
  const observedComposerIds = new Set<string>();
  const bubbleRowsByComposer = new Map<string, number>();
  const malformedMetadataComposerIds = new Set<string>();
  const unrecognizedMetadataComposerIds = new Set<string>();
  const malformedBubbleComposerIds = new Set<string>();
  const unrecognizedBubbleComposerIds = new Set<string>();
  let malformedRows = 0;

  for (const row of rows) {
    if (typeof row.key !== "string" || typeof row.value !== "string") continue;
    if (row.key.startsWith("composerData:")) {
      const composerId = row.key.slice("composerData:".length);
      if (composerId) observedComposerIds.add(composerId);
      const parsedMeta = parseComposerData(row.key, row.value);
      if (parsedMeta.kind === "valid") composers.set(parsedMeta.meta.composerId, parsedMeta.meta);
      else {
        malformedRows += 1;
        if (composerId && parsedMeta.kind === "malformed") malformedMetadataComposerIds.add(composerId);
        if (composerId && parsedMeta.kind === "unrecognized") unrecognizedMetadataComposerIds.add(composerId);
      }
      continue;
    }
    if (row.key.startsWith("bubbleId:")) {
      const keyMatch = /^bubbleId:([^:]+):(.+)$/u.exec(row.key);
      if (keyMatch) {
        observedComposerIds.add(keyMatch[1]);
        bubbleRowsByComposer.set(keyMatch[1], (bubbleRowsByComposer.get(keyMatch[1]) ?? 0) + 1);
      }
      const parsedBubble = parseBubble(row.key, row.value, bubblesByComposer.get(keyMatch?.[1] ?? "")?.length ?? 0);
      if (parsedBubble.kind !== "valid") {
        malformedRows += 1;
        if (parsedBubble.kind === "malformed") malformedBubbleComposerIds.add(parsedBubble.composerId);
        if (parsedBubble.kind === "unrecognized") unrecognizedBubbleComposerIds.add(parsedBubble.composerId);
        continue;
      }
      const bubble = parsedBubble.bubble;
      const list = bubblesByComposer.get(bubble.composerId) ?? [];
      list.push(bubble);
      bubblesByComposer.set(bubble.composerId, list);
    }
  }

  if (malformedRows > 0) {
    warnings.push(warn("INVALID_JSON_LINE_SKIPPED", context.sourceFileId, {
      count: malformedRows,
      observed_composers: observedComposerIds.size,
      malformed_metadata_composers: malformedMetadataComposerIds.size,
      unrecognized_metadata_composers: unrecognizedMetadataComposerIds.size,
      malformed_bubble_composers: malformedBubbleComposerIds.size,
      unrecognized_bubble_composers: unrecognizedBubbleComposerIds.size
    }));
  }

  const explicitlyEmptyComposerIds = [...composers.keys()].filter((composerId) => (bubbleRowsByComposer.get(composerId) ?? 0) === 0);
  const usableComposerIds = new Set<string>([...bubblesByComposer.keys(), ...explicitlyEmptyComposerIds]);
  if (usableComposerIds.size === 0) {
    throw new FileImportError("SOURCE_SCHEMA_UNSUPPORTED", "SOURCE_SCHEMA_UNSUPPORTED", warnings);
  }

  // Counters are deliberately private to this transaction.  Do not expose
  // values from a transaction that was rolled back to the scanner.
  const committedCounters: ImportCounters = { sessions_upserted: 0, messages_upserted: 0, runtime_events_upserted: 0 };
  let eventSeq = 0;
  await context.db.exec("BEGIN IMMEDIATE TRANSACTION");
  try {
    for (const composerId of usableComposerIds) {
      const meta = composers.get(composerId) ?? null;
      const bubbles = (bubblesByComposer.get(composerId) ?? []).slice().sort((a, b) => {
        if (a.timestamp !== null && b.timestamp !== null) return a.timestamp.localeCompare(b.timestamp) || a.bubbleId.localeCompare(b.bubbleId);
        if (a.timestamp !== null) return -1;
        if (b.timestamp !== null) return 1;
        return a.bubbleId.localeCompare(b.bubbleId) || a.originalIndex - b.originalIndex;
      });
      const sessionId = hmacHex(`${context.machineId}:cursor:${composerId}`, context.hmacSalt);
      if (bubbles.length === 0) {
        // Cleanup is authorized only by parseable composer metadata and the explicit absence of
        // every bubble row. Malformed/unrecognized bubbles never prove that a session is empty.
        await context.db.run("DELETE FROM sessions WHERE id = ? AND source = 'cursor' AND source_file_id = ?", [sessionId, context.sourceFileId]);
        continue;
      }
      const sourceSessionHash = hmacHex(composerId, context.hmacSalt);
      const createdAt = meta?.createdAt ?? null;
      const updatedAt = meta?.updatedAt ?? createdAt ?? new Date().toISOString();
      const modelPrimary = meta?.model ?? bubbles.find((b) => b.model)?.model ?? null;
      const createdAtSource: "native" | "unknown" = meta?.createdAt ? "native" : "unknown";

      await context.db.run("DELETE FROM message_metrics WHERE session_id = ?", [sessionId]);
      await context.db.run("DELETE FROM runtime_events WHERE session_id = ?", [sessionId]);

      await context.db.run(
        `INSERT INTO sessions(
          id, source, source_session_hash, source_file_id, machine_id, project_id, mode, model_primary, token_available,
          message_count, user_message_count, assistant_message_count, created_at, updated_at, created_at_source, client_surface, session_kind, metadata_json
        ) VALUES (?, 'cursor', ?, ?, ?, NULL, 'chat', ?, 0, ?, ?, ?, ?, ?, ?, 'desktop', 'main', NULL)
        ON CONFLICT(id) DO UPDATE SET
          source_file_id=excluded.source_file_id, model_primary=excluded.model_primary,
          token_available=excluded.token_available, message_count=excluded.message_count,
          user_message_count=excluded.user_message_count, assistant_message_count=excluded.assistant_message_count,
          created_at=excluded.created_at, updated_at=excluded.updated_at, created_at_source=excluded.created_at_source,
          client_surface=excluded.client_surface, session_kind=excluded.session_kind`,
        [
          sessionId,
          sourceSessionHash,
          context.sourceFileId,
          context.machineId,
          modelPrimary,
          bubbles.length,
          bubbles.filter((b) => b.role === "user").length,
          bubbles.filter((b) => b.role === "assistant").length,
          createdAt,
          updatedAt,
          createdAtSource
        ]
      );
      committedCounters.sessions_upserted += 1;

      for (let i = 0; i < bubbles.length; i += 1) {
        const bubble = bubbles[i];
        const createdAtIso = bubble.timestamp ?? updatedAt;
        await context.db.run(
          `INSERT INTO message_metrics(
             id, session_id, source_file_id, seq, role, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
             reasoning_tokens, token_available, partial_token_data, created_at, timestamp_source, metadata_json
           ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0, 0, ?, ?, NULL)`,
          [
            hmacHex(`${context.machineId}:${sessionId}:${i + 1}`, context.hmacSalt),
            sessionId,
            context.sourceFileId,
            i + 1,
            bubble.role,
            bubble.model,
            createdAtIso,
            bubble.timestamp !== null ? "native" : "unknown"
          ]
        );
        committedCounters.messages_upserted += 1;

        if (bubble.mcpCall && isAllowedMcpServer(bubble.mcpCall.server)) {
          eventSeq += 1;
          const eventName = `${bubble.mcpCall.server}.${bubble.mcpCall.tool}`;
          const occurredAt = createdAtIso;
          const eventId = hmacHex(`${context.machineId}:canon:mcp:${eventName}:${occurredAt}:${sessionId}:noargs`, context.hmacSalt);
          await context.db.run(
            `INSERT OR IGNORE INTO runtime_events(
              id, source, source_file_id, session_id, event_type, event_origin, event_name, mcp_server_name, tool_name,
              skill_name, hook_name, hook_event, source_line_no, event_seq, args_keys_json, args_hash, args_bytes,
              occurred_at, timestamp_source, is_self_event, metadata_json
            ) VALUES (?, 'cursor', ?, ?, 'mcp', 'observed_structured', ?, ?, ?, NULL, NULL, NULL, NULL, ?, '[]', NULL, 0, ?, 'native', ?, NULL)`,
            [
              eventId,
              context.sourceFileId,
              sessionId,
              eventName,
              bubble.mcpCall.server,
              bubble.mcpCall.tool,
              eventSeq,
              occurredAt,
              bubble.mcpCall.server === "analytics-mcp-server" ? 1 : 0
            ]
          );
          const observationId = hmacHex(`${eventId}:cursor:${context.sourceFileId}:${eventSeq}:chat_structured`, context.hmacSalt);
          await context.db.run(
            `INSERT OR IGNORE INTO runtime_event_observations(
              id, runtime_event_id, observed_source, source_file_id, session_id, source_line_no, event_seq, observation_kind, observed_at
            ) VALUES (?, ?, 'cursor', ?, ?, NULL, ?, 'chat_structured', ?)`,
            [observationId, eventId, context.sourceFileId, sessionId, eventSeq, occurredAt]
          );
          committedCounters.runtime_events_upserted += 1;
        }
      }
    }
    await context.db.exec("COMMIT");
  } catch (error) {
    try {
      await context.db.exec("ROLLBACK");
    } catch {
      // Preserve the original import error: a rollback failure must not make
      // the scanner record a successful import either.
    }
    throw error;
  }

  return { counters: committedCounters, warnings };
}
