// --- Shared session file utilities ---
const fs = require('fs');
const path = require('path');
const os = require('os');
const walleTranscript = require('./lib/walle-transcript');
const claudeDesktopSessions = require('./lib/claude-desktop-sessions');
const {
  codexRolloutIdFromPath,
  readCodexRolloutMetadata,
} = require('./lib/session-history');

function decodeProjectEntry(entry) {
  if (!entry.startsWith('-')) return entry;
  const parts = entry.slice(1).split('-');
  function hiddenFallback() {
    const merged = [];
    for (let i = 0; i < parts.length; i++) {
      if (parts[i] === '' && i + 1 < parts.length) merged.push('.' + parts[++i]);
      else merged.push(parts[i]);
    }
    while (merged.length > 0 && merged[merged.length - 1] === '') merged.pop();
    return '/' + merged.join('/');
  }
  if (parts.length > 12) return hiddenFallback();
  const memo = new Map();
  function solve(idx, current) {
    const key = `${idx}:${current}`;
    if (memo.has(key)) return memo.get(key);
    let result;
    if (idx >= parts.length) { result = fs.existsSync(current) ? current : null; }
    else {
      // Claude Code encodes /.hidden/path as --hidden-path. Treat the empty
      // token as a possible hidden segment, but keep the original hyphen/slash
      // branches too so real double-hyphen names still decode by existence.
      if (parts[idx] === '' && idx + 1 < parts.length) {
        result = solve(idx + 2, current + '/.' + parts[idx + 1]);
      }
      if (!result) result = solve(idx + 1, current + '/' + parts[idx]);
      if (!result) result = solve(idx + 1, current + '-' + parts[idx]);
    }
    memo.set(key, result);
    return result;
  }
  return solve(1, '/' + parts[0]) || hiddenFallback();
}

function inferModelProvider(modelId) {
  if (!modelId || modelId.startsWith('<')) return '';
  if (modelId.startsWith('claude-')) return 'anthropic';
  if (modelId.startsWith('codex-')) return 'openai';
  if (modelId.startsWith('gemini-')) return 'google';
  if (modelId.startsWith('gpt-') || modelId.startsWith('o1-') || modelId.startsWith('o3-') || modelId.startsWith('o4-')) return 'openai';
  if (modelId.startsWith('deepseek-')) return 'deepseek';
  if (modelId.startsWith('kimi-') || modelId.startsWith('moonshot-')) return 'moonshot';
  return 'unknown';
}

function messageText(messageOrContent) {
  if (!messageOrContent) return '';
  const content = messageOrContent.content !== undefined ? messageOrContent.content : messageOrContent;
  return walleTranscript.extractText(content);
}

function shouldSkipLegacyWalleSessionFile(file, fileSet) {
  const sessionId = String(file || '').replace(/\.jsonl(\.bak)?$/, '');
  if (!sessionId || sessionId.startsWith('walle-')) return false;
  return fileSet.has(`walle-${sessionId}.jsonl`) || fileSet.has(`walle-${sessionId}.jsonl.bak`);
}

function pushWalleSessionFiles(results) {
  const sessionsDir = walleTranscript.defaultSessionsDir(process.env);
  let files;
  try { files = fs.readdirSync(sessionsDir); } catch { return; }
  const fileSet = new Set(files);
  for (const file of files) {
    if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.bak')) continue;
    if (file.endsWith('.jsonl.bak') && fileSet.has(file.replace(/\.bak$/, ''))) continue;
    if (shouldSkipLegacyWalleSessionFile(file, fileSet)) continue;
    const filePath = path.join(sessionsDir, file);
    let stat;
    try { stat = fs.statSync(filePath); } catch { continue; }
    if (!stat.isFile()) continue;
    results.push({
      filePath,
      projectPath: sessionsDir,
      projectEntry: walleTranscript.WALLE_PROJECT_ENTRY,
      sessionId: file.replace(/\.jsonl(\.bak)?$/, ''),
    });
  }
}

function codexSessionRoots() {
  return [
    path.join(process.env.HOME, '.codex', 'sessions'),
    path.join(process.env.HOME, '.codex', 'archived_sessions'),
  ];
}

function pushCodexSessionFiles(results) {
  const stack = codexSessionRoots();
  while (stack.length > 0) {
    const dir = stack.pop();
    let entries;
    try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
    for (const entry of entries) {
      const filePath = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        stack.push(filePath);
        continue;
      }
      if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
      const sessionId = codexRolloutIdFromPath(filePath);
      if (!sessionId) continue;
      const meta = readCodexRolloutMetadata(filePath) || {};
      results.push({
        filePath,
        projectPath: meta.cwd || path.dirname(filePath),
        projectEntry: 'codex',
        sessionId: meta.id || sessionId,
      });
    }
  }
}

