'use strict';

const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');

const DEFAULT_LIMIT_BYTES = 1024 * 1024;

class WorkspaceReplay {
  constructor({ sessionsDir = defaultSessionsDir(), now = () => new Date().toISOString() } = {}) {
    this.sessionsDir = sessionsDir;
    this.now = now;
  }

  resolveSessionPath(sessionId, { transcriptPath = '' } = {}) {
    return findSessionPath(sessionId, { transcriptPath, sessionsDir: this.sessionsDir });
  }

  appendRecord(sessionId, record = {}, opts = {}) {
    const transcriptPath = opts.transcriptPath || sessionPath(this.sessionsDir, sessionId);
    fs.mkdirSync(path.dirname(transcriptPath), { recursive: true });
    const offset = fs.existsSync(transcriptPath) ? fs.statSync(transcriptPath).size : 0;
    const seq = isSafeSeq(record.seq) ? record.seq : countJsonlRecords(transcriptPath);
    const normalized = {
      provider: 'walle',
      version: 'walle-jsonl-v1',
      sessionId,
      seq,
      uuid: record.uuid || crypto.randomUUID(),
      timestamp: record.timestamp || this.now(),
      ...record,
      seq,
    };
    const line = JSON.stringify(normalized) + '\n';
    fs.appendFileSync(transcriptPath, line, 'utf8');
    return {
      sessionId,
      transcriptPath,
      offset,
      bytes: Buffer.byteLength(line),
      nextOffset: offset + Buffer.byteLength(line),
      record: normalized,
    };
  }

  appendPart(sessionId, partType, data = {}, opts = {}) {
    return this.appendRecord(sessionId, {
      type: 'walle_part',
      cwd: opts.cwd || '',
      chatSessionId: opts.chatSessionId || '',
      parentUuid: opts.parentUuid || null,
      partType: partType || 'event',
      data: data && typeof data === 'object' ? data : {},
    }, opts);
  }

  replayRange(sessionId, opts = {}) {
    const transcriptPath = this.resolveSessionPath(sessionId, opts);
    if (!transcriptPath) return emptyReplay(sessionId, opts);

    const stat = fs.statSync(transcriptPath);
    const startOffset = clampNumber(opts.offset, 0, stat.size);
    const requestedSequence = clampNumber(opts.sequence, 0, Number.MAX_SAFE_INTEGER);
    const limitBytes = Math.max(1, Number(opts.limitBytes || opts.limit || DEFAULT_LIMIT_BYTES));
    const raw = readRange(transcriptPath, startOffset, limitBytes);
    const parsed = parseJsonlRange(raw.text, startOffset, startOffset > 0 ? requestedSequence : 0);
    const nextOffset = raw.nextOffset - Buffer.byteLength(parsed.tail, 'utf8');
    const records = parsed.records.filter((entry) => startOffset > 0 || entry.sequence >= requestedSequence);

    return {
      sessionId,
      transcriptPath,
      offset: startOffset,
      sequence: requestedSequence,
      records,
      nextOffset,
      nextSequence: records.length ? records[records.length - 1].sequence + 1 : parsed.nextSequence,
      fileSize: stat.size,
      eof: nextOffset >= stat.size,
    };
  }

  rebuildState(sessionId, opts = {}) {
    const transcriptPath = this.resolveSessionPath(sessionId, opts);
    if (!transcriptPath) return { ...emptyState(sessionId), workspace: workspaceStatus(opts.cwd || '') };

    const replay = this.replayRange(sessionId, {
      ...opts,
      offset: 0,
      sequence: 0,
      limitBytes: Number.MAX_SAFE_INTEGER,
      transcriptPath,
    });
    const state = emptyState(sessionId);
    state.transcriptPath = transcriptPath;
    state.nextOffset = replay.nextOffset;
    state.nextSequence = replay.nextSequence;
    state.fileSize = replay.fileSize;

    for (const entry of replay.records) {
      const record = entry.record;
      if (record.type === 'session_meta') {
        state.meta = {
          ...state.meta,
          ...pick(record, ['sessionId', 'chatSessionId', 'cwd', 'label', 'modelId', 'modelProvider', 'runtimeMode', 'gitBranch', 'agent', 'mode', 'timestamp']),
        };
        if (record.cwd) state.cwd = record.cwd;
        continue;
      }
      if (record.cwd && !state.cwd) state.cwd = record.cwd;
      if (record.type === 'user' || record.type === 'assistant') {
        state.messages.push({
          sequence: entry.sequence,
          offset: entry.offset,
          uuid: record.uuid || '',
          parentUuid: record.parentUuid || null,
          role: record.message?.role || record.type,
          text: extractText(record.message?.content ?? record.message ?? record.content),
          timestamp: record.timestamp || '',
        });
        continue;
      }
      if (record.type === 'walle_part') {
        const part = {
          sequence: entry.sequence,
          seq: entry.seq,
          offset: entry.offset,
          uuid: record.uuid || '',
          parentUuid: record.parentUuid || null,
          partType: record.partType || 'event',
          data: record.data || {},
          timestamp: record.timestamp || '',
        };
        state.parts.push(part);
        if (isToolPart(part)) {
          state.tools.push(part);
        }
        if (isPermissionPart(part)) {
          state.permissions.push(part);
        }
        if (isUsagePart(part)) {
          state.usage.push(part.data.usage || part.data.tokens || part.data);
        }
        if (isErrorPart(part)) {
          state.errors.push(part);
        }
        if (isArtifactPart(part)) {
          state.artifacts.push(part);
        }
        if (isDiagnosticPart(part)) {
          state.diagnostics.push(part);
        }
        if (isRuntimePart(part)) {
          collectRuntimePart(state, part);
        }
      }
    }

    state.workspace = workspaceStatus(opts.cwd || state.cwd || state.meta.cwd || '');
    return state;
  }
}

