import { readFile } from "node:fs/promises";
import { basename, dirname } from "node:path";
import type { DatabaseAdapter } from "../db.js";
import { hmacHex } from "../privacy.js";
import type { ClientSurface } from "../sources.js";
import { isAllowedMcpServer } from "../repo-catalog.js";
import { FileImportError } from "./file-import-contract.js";

/**
 * Antigravity (Google's Antigravity IDE/CLI, built on the Gemini stack) writes one JSONL
 * transcript file per conversation at:
 *
 *   <root>/brain/<conversation-id>/.system_generated/logs/transcript.jsonl
 *
 * (or `transcript_full.jsonl` as a fallback -- scanner.ts is responsible for picking exactly one
 * of the two per conversation directory before this adapter ever sees a candidate file, so this
 * adapter only ever imports a single file per conversation-id).
 *
 * IMPORTANT: this shape (the `brain/<id>/.system_generated/logs/transcript*.jsonl` path
 * convention and the per-line fields below) is reverse-engineered from public
 * documentation/community sources, not an official Antigravity/Gemini API or schema. It may shift
 * across Antigravity releases. Every read in this file must degrade gracefully (skip the line/
 * file, emit a warning) when a field is missing or has an unexpected type -- never throw on an
 * unexpected shape.
 *
 * Token usage is not exposed by this local storage, so `token_available` always reports 0/false
 * for Antigravity sessions and messages, the same convention other unofficial-schema adapters
 * (Cursor) use when their source has no usable token counters.
 *
 * Privacy: the `content` field of each transcript line is prompt/response text and must NEVER be
 * read, stored, or hashed into anything persisted -- only structural fields (step_index, type,
 * source, created_at, tool_calls[].name) are ever touched.
 */

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(typeField: string, sourceField: string): "user" | "assistant" | "unknown" {
  const combined = `${typeField} ${sourceField}`.toUpperCase();
  if (combined.includes("USER")) return "user";
  if (combined.includes("AGENT") || combined.includes("MODEL") || combined.includes("ASSISTANT")) return "assistant";
  return "unknown";
}

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] };
}

interface McpCallEntry {
  server: string;
  tool: string;
}

interface TranscriptEntry {
  stepIndex: number | null;
  originalIndex: number;
  role: "user" | "assistant" | "unknown";
  createdAt: string | null;
  mcpCalls: McpCallEntry[];
}

function extractMcpCalls(parsed: Record<string, unknown>): McpCallEntry[] {
  const toolCalls = Array.isArray(parsed.tool_calls) ? parsed.tool_calls : [];
  const out: McpCallEntry[] = [];
  for (const entry of toolCalls) {
    if (!entry || typeof entry !== "object") continue;
    const name = (entry as Record<string, unknown>).name;
    if (typeof name !== "string" || name.length === 0) continue;
    const parsedName = parseMcpToolName(name);
    if (parsedName && isAllowedMcpServer(parsedName.server)) {
      out.push(parsedName);
    }
  }
  return out;
}

function parseTranscriptLine(raw: string, originalIndex: number): TranscriptEntry | null {
  let parsed: Record<string, unknown>;
  try {
    parsed = JSON.parse(raw) as Record<string, unknown>;
  } catch {
    return null;
  }
  const typeField = typeof parsed.type === "string" ? parsed.type : "";
  const sourceField = typeof parsed.source === "string" ? parsed.source : "";
  const createdAt = typeof parsed.created_at === "string" && Number.isFinite(Date.parse(parsed.created_at)) ? parsed.created_at : null;
  const stepIndexRaw = parsed.step_index;
  const stepIndex = typeof stepIndexRaw === "number" && Number.isFinite(stepIndexRaw) ? stepIndexRaw : null;

  // A line that parses as JSON but exposes none of the fields we recognize (no type, no source,
  // no created_at) is not a recognizable transcript entry -- treat it like an unusable line for
  // the purposes of the file-level UNSUPPORTED_FORMAT check, same as a JSON parse failure.
  if (!typeField && !sourceField && !createdAt && stepIndex === null) {
    return null;
  }

  return {
    stepIndex,
    originalIndex,
    role: cleanRole(typeField, sourceField),
    createdAt,
    mcpCalls: extractMcpCalls(parsed)
  };
}

function deriveConversationId(absolutePath: string): string {
  // <root>/brain/<conversation-id>/.system_generated/logs/transcript.jsonl
  // dirname(file)              -> .../logs
  // dirname(dirname(file))     -> .../.system_generated
  // dirname(dirname(dirname))  -> .../<conversation-id>
  return basename(dirname(dirname(dirname(absolutePath))));
}