async function pushWalleSessionFilesAsync(results) {
  const sessionsDir = walleTranscript.defaultSessionsDir(process.env);
  let files;
  try { files = await fsp.readdir(sessionsDir); } catch { return; }
  const fileSet = new Set(files);
  for (const file of files) {
    if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.bak')) continue;
    if (file.endsWith('.jsonl.bak') && fileSet.has(file.replace(/\.bak$/, ''))) continue;
    if (shouldSkipLegacyWalleSessionFile(file, fileSet)) continue;
    const filePath = path.join(sessionsDir, file);
    let stat;
    try { stat = await fsp.stat(filePath); } catch { continue; }
    if (!stat.isFile()) continue;
    results.push({
      filePath,
      projectPath: sessionsDir,
      projectEntry: walleTranscript.WALLE_PROJECT_ENTRY,
      sessionId: file.replace(/\.jsonl(\.bak)?$/, ''),
    });
  }
}

async function pushCodexSessionFilesAsync(results) {
  const stack = codexSessionRoots();
  while (stack.length > 0) {
    const dir = stack.pop();
    let entries;
    try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { continue; }
    for (const entry of entries) {
      const filePath = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        stack.push(filePath);
        continue;
      }
      if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
      const sessionId = codexRolloutIdFromPath(filePath);
      if (!sessionId) continue;
      const meta = readCodexRolloutMetadata(filePath) || {};
      results.push({
        filePath,
        projectPath: meta.cwd || path.dirname(filePath),
        projectEntry: 'codex',
        sessionId: meta.id || sessionId,
      });
    }
  }
}