function defaultSessionsDir(env = process.env) {
  if (env.WALLE_SESSIONS_DIR) return env.WALLE_SESSIONS_DIR;
  if (env.WALL_E_SESSIONS_DIR) return env.WALL_E_SESSIONS_DIR;
  if (env.WALLE_DEV_DIR) return path.join(env.WALLE_DEV_DIR, 'sessions');
  if (env.WALL_E_DATA_DIR && env.WALL_E_DATA_DIR !== path.join(os.homedir(), '.walle', 'data')) {
    return path.join(env.WALL_E_DATA_DIR, 'sessions');
  }
  // CTM_DATA_DIR belongs to the task manager; Wall-E transcripts stay in Wall-E-owned storage.
  return path.join(os.homedir(), '.walle', 'sessions');
}

function sessionPath(rootDir, sessionId) {
  return path.join(rootDir, `${safeSessionId(sessionId)}.jsonl`);
}

function safeSessionId(sessionId) {
  const id = String(sessionId || '');
  if (!id || id.includes('..') || /[\\/]/.test(id) || !/^[A-Za-z0-9_.:-]+$/.test(id)) {
    throw new Error(`Invalid session id: ${id || '<empty>'}`);
  }
  return id;
}

function findSessionPath(sessionId, { transcriptPath = '', sessionsDir = defaultSessionsDir() } = {}) {
  if (transcriptPath && fs.existsSync(transcriptPath)) return transcriptPath;
  if (!sessionId) return '';
  const roots = [
    sessionsDir,
    process.env.WALLE_SESSIONS_DIR,
    process.env.WALL_E_SESSIONS_DIR,
    process.env.WALLE_DEV_DIR ? path.join(process.env.WALLE_DEV_DIR, 'sessions') : '',
    process.env.WALL_E_DATA_DIR ? path.join(process.env.WALL_E_DATA_DIR, 'sessions') : '',
    path.join(os.homedir(), '.walle', 'sessions'),
  ].filter(Boolean);
  for (const root of [...new Set(roots)]) {
    const candidate = sessionPath(root, sessionId);
    try {
      if (fs.existsSync(candidate)) return candidate;
    } catch {}
  }
  return '';
}

function readRange(filePath, offset, limitBytes) {
  const fd = fs.openSync(filePath, 'r');
  try {
    const stat = fs.fstatSync(fd);
    const length = Math.min(limitBytes, Math.max(0, stat.size - offset));
    const buffer = Buffer.alloc(length);
    const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
    return {
      text: buffer.toString('utf8', 0, bytesRead),
      nextOffset: offset + bytesRead,
    };
  } finally {
    fs.closeSync(fd);
  }
}

function parseJsonlRange(text, baseOffset, baseSequence) {
  const records = [];
  const lines = text.split('\n');
  const completeLineCount = text.endsWith('\n') ? lines.length - 1 : lines.length - 1;
  let cursor = baseOffset;
  let sequence = clampNumber(baseSequence, 0, Number.MAX_SAFE_INTEGER);
  for (let i = 0; i < completeLineCount; i++) {
    const line = lines[i];
    const bytes = Buffer.byteLength(line + '\n');
    if (line.trim()) {
      try {
        const record = JSON.parse(line);
        records.push({
          sequence,
          seq: isSafeSeq(record.seq) ? record.seq : sequence,
          offset: cursor,
          nextOffset: cursor + bytes,
          record,
          raw: line,
        });
        sequence++;
      } catch {
        sequence++;
      }
    }
    cursor += bytes;
  }
  return {
    records,
    tail: text.endsWith('\n') ? '' : lines[lines.length - 1],
    nextSequence: sequence,
  };
}

function emptyReplay(sessionId, opts = {}) {
  return {
    sessionId,
    transcriptPath: '',
    offset: clampNumber(opts.offset, 0, Number.MAX_SAFE_INTEGER),
    sequence: clampNumber(opts.sequence, 0, Number.MAX_SAFE_INTEGER),
    records: [],
    nextOffset: 0,
    nextSequence: clampNumber(opts.sequence, 0, Number.MAX_SAFE_INTEGER),
    fileSize: 0,
    eof: true,
  };
}

