'use strict';

const fs = require('fs');
const path = require('path');
const {
  findCodexThreadForSession,
  getCodexThreadById,
} = require('./session-history');
const { detectAgentType } = require('./agent-capabilities');

// Aliases (`opus`/`sonnet`/`haiku`) to latest concrete model id. Used as a
// fallback only. When we have the session's JSONL we read the real model
// from the most recent assistant turn (see getClaudeCodeModelFromJsonl below)
// because Anthropic shifts these aliases over time and a hardcoded map
// goes stale (e.g. `opus` was 4-6, now 4-7). Update this map when bumping
// the Claude SDK in CTM, and treat it as a best-effort default.
const CC_ALIAS_MAP = {
  opus: 'claude-opus-4-7',
  sonnet: 'claude-sonnet-4-6',
  haiku: 'claude-haiku-4-5-20251001',
};

const AGENT_MODEL_CACHE_TTL = 5000;

function _normalizeClaudeModelName(value) {
  if (typeof value !== 'string') return null;
  const alias = value.replace(/\[.*$/, '').trim().toLowerCase();
  return CC_ALIAS_MAP[alias] || (alias.startsWith('claude-') ? alias : null);
}

// Read the actual model used in the most recent assistant turn of a JSONL
// transcript. This is the source of truth: it reflects what Claude Code
// actually invoked, immune to alias drift.
// Walk a JSONL chunk backwards for the last assistant turn carrying a claude- model id.
function _scanJsonlForClaudeModelInfo(raw) {
  const lines = raw.split('\n');
  for (let i = lines.length - 1; i >= 0; i--) {
    const line = lines[i];
    if (!line || line[0] !== '{') continue;
    // Cheap pre-filter so we don't JSON.parse every user/system line.
    if (line.indexOf('"model"') === -1) continue;
    try {
      const rec = JSON.parse(line);
      if (!rec || rec.type !== 'assistant') continue;
      const m = rec && rec.message && rec.message.model;
      const model = _normalizeClaudeModelName(m);
      if (model) {
        const timestampMs = typeof rec.timestamp === 'string' ? Date.parse(rec.timestamp) : NaN;
        return {
          model,
          timestampMs: Number.isFinite(timestampMs) ? timestampMs : 0,
        };
      }
    } catch { /* skip malformed line */ }
  }
  return null;
}

function _scanJsonlForClaudeModel(raw) {
  const info = _scanJsonlForClaudeModelInfo(raw);
  return info ? info.model : null;
}

// Read just the TAIL of the transcript by default: the model lives in the most
// recent assistant turn, so reading the whole file (an active session's JSONL is
// 50–100MB) on the main loop just to find the last line was a model-sync boot
// freeze — getSessionAgentModel runs this for every session on a cold cache.
const _CLAUDE_JSONL_MODEL_TAIL_BYTES = Math.max(
  64 * 1024,
  Number(process.env.CTM_CLAUDE_MODEL_JSONL_TAIL_BYTES) || 512 * 1024
);

function getClaudeCodeModelInfoFromJsonl(jsonlPath) {
  if (!jsonlPath) return null;
  try {
    let size;
    try { size = fs.statSync(jsonlPath).size; } catch { return null; } // missing/unreadable
    if (size <= _CLAUDE_JSONL_MODEL_TAIL_BYTES) {
      return _scanJsonlForClaudeModelInfo(fs.readFileSync(jsonlPath, 'utf8'));
    }
    // Large file: read only the trailing window, dropping the leading partial line
    // (the tail boundary almost certainly splits one).
    const fd = fs.openSync(jsonlPath, 'r');
    let tail;
    try {
      const buf = Buffer.allocUnsafe(_CLAUDE_JSONL_MODEL_TAIL_BYTES);
      const read = fs.readSync(fd, buf, 0, _CLAUDE_JSONL_MODEL_TAIL_BYTES, size - _CLAUDE_JSONL_MODEL_TAIL_BYTES);
      tail = buf.toString('utf8', 0, read);
    } finally { fs.closeSync(fd); }
    const nl = tail.indexOf('\n');
    const found = _scanJsonlForClaudeModelInfo(nl >= 0 ? tail.slice(nl + 1) : tail);
    if (found) return found;
    // Rare: the last assistant-with-model turn is older than the tail window.
    // Fall back to a full read so we are never LESS correct than before — only the
    // (uncommon) miss pays the full cost the old code always paid.
    return _scanJsonlForClaudeModelInfo(fs.readFileSync(jsonlPath, 'utf8'));
  } catch { /* file unreadable */ }
  return null;
}

function getClaudeCodeModelFromJsonl(jsonlPath) {
  const info = getClaudeCodeModelInfoFromJsonl(jsonlPath);
  return info ? info.model : null;
}

function getClaudeCodeModelSelection(homeDir = process.env.HOME) {
  const settingsPath = path.join(homeDir, '.claude', 'settings.json');
  try {
    const raw = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
    const model = _normalizeClaudeModelName(raw.model);
    if (!model) return null;
    let mtimeMs = 0;
    try { mtimeMs = fs.statSync(settingsPath).mtimeMs || 0; } catch {}
    return { model, mtimeMs, rawModel: raw.model, settingsPath };
  } catch {
    return null;
  }
}

function getClaudeCodeModel(homeDir = process.env.HOME) {
  const selection = getClaudeCodeModelSelection(homeDir);
  return selection ? selection.model : null;
}

function shouldPreferClaudeSelectedModel(selection, jsonlInfo) {
  if (!selection || !selection.model) return false;
  if (!jsonlInfo || !jsonlInfo.model) return true;
  if (!selection.mtimeMs || !jsonlInfo.timestampMs) return false;
  return selection.mtimeMs > (jsonlInfo.timestampMs + 1000);
}

function _coerceTimeMs(value) {
  if (!value) return 0;
  if (typeof value === 'number' && Number.isFinite(value)) return value > 1e12 ? value : value * 1000;
  const parsed = Date.parse(String(value));
  return Number.isFinite(parsed) ? parsed : 0;
}

function getClaudeSessionModelName({ jsonlPath, homeDir = process.env.HOME, sessionActivityMs = 0 } = {}) {
  const jsonlInfo = getClaudeCodeModelInfoFromJsonl(jsonlPath);
  const selection = getClaudeCodeModelSelection(homeDir);
  if (shouldPreferClaudeSelectedModel(selection, jsonlInfo)) {
    const activityMs = _coerceTimeMs(sessionActivityMs);
    if (!activityMs || activityMs >= (selection.mtimeMs - 10 * 60 * 1000)) return selection.model;
  }
  return (jsonlInfo && jsonlInfo.model) || (selection && selection.model) || null;
}

function openCodexStateDb(homeDir = process.env.HOME) {
  const Database = require('better-sqlite3');
  const dbPath = path.join(homeDir, '.codex', 'state_5.sqlite');
  if (!fs.existsSync(dbPath)) return null;
  return new Database(dbPath, { readonly: true });
}

function getCodexThreadModel(threadId, homeDir = process.env.HOME) {
  const row = getCodexThreadById(threadId, homeDir);
  return row ? row.model : null;
}

function getCodexLatestModel(homeDir = process.env.HOME) {
  let db = null;
  try {
    db = openCodexStateDb(homeDir);
    if (!db) return null;
    const row = db.prepare('SELECT model FROM threads WHERE model IS NOT NULL ORDER BY created_at DESC LIMIT 1').get();
    return row ? row.model : null;
  } catch {
    return null;
  } finally {
    try { if (db) db.close(); } catch {}
  }
}

function getCodexThreadTitle(sessionCwd, sessionCreatedAt, homeDir = process.env.HOME) {
  const row = findCodexThreadForSession({
    cwd: sessionCwd,
    createdAtMs: sessionCreatedAt,
    homeDir,
    maxDelaySec: 180,
  });
  if (row?._source === 'codex_state') return null;
  const title = row?.title || row?.first_user_message || '';
  return title ? title.slice(0, 80) : null;
}

function getCodexThreadTitleById(threadId, homeDir = process.env.HOME) {
  const row = getCodexThreadById(threadId, homeDir);
  if (row?._source === 'codex_state') return null;
  const title = row?.title || row?.first_user_message || '';
  return title ? title.slice(0, 80) : null;
}

function normalizeTitleText(value) {
  return String(value || '').replace(/\s+/g, ' ').trim();
}

function isGenericCodingSessionLabel(label, agentType = 'codex') {
  const text = normalizeTitleText(label);
  if (!text) return true;
  const lower = text.toLowerCase();
  if (agentType === 'codex') {
    return lower === 'codex'
      || lower === 'codex session'
      || lower === 'new codex session'
      || lower === 'coding...'
      || /^codex:\s*(~|\/|\.\.?\/|$)/i.test(text);
  }
  return lower === 'coding...'
    || lower === 'new session'
    || lower === 'session';
}

function shouldApplyCodexAutoTitle({ session, existingTitle } = {}) {
  if (existingTitle && existingTitle.userRenamed) return false;
  for (const candidate of [session?.label, existingTitle?.title]) {
    const text = normalizeTitleText(candidate);
    if (text && !/^Codex:\s*/i.test(text) && !isGenericCodingSessionLabel(text, 'codex')) return false;
  }
  const title = normalizeTitleText(existingTitle?.title || session?.label || '');
  if (!title) return true;
  if (/^Codex:\s*/i.test(title) && !isGenericCodingSessionLabel(title, 'codex')) return true;
  return isGenericCodingSessionLabel(title, 'codex');
}

function resolveModelToRegistry(modelName) {
  if (!modelName) return null;
  let provider = null;
  if (/^claude-/i.test(modelName)) provider = 'anthropic';
  else if (/^(gpt|o[1-9])/i.test(modelName)) provider = 'openai';
  else if (/^(gemini|text-embedding)/i.test(modelName)) provider = 'google';
  else if (/^deepseek/i.test(modelName)) provider = 'deepseek';
  else if (/^(kimi|moonshot)/i.test(modelName)) provider = 'moonshot';
  return { model_id: modelName, model_provider: provider };
}

function createCodingAgentModelSync({ sessions, dbModule, broadcastSessionList, resolveClaudeJsonlPath, resolveAgentModelsOffThread }) {
  const agentModelCache = {};
  let agentModelCacheTime = 0;

  function getSessionAgentModel(session) {
    const agentType = detectAgentType(session.cmd);
    if (!agentType) return null;

    const now = Date.now();

    // For claude, resolve the transcript path up front. session.jsonlPath is
    // only populated by the JSONL file-watcher on a write event, so idle and
    // freshly-restored sessions lack it even though the transcript on disk
    // already records the model — leaving the picker stuck on "(detecting…)".
    // resolveClaudeJsonlPath derives the deterministic path
    // (~/.claude/projects/<encoded-cwd>/<agentSessionId>.jsonl) so detection
    // works without waiting for the next write.
    const claudeJsonlPath = agentType === 'claude'
      ? (session.jsonlPath || (resolveClaudeJsonlPath ? resolveClaudeJsonlPath(session) : '') || '')
      : '';

    // Per-session cache key for claude too because JSONL fallback is
    // per-session. Codex also includes the matched thread id because it can
    // be discovered after spawn.
    const cacheKey = agentType === 'claude'
      ? `claude:${session.id}:${claudeJsonlPath}`
      : agentType === 'codex'
        ? `codex:${session.id}:${session._claudeSessionId || ''}`
        : agentType;

    if (agentModelCache[cacheKey] && (now - agentModelCacheTime) < AGENT_MODEL_CACHE_TTL) {
      return agentModelCache[cacheKey];
    }

    let modelName = null;
    switch (agentType) {
      case 'claude':
        // The JSONL transcript is the historical source of truth because it
        // records what the API actually resolved on the latest assistant turn.
        // Claude Code also rewrites settings.json immediately when the user
        // changes the selected model, before the next assistant turn exists.
        // Prefer that newer live selection only when its mtime is newer than
        // the latest assistant turn timestamp; otherwise keep JSONL precedence.
        modelName = getClaudeSessionModelName({
          jsonlPath: claudeJsonlPath,
          sessionActivityMs: Math.max(
            _coerceTimeMs(session.lastActivity),
            _coerceTimeMs(session.lastPtyActivity),
            _coerceTimeMs(session.updatedAt),
            _coerceTimeMs(session.createdAt)
          ),
        });
        break;
      case 'codex':
        modelName = session._claudeSessionId
          ? getCodexThreadModel(session._claudeSessionId)
          : getCodexLatestModel();
        break;
      case 'gemini':
        if (session.args) {
          const longIdx = session.args.indexOf('--model');
          const shortIdx = session.args.indexOf('-m');
          const idx = longIdx >= 0 ? longIdx : shortIdx;
          if (idx >= 0 && session.args[idx + 1]) modelName = session.args[idx + 1];
        }
        break;
    }

    const resolved = resolveModelToRegistry(modelName);
    if (resolved) {
      agentModelCache[cacheKey] = resolved;
      agentModelCacheTime = now;
    }
    return resolved;
  }

  function syncCodingSessionModels() {
    let changed = false;
    for (const [, session] of sessions) {
      const agentType = detectAgentType(session.cmd);
      if (!agentType) continue;
      const model = getSessionAgentModel(session);
      if (model && session.model_id !== model.model_id) {
        session.model_id = model.model_id;
        session.model_provider = model.model_provider;
        changed = true;
      }
      if (agentType === 'codex' && !session._codexTitleSynced) {
        const existingTitle = dbModule.getSessionTitleNew(session.id);
        if (existingTitle && existingTitle.userRenamed) {
          session._codexTitleSynced = true;
        } else if (!shouldApplyCodexAutoTitle({ session, existingTitle })) {
          const liveLabel = normalizeTitleText(session.label);
          if (liveLabel
            && !isGenericCodingSessionLabel(liveLabel, 'codex')
            && (!existingTitle || normalizeTitleText(existingTitle.title) !== liveLabel)) {
            dbModule.setSessionTitleNew(session.id, liveLabel, false);
            changed = true;
          }
          session._codexTitleSynced = true;
        } else {
          // Title sync must require the exact Codex thread id. Matching by cwd
          // and spawn time is useful for one-shot link recovery, but unsafe for
          // periodic title refresh: multiple active Codex sessions often share
          // the same repo and Codex can persist names after CTM has already
          // rendered the tab.
          const codexTitle = session._claudeSessionId
            ? getCodexThreadTitleById(session._claudeSessionId)
            : null;
          if (codexTitle) {
            session.label = `Codex: ${codexTitle}`;
            session._codexTitleSynced = true;
            dbModule.setSessionTitleNew(session.id, session.label, false);
            changed = true;
          }
        }
      }
    }
    if (changed && typeof broadcastSessionList === 'function') broadcastSessionList();
  }

  // Off-main-loop variant of syncCodingSessionModels. The fs/DB-heavy resolution (claude JSONL
  // scan + Codex state-DB reads + getCodexThreadTitleById) runs on the read-pool worker via
  // resolveAgentModelsOffThread; only the small DECIDE/APPLY work (model→registry mapping, the
  // CTM-DB title decision, mutating the in-memory session) stays on main. On pool
  // unavailable/timeout (returns null) we SKIP the tick rather than dragging the work back onto
  // the loop — model/title detection is best-effort UI sugar and converges next tick.
  async function syncCodingSessionModelsOffThread() {
    if (typeof resolveAgentModelsOffThread !== 'function') return syncCodingSessionModels();
    let changed = false;
    const items = [];
    const ctx = []; // parallel to items: { session, agentType, wantCodexTitle }
    for (const [, session] of sessions) {
      const agentType = detectAgentType(session.cmd);
      if (!agentType) continue;
      if (agentType === 'gemini') {
        // Pure arg-parse + registry map — no fs/DB; resolve inline on main.
        const model = getSessionAgentModel(session);
        if (model && session.model_id !== model.model_id) {
          session.model_id = model.model_id;
          session.model_provider = model.model_provider;
          changed = true;
        }
        continue;
      }
      const claudeJsonlPath = agentType === 'claude'
        ? (session.jsonlPath || (resolveClaudeJsonlPath ? resolveClaudeJsonlPath(session) : '') || '')
        : '';
      // Codex title DECISION runs on main (cheap CTM-DB read). Only the actual Codex-DB title
      // FETCH is offloaded (wantCodexTitle). Mirrors the sync version's branches exactly.
      let wantCodexTitle = false;
      if (agentType === 'codex' && !session._codexTitleSynced) {
        const existingTitle = dbModule.getSessionTitleNew(session.id);
        if (existingTitle && existingTitle.userRenamed) {
          session._codexTitleSynced = true;
        } else if (!shouldApplyCodexAutoTitle({ session, existingTitle })) {
          const liveLabel = normalizeTitleText(session.label);
          if (liveLabel
            && !isGenericCodingSessionLabel(liveLabel, 'codex')
            && (!existingTitle || normalizeTitleText(existingTitle.title) !== liveLabel)) {
            dbModule.setSessionTitleNew(session.id, liveLabel, false);
            changed = true;
          }
          session._codexTitleSynced = true;
        } else {
          wantCodexTitle = !!session._claudeSessionId;
        }
      }
      items.push({
        id: session.id,
        agentType,
        claudeJsonlPath,
        sessionActivityMs: Math.max(
          _coerceTimeMs(session.lastActivity),
          _coerceTimeMs(session.lastPtyActivity),
          _coerceTimeMs(session.updatedAt),
          _coerceTimeMs(session.createdAt)
        ),
        codexThreadId: session._claudeSessionId || null,
        wantCodexTitle,
      });
      ctx.push({ session, agentType, wantCodexTitle });
    }
    if (items.length === 0) {
      if (changed && typeof broadcastSessionList === 'function') broadcastSessionList();
      return;
    }
    const results = await resolveAgentModelsOffThread(items);
    if (!results) {
      // Pool unavailable/timeout → skip this tick (do not run the heavy resolve on main).
      if (changed && typeof broadcastSessionList === 'function') broadcastSessionList();
      return;
    }
    const byId = new Map(results.map((r) => [r.id, r]));
    for (const { session, agentType, wantCodexTitle } of ctx) {
      const r = byId.get(session.id);
      if (!r) continue;
      const model = resolveModelToRegistry(r.modelName);
      if (model && session.model_id !== model.model_id) {
        session.model_id = model.model_id;
        session.model_provider = model.model_provider;
        changed = true;
      }
      if (agentType === 'codex' && wantCodexTitle && r.codexTitle) {
        session.label = `Codex: ${r.codexTitle}`;
        session._codexTitleSynced = true;
        dbModule.setSessionTitleNew(session.id, session.label, false);
        changed = true;
      }
    }
    if (changed && typeof broadcastSessionList === 'function') broadcastSessionList();
  }

  return {
    detectAgentType,
    getSessionAgentModel,
    syncCodingSessionModels,
    syncCodingSessionModelsOffThread,
  };
}

module.exports = {
  createCodingAgentModelSync,
  detectAgentType,
  getClaudeCodeModel,
  getClaudeCodeModelFromJsonl,
  getClaudeCodeModelInfoFromJsonl,
  getClaudeCodeModelSelection,
  getClaudeSessionModelName,
  getCodexThreadModel,
  getCodexThreadTitleById,
  getCodexLatestModel,
  getCodexThreadTitle,
  isGenericCodingSessionLabel,
  resolveModelToRegistry,
  shouldApplyCodexAutoTitle,
  CC_ALIAS_MAP,
};