function parseSessionFile(filePath, projectPath, projectEntry) {
  if (projectEntry === claudeDesktopSessions.DESKTOP_PROJECT_ENTRY ||
      claudeDesktopSessions.isVirtualSessionPath(filePath)) {
    return claudeDesktopSessions.parseSessionFile(filePath, projectPath, projectEntry);
  }

  const fileStat = fs.statSync(filePath);
  const modifiedAt = fileStat.mtime.toISOString();
  const codexSessionId = codexRolloutIdFromPath(filePath);
  if (codexSessionId) {
    const meta = readCodexRolloutMetadata(filePath) || {};
    const cwd = meta.cwd || projectPath || path.dirname(filePath);
    const modelId = meta.model || '';
    const firstMessage = meta.first_user_message || meta.title || '';
    const title = firstMessage
      ? firstMessage.split('\n')[0].replace(/^#+\s*/, '').replace(/[*_`]/g, '').trim().slice(0, 80)
      : '';
    const createdAt = meta.created_at
      ? new Date(Number(meta.created_at) * 1000).toISOString()
      : modifiedAt;
    return {
      sessionId: meta.id || codexSessionId,
      project: cwd,
      projectEntry: 'codex',
      cwd,
      firstMessage,
      lastUserContent: firstMessage,
      firstAssistantText: '',
      renameName: '',
      title,
      isEmpty: false,
      userMsgCount: firstMessage ? 1 : 0,
      modifiedAt,
      timestamp: createdAt,
      version: '',
      gitBranch: meta.git_branch || '',
      slug: '',
      fileSize: fileStat.size,
      hostname: os.hostname(),
      modelId,
      modelProvider: meta.model_provider || 'openai',
      model: modelId,
      agent: 'codex',
      jsonlPath: filePath,
      threadSource: meta.thread_source || '',
      parentAgentSessionId: meta.parent_agent_session_id || meta.parent_thread_id || '',
      parentThreadId: meta.parent_thread_id || meta.parent_agent_session_id || '',
      agentNickname: meta.agent_nickname || '',
      agentRole: meta.agent_role || '',
    };
  }

  const sessionId = path.basename(filePath).replace(/\.jsonl(\.bak)?$/, '');

  const fd = fs.openSync(filePath, 'r');
  const bufSize = Math.min(fileStat.size, 16384);
  const buf = Buffer.alloc(bufSize);
  const bytesRead = fs.readSync(fd, buf, 0, bufSize, 0);
  fs.closeSync(fd);

  const chunk = buf.toString('utf8', 0, bytesRead);
  const lines = chunk.split('\n').filter(Boolean);

  let firstUserMessage = '';
  let lastUserContent = '';
  let firstAssistantText = '';
  let renameName = '';
  let sessionCwd = projectPath;
  let timestamp = modifiedAt;
  let version = '';
  let gitBranch = '';
  let slug = '';
  let userMsgCount = 0;
  let allUserMessages = [];
  let modelId = '';
  let modelProvider = '';
  let agent = 'claude';

  // Claude Code users can issue `/rename Foo bar` as a user message to
  // re-label the session. Upstream claude-code-history-viewer uses this
  // as the highest-priority title source; we port that here.
  const RENAME_CMD_RE = /^\s*\/rename\s+(.+?)\s*$/m;

  for (const line of lines) {
    try {
      const entry = JSON.parse(line);
      const isWalle = entry.provider === 'walle' || entry.type === 'walle_part';
      if (isWalle) agent = 'walle';
      if (entry.type === 'session_meta' && isWalle) {
        sessionCwd = entry.cwd || sessionCwd;
        timestamp = entry.timestamp || timestamp;
        version = entry.version || version;
        gitBranch = entry.gitBranch || gitBranch;
        if (!modelId && entry.modelId) modelId = entry.modelId;
        if (!modelProvider && entry.modelProvider) modelProvider = entry.modelProvider;
        continue;
      }
      // Capture slug from any row that has it (Claude Code writes it on most rows).
      // slug is the conversation-level identifier that persists across --resume boundaries.
      if (!slug && typeof entry.slug === 'string' && entry.slug) slug = entry.slug;
      // Session-start hook/attachment rows often carry the real cwd before the
      // first user message. Prefer that over lossy project-entry decoding.
      if (entry.cwd && (!sessionCwd || sessionCwd === projectPath)) sessionCwd = entry.cwd;
      if (!gitBranch && entry.gitBranch) gitBranch = entry.gitBranch;
      // Extract model from assistant entries (JSONL has "model" inside entry.message)
      const entryModel = entry.message?.model || entry.model || entry.modelId;
      if (!modelId && entryModel && !entryModel.startsWith('<')) {
        modelId = entryModel;
        modelProvider = entry.modelProvider || inferModelProvider(modelId);
      }
      if (entry.type === 'user' && entry.message?.role === 'user') {
        const text = messageText(entry.message);
        userMsgCount++;
        if (text) {
          allUserMessages.push(text.slice(0, 200));
          lastUserContent = text.slice(0, 200);
          // Only the first /rename in the head buffer wins; later ones
          // are picked up during the full-file parse in
          // api-prompts.js:_parseConversationContent.
          if (!renameName) {
            const m = text.match(RENAME_CMD_RE);
            if (m && m[1]) renameName = m[1].slice(0, 200);
          }
        }
        if (!firstUserMessage && text) {
          firstUserMessage = text.slice(0, 200);
          sessionCwd = entry.cwd || sessionCwd;
          timestamp = entry.timestamp || timestamp;
          version = entry.version || version;
          gitBranch = entry.gitBranch || gitBranch;
        }
      } else if (entry.type === 'user' && entry.provider === 'walle' && typeof entry.content === 'string') {
        const text = entry.content;
        userMsgCount++;
        if (text) {
          allUserMessages.push(text.slice(0, 200));
          lastUserContent = text.slice(0, 200);
        }
        if (!firstUserMessage && text) {
          firstUserMessage = text.slice(0, 200);
          sessionCwd = entry.cwd || sessionCwd;
          timestamp = entry.timestamp || timestamp;
          version = entry.version || version;
          gitBranch = entry.gitBranch || gitBranch;
        }
      } else if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
        if (!firstAssistantText) {
          const text = messageText(entry.message);
          if (text) firstAssistantText = text.slice(0, 200);
        }
      } else if (entry.type === 'assistant' && entry.provider === 'walle' && typeof entry.content === 'string') {
        if (!firstAssistantText && entry.content) firstAssistantText = entry.content.slice(0, 200);
      }
    } catch {
      // JSON.parse failed — line may be truncated (e.g. 1MB+ image block cut off
      // by the head buffer). Try regex extraction for user messages.
      if (!firstUserMessage && line.includes('"type":"user"') && line.includes('"role":"user"')) {
        userMsgCount++;
        const textMatch = line.match(/"type":"text","text":"((?:[^"\\]|\\.)*)"/);
        if (textMatch) {
          const text = textMatch[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\\\/g, '\\');
          if (text) {
            firstUserMessage = text.slice(0, 200);
            allUserMessages.push(firstUserMessage);
          }
        }
        const cwdMatch = line.match(/"cwd":"((?:[^"\\]|\\.)*)"/);
        if (cwdMatch) sessionCwd = cwdMatch[1];
        const tsMatch = line.match(/"timestamp":"((?:[^"\\]|\\.)*)"/);
        if (tsMatch) timestamp = tsMatch[1];
        const verMatch = line.match(/"version":"((?:[^"\\]|\\.)*)"/);
        if (verMatch) version = verMatch[1];
        const brMatch = line.match(/"gitBranch":"((?:[^"\\]|\\.)*)"/);
        if (brMatch) gitBranch = brMatch[1];
        if (!slug) {
          const slugMatch = line.match(/"slug":"((?:[^"\\]|\\.)*)"/);
          if (slugMatch) slug = slugMatch[1];
        }
      }
    }
  }

  let title = '';
  if (firstUserMessage) {
    title = firstUserMessage.split('\n')[0].replace(/^#+\s*/, '').replace(/[*_`]/g, '').trim();
    if (title.length > 80) title = title.slice(0, 77) + '...';
  }

  const isEmpty = userMsgCount === 0;

  return {
    sessionId,
    project: projectPath,
    projectEntry,
    cwd: sessionCwd,
    firstMessage: firstUserMessage,
    lastUserContent,
    firstAssistantText,
    renameName,
    title,
    isEmpty,
    userMsgCount,
    modifiedAt,
    timestamp,
    version,
    gitBranch,
    slug,
    fileSize: fileStat.size,
    hostname: os.hostname(),
    modelId,
    modelProvider,
    model: modelId,
    agent,
    jsonlPath: filePath,
  };
}