function emptyState(sessionId) {
  return {
    sessionId,
    transcriptPath: '',
    cwd: '',
    meta: {},
    messages: [],
    parts: [],
    tools: [],
    permissions: [],
    usage: [],
    errors: [],
    artifacts: [],
    diagnostics: [],
    runtime: {
      threads: [],
      turns: [],
      items: [],
      events: [],
      latestSeq: -1,
    },
    workspace: workspaceStatus(''),
    nextOffset: 0,
    nextSequence: 0,
    fileSize: 0,
  };
}

function countJsonlRecords(filePath) {
  try {
    if (!filePath || !fs.existsSync(filePath)) return 0;
    const text = fs.readFileSync(filePath, 'utf8');
    if (!text) return 0;
    const lines = text.split('\n');
    const limit = text.endsWith('\n') ? lines.length - 1 : lines.length;
    let count = 0;
    for (let i = 0; i < limit; i++) {
      if (lines[i].trim()) count++;
    }
    return count;
  } catch {
    return 0;
  }
}

function workspaceStatus(cwd) {
  if (!cwd) return { cwd: '', available: false, reason: 'missing_cwd' };
  try {
    const stat = fs.statSync(cwd);
    return { cwd, available: stat.isDirectory(), reason: stat.isDirectory() ? '' : 'not_directory' };
  } catch {
    return { cwd, available: false, reason: 'missing' };
  }
}

function extractText(content) {
  if (!content) return '';
  if (typeof content === 'string') return content;
  if (Array.isArray(content)) {
    return content
      .map((part) => {
        if (!part) return '';
        if (typeof part === 'string') return part;
        return part.text || part.content || '';
      })
      .filter(Boolean)
      .join('\n');
  }
  return content.text || content.content || '';
}

function pick(record, keys) {
  const out = {};
  for (const key of keys) {
    if (record[key] !== undefined) out[key] = record[key];
  }
  return out;
}

function isToolPart(part) {
  return part.partType === 'tool'
    || part.partType === 'tool_call'
    || part.partType === 'tool_result'
    || part.partType === 'tool_done';
}

function isPermissionPart(part) {
  return part.partType === 'permission'
    || part.partType === 'approval_request'
    || part.partType === 'approval_response'
    || part.data.state === 'permission_check';
}

function isUsagePart(part) {
  return part.partType === 'usage'
    || (part.partType === 'turn_complete' && (part.data.usage || part.data.tokens));
}

function isErrorPart(part) {
  return part.partType === 'runtime_error' || part.partType === 'error';
}

function isArtifactPart(part) {
  return part.partType === 'artifact'
    || part.partType === 'tool_artifact'
    || part.data?.type === 'tool_output'
    || Boolean(part.data?.artifact?.artifactId);
}

function isDiagnosticPart(part) {
  return part.partType === 'diagnostic'
    || part.partType === 'diagnostics'
    || part.data?.type === 'diagnostic'
    || Array.isArray(part.data?.diagnostics);
}

function isRuntimePart(part) {
  return part.partType === 'runtime_thread'
    || part.partType === 'runtime_turn'
    || part.partType === 'runtime_item'
    || part.partType === 'runtime_event'
    || String(part.data?.type || '').startsWith('runtime.');
}

function collectRuntimePart(state, part) {
  const data = part.data || {};
  const row = {
    sequence: part.sequence,
    seq: part.seq,
    offset: part.offset,
    uuid: part.uuid,
    timestamp: part.timestamp,
    type: data.type || `runtime.${String(part.partType || '').replace(/^runtime_/, '')}`,
    kind: data.kind || String(part.partType || '').replace(/^runtime_/, ''),
    status: data.status || data.state || '',
    threadId: data.threadId || '',
    turnId: data.turnId || '',
    itemId: data.itemId || '',
    eventId: data.eventId || '',
    name: data.name || '',
    data: data.data || data.details || {},
  };
  state.runtime.latestSeq = Math.max(state.runtime.latestSeq, isSafeSeq(row.seq) ? row.seq : row.sequence);
  if (part.partType === 'runtime_thread' || row.kind === 'thread') {
    state.runtime.threads.push(row);
  } else if (part.partType === 'runtime_turn' || row.kind === 'turn') {
    state.runtime.turns.push(row);
  } else if (part.partType === 'runtime_item' || row.kind === 'item') {
    state.runtime.items.push(row);
  } else {
    state.runtime.events.push(row);
  }
}

function isSafeSeq(value) {
  return Number.isSafeInteger(value) && value >= 0;
}

function clampNumber(value, min, max) {
  const n = Number(value);
  if (!Number.isFinite(n)) return min;
  return Math.min(max, Math.max(min, Math.floor(n)));
}

module.exports = {
  WorkspaceReplay,
  defaultSessionsDir,
  sessionPath,
  safeSessionId,
  findSessionPath,
  parseJsonlRange,
  workspaceStatus,
  extractText,
  isToolPart,
  isPermissionPart,
  isArtifactPart,
  isDiagnosticPart,
  isRuntimePart,
};