export async function importAntigravityFile(
  context: { db: DatabaseAdapter; sourceFileId: string; machineId: string; hmacSalt: string; clientSurface: Extract<ClientSurface, "cli" | "code"> },
  absolutePath: string
): Promise<{ counters: ImportCounters; warnings: WarningEntry[] }> {
  const counters: ImportCounters = { sessions_upserted: 0, messages_upserted: 0, runtime_events_upserted: 0 };
  const warnings: WarningEntry[] = [];

  let raw: string;
  try {
    raw = await readFile(absolutePath, "utf8");
  } catch {
    throw new FileImportError("SOURCE_LOCKED_OR_UNREADABLE");
  }

  const lines = raw.split(/\r?\n/u).map((v) => v.trim()).filter(Boolean);
  const entries: TranscriptEntry[] = [];
  let malformedLines = 0;

  for (let i = 0; i < lines.length; i += 1) {
    const entry = parseTranscriptLine(lines[i], i);
    if (!entry) {
      malformedLines += 1;
      continue;
    }
    entries.push(entry);
  }

  if (malformedLines > 0) {
    warnings.push(warn("INVALID_JSON_LINE_SKIPPED", context.sourceFileId, { count: malformedLines }));
  }

  if (entries.length === 0) {
    // Nothing in the whole file parsed as a recognizable transcript line -- never create an
    // empty session shell for this, just flag the file as unsupported/unexpected format.
    throw new FileImportError("SOURCE_SCHEMA_UNSUPPORTED");
  }

  entries.sort((a, b) => {
    const aKey = a.stepIndex ?? a.originalIndex;
    const bKey = b.stepIndex ?? b.originalIndex;
    if (aKey !== bKey) return aKey - bKey;
    return a.originalIndex - b.originalIndex;
  });

  const conversationId = deriveConversationId(absolutePath);
  const clientSurface = context.clientSurface;

  const sessionId = hmacHex(`${context.machineId}:antigravity:${conversationId}`, context.hmacSalt);
  const sourceSessionHash = hmacHex(conversationId, context.hmacSalt);

  const timestamps = entries.map((e) => e.createdAt).filter((v): v is string => v !== null);
  const createdAt = timestamps.length > 0 ? timestamps.reduce((min, v) => (v < min ? v : min)) : null;
  const updatedAt = timestamps.length > 0 ? timestamps.reduce((max, v) => (v > max ? v : max)) : new Date().toISOString();
  const createdAtSource: "native" | "unknown" = createdAt ? "native" : "unknown";

  // Only publish counters after COMMIT.  The scanner must never aggregate
  // work which was subsequently rolled back.
  const committedCounters: ImportCounters = { sessions_upserted: 0, messages_upserted: 0, runtime_events_upserted: 0 };
  await context.db.exec("BEGIN IMMEDIATE TRANSACTION");
  try {
    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 (?, 'antigravity', ?, ?, ?, NULL, 'chat', NULL, 0, ?, ?, ?, ?, ?, ?, ?, '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,
        entries.length,
        entries.filter((e) => e.role === "user").length,
        entries.filter((e) => e.role === "assistant").length,
        createdAt,
        updatedAt,
        createdAtSource,
        clientSurface
      ]
    );
    committedCounters.sessions_upserted += 1;

    let eventSeq = 0;
    for (let i = 0; i < entries.length; i += 1) {
      const entry = entries[i];
      const createdAtIso = entry.createdAt ?? 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 (?, ?, ?, ?, ?, NULL, 0, 0, 0, 0, 0, 0, 0, ?, ?, NULL)`,
        [
          hmacHex(`${context.machineId}:${sessionId}:${i + 1}`, context.hmacSalt),
          sessionId,
          context.sourceFileId,
          i + 1,
          entry.role,
          createdAtIso,
          entry.createdAt !== null ? "native" : "unknown"
        ]
      );
      committedCounters.messages_upserted += 1;

      for (const mcpCall of entry.mcpCalls) {
        eventSeq += 1;
        const eventName = `${mcpCall.server}.${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 (?, 'antigravity', ?, ?, 'mcp', 'observed_structured', ?, ?, ?, NULL, NULL, NULL, NULL, ?, '[]', NULL, 0, ?, 'native', ?, NULL)`,
          [
            eventId,
            context.sourceFileId,
            sessionId,
            eventName,
            mcpCall.server,
            mcpCall.tool,
            eventSeq,
            occurredAt,
            mcpCall.server === "analytics-mcp-server" ? 1 : 0
          ]
        );
        const observationId = hmacHex(`${eventId}:antigravity:${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 (?, ?, 'antigravity', ?, ?, 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 {
      // The original SQL/import error is still the error the scanner must
      // report, even if rollback itself also fails.
    }
    throw error;
  }

  return { counters: committedCounters, warnings };
}