function getAllSessionFiles() {
  const claudeProjectsDir = path.join(process.env.HOME, '.claude', 'projects');
  const results = [];

  if (fs.existsSync(claudeProjectsDir)) {
    for (const projectEntry of fs.readdirSync(claudeProjectsDir)) {
      const projectDir = path.join(claudeProjectsDir, projectEntry);
      let stat;
      try { stat = fs.statSync(projectDir); } catch { continue; }
      if (!stat.isDirectory()) continue;

      const projectPath = decodeProjectEntry(projectEntry);
      let files;
      try { files = fs.readdirSync(projectDir); } catch { continue; }

      const fileSet = new Set(files);
      for (const file of files) {
        if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.bak')) continue;
        if (file.endsWith('.jsonl.bak') && fileSet.has(file.replace(/\.bak$/, ''))) continue;
        const filePath = path.join(projectDir, file);
        results.push({ filePath, projectPath, projectEntry, sessionId: file.replace(/\.jsonl(\.bak)?$/, '') });
      }
    }
  }
  pushWalleSessionFiles(results);
  pushCodexSessionFiles(results);
  for (const entry of claudeDesktopSessions.listSessionFileEntries()) results.push(entry);
  return results;
}

// Async version — uses fs.promises to avoid blocking the event loop
// (critical when ~/.claude/projects is on Dropbox/network FS)
const fsp = fs.promises;
async function getAllSessionFilesAsync() {
  const claudeProjectsDir = path.join(process.env.HOME, '.claude', 'projects');
  const results = [];

  let projectEntries;
  try { projectEntries = await fsp.readdir(claudeProjectsDir); } catch { projectEntries = []; }

  for (const projectEntry of projectEntries) {
    const projectDir = path.join(claudeProjectsDir, projectEntry);
    let stat;
    try { stat = await fsp.stat(projectDir); } catch { continue; }
    if (!stat.isDirectory()) continue;

    const projectPath = decodeProjectEntry(projectEntry);
    let files;
    try { files = await fsp.readdir(projectDir); } catch { continue; }

    const fileSet = new Set(files);
    for (const file of files) {
      if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.bak')) continue;
      if (file.endsWith('.jsonl.bak') && fileSet.has(file.replace(/\.bak$/, ''))) continue;
      const filePath = path.join(projectDir, file);
      results.push({ filePath, projectPath, projectEntry, sessionId: file.replace(/\.jsonl(\.bak)?$/, '') });
    }
  }
  await pushWalleSessionFilesAsync(results);
  await pushCodexSessionFilesAsync(results);
  for (const entry of claudeDesktopSessions.listSessionFileEntries()) results.push(entry);
  return results;
}

module.exports = { decodeProjectEntry, parseSessionFile, getAllSessionFiles, getAllSessionFilesAsync };
