'use strict';

/**
 * status-watcher.js — Core JSONL watcher.
 *
 * - fs.watch on TRANSCRIPT_DIR (recursive)
 * - On file change: read new bytes, parse JSONL, update SessionState
 * - Map parsed events to status lines
 * - Detect file truncation (compaction) -> reset offset
 * - Debounce updates via status-box.js throttle
 * - Idle detection: pendingToolCalls==0 AND no new lines for IDLE_TIMEOUT_S
 * - Emits: 'session-update' (sessionKey, sessionState)
 *           'session-idle' (sessionKey)
 */

const fs = require('fs');
const path = require('path');
const { EventEmitter } = require('events');
const { resolve: resolveLabel } = require('./tool-labels');

class StatusWatcher extends EventEmitter {
  /**
   * @param {object} opts
   * @param {string} opts.transcriptDir - Base transcript directory
   * @param {number} [opts.idleTimeoutS] - Idle timeout in seconds
   * @param {object} [opts.logger] - pino logger
   */
  constructor(opts) {
    super();
    this.transcriptDir = opts.transcriptDir;
    this.idleTimeoutS = opts.idleTimeoutS || 60;
    this.logger = opts.logger || null;

    // Map<sessionKey, SessionState>
    this.sessions = new Map();

    // Map<filePath, sessionKey>
    this.fileToSession = new Map();

    // fs.Watcher instance
    this._watcher = null;
    this._running = false;
  }

  /**
   * Register a session to watch.
   * @param {string} sessionKey
   * @param {string} transcriptFile - Absolute path to {uuid}.jsonl
   * @param {object} [initialState] - Pre-populated state (from offset recovery)
   */
  addSession(sessionKey, transcriptFile, initialState = {}) {
    if (this.sessions.has(sessionKey)) return;

    // For new sessions (no saved offset), start from current file position
    // so we only show NEW content going forward — not the entire backlog.
    // Pass lastOffset: 0 explicitly to read from the beginning.
    var startOffset;
    if (initialState.lastOffset !== undefined) {
      startOffset = initialState.lastOffset;
    } else {
      try {
        var stat = fs.statSync(transcriptFile);
        startOffset = stat.size;
      } catch (_e) {
        startOffset = 0;
      }
    }

    const state = {
      sessionKey,
      transcriptFile,
      status: 'active',
      startTime: initialState.startTime || Date.now(),
      lines: initialState.lines || [],
      pendingToolCalls: 0,
      lastOffset: startOffset,
      lastActivityAt: Date.now(),
      agentId: initialState.agentId || extractAgentId(sessionKey),
      depth: initialState.depth || 0,
      tokenCount: 0,
      children: [],
      idleTimer: null,
    };

    this.sessions.set(sessionKey, state);
    this.fileToSession.set(transcriptFile, sessionKey);

    if (this.logger) {
      this.logger.debug({ sessionKey, transcriptFile, startOffset }, 'Session added to watcher');
    }

    // Only read if we have an explicit offset (recovery or explicit 0) — new sessions start streaming from current position
    if (initialState.lastOffset !== undefined) {
      this._readFile(sessionKey, state);
    }

    // Start file polling as fallback (fs.watch may not work on bind mounts in Docker)
    this._startFilePoll(sessionKey, state);
  }

  /**
   * Start polling a transcript file for changes (fallback for fs.watch).
   * @private
   */
  _startFilePoll(sessionKey, state) {
    var self = this;
    var pollInterval = 500; // 500ms poll
    state._filePollTimer = setInterval(function () {
      try {
        var stat = fs.statSync(state.transcriptFile);
        if (stat.size > state.lastOffset) {
          self._readFile(sessionKey, state);
        }
      } catch (_e) {
        // File might not exist yet or was deleted
      }
    }, pollInterval);
  }

  /**
   * Remove a session from watching.
   * @param {string} sessionKey
   */
  removeSession(sessionKey) {
    const state = this.sessions.get(sessionKey);
    if (!state) return;

    if (state.idleTimer) clearTimeout(state.idleTimer);
    if (state._filePollTimer) clearInterval(state._filePollTimer);
    this.fileToSession.delete(state.transcriptFile);
    this.sessions.delete(sessionKey);

    if (this.logger) {
      this.logger.debug({ sessionKey }, 'Session removed from watcher');
    }
  }

  /**
   * Get current session state (for offset persistence).
   * @param {string} sessionKey
   * @returns {object|null}
   */
  getSessionState(sessionKey) {
    return this.sessions.get(sessionKey) || null;
  }

  /**
   * Start the file system watcher.
   */
  start() {
    if (this._running) return;
    this._running = true;

    try {
      this._watcher = fs.watch(this.transcriptDir, { recursive: true }, (eventType, filename) => {
        if (!filename) return;
        const fullPath = path.resolve(this.transcriptDir, filename);
        this._onFileChange(fullPath);
      });

      this._watcher.on('error', (err) => {
        if (this.logger) this.logger.error({ err }, 'fs.watch error');
        this.emit('error', err);
      });

      if (this.logger) {
        this.logger.info({ dir: this.transcriptDir }, 'StatusWatcher started (fs.watch)');
      }
    } catch (err) {
      if (this.logger) {
        this.logger.error({ err }, 'Failed to start fs.watch — transcriptDir may not exist');
      }
      this._running = false;
      throw err;
    }
  }

  /**
   * Stop the file system watcher.
   */
  stop() {
    this._running = false;
    if (this._watcher) {
      this._watcher.close();
      this._watcher = null;
    }
    // Clear all timers
    for (const [, state] of this.sessions) {
      if (state.idleTimer) {
        clearTimeout(state.idleTimer);
        state.idleTimer = null;
      }
      if (state._filePollTimer) {
        clearInterval(state._filePollTimer);
        state._filePollTimer = null;
      }
    }
    if (this.logger) this.logger.info('StatusWatcher stopped');
  }

  /**
   * Handle a file change event.
   * @private
   */
  _onFileChange(fullPath) {
    // Only process .jsonl files
    if (!fullPath.endsWith('.jsonl')) return;

    const sessionKey = this.fileToSession.get(fullPath);
    if (!sessionKey) return;

    const state = this.sessions.get(sessionKey);
    if (!state) return;

    this._readFile(sessionKey, state);
  }

  /**
   * Read new bytes from a transcript file.
   * @private
   */
  _readFile(sessionKey, state) {
    let fd;
    try {
      fd = fs.openSync(state.transcriptFile, 'r');
      const stat = fs.fstatSync(fd);
      const fileSize = stat.size;

      // Detect file truncation (compaction)
      if (fileSize < state.lastOffset) {
        if (this.logger) {
          this.logger.warn(
            { sessionKey, fileSize, lastOffset: state.lastOffset },
            'Transcript truncated (compaction detected) — resetting offset',
          );
        }
        state.lastOffset = 0;
        state.lines = ['[session compacted - continuing]'];
        state.pendingToolCalls = 0;
      }

      if (fileSize <= state.lastOffset) {
        fs.closeSync(fd);
        return;
      }

      // Read new bytes
      const bytesToRead = fileSize - state.lastOffset;
      const buffer = Buffer.allocUnsafe(bytesToRead);
      const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, state.lastOffset);
      fs.closeSync(fd);

      state.lastOffset += bytesRead;

      // Parse JSONL lines
      const chunk = buffer.toString('utf8', 0, bytesRead);
      const lines = chunk.split('\n').filter((l) => l.trim());

      for (const line of lines) {
        this._parseLine(sessionKey, state, line);
      }

      // Update activity timestamp
      state.lastActivityAt = Date.now();

      // Schedule idle check
      this._scheduleIdleCheck(sessionKey, state);

      // Emit update event
      this.emit('session-update', sessionKey, this._sanitizeState(state));
    } catch (err) {
      if (fd !== undefined) {
        try {
          fs.closeSync(fd);
        } catch (_e) {
          /* ignore close error */
        }
      }
      if (this.logger) {
        this.logger.error({ sessionKey, err }, 'Error reading transcript file');
      }
    }
  }

  /**
   * Parse a single JSONL line and update session state.
   * @private
   */
  _parseLine(sessionKey, state, line) {
    let record;
    try {
      record = JSON.parse(line);
    } catch (_e) {
      // Skip malformed lines
      return;
    }

    // OpenClaw transcript format: all records have type="message"
    // with record.message.role = "assistant" | "toolResult" | "user"
    // and record.message.content = array of {type: "text"|"toolCall", ...}
    if (record.type === 'message' && record.message) {
      const msg = record.message;
      const role = msg.role;

      if (role === 'assistant') {
        // Extract usage if present
        if (msg.usage && msg.usage.totalTokens) {
          state.tokenCount = msg.usage.totalTokens;
        }

        // Parse content items
        const contentItems = Array.isArray(msg.content) ? msg.content : [];
        for (var i = 0; i < contentItems.length; i++) {
          var item = contentItems[i];

          if (item.type === 'toolCall') {
            state.pendingToolCalls++;
            var toolName = item.name || 'unknown';
            var label = resolveLabel(toolName);
            // Show tool name with key arguments
            var argStr = '';
            if (item.arguments) {
              if (item.arguments.command) {
                argStr = item.arguments.command.slice(0, 60);
              } else if (item.arguments.file_path || item.arguments.path) {
                argStr = item.arguments.file_path || item.arguments.path;
              } else if (item.arguments.query) {
                argStr = item.arguments.query.slice(0, 60);
              } else if (item.arguments.url) {
                argStr = item.arguments.url.slice(0, 60);
              }
            }
            var statusLine = argStr ? toolName + ': ' + argStr : toolName + ': ' + label;
            state.lines.push(statusLine);
          } else if (item.type === 'text' && item.text) {
            var text = item.text.trim();
            if (text) {
              var truncated = text.length > 100 ? text.slice(0, 97) + '...' : text;
              state.lines.push(truncated);
            }
          }
        }
      } else if (role === 'toolResult') {
        if (state.pendingToolCalls > 0) state.pendingToolCalls--;
        var resultToolName = msg.toolName || 'unknown';
        var isError = msg.isError === true;
        var marker = isError ? '[ERR]' : '[OK]';
        var idx = findLastIndex(state.lines, function (l) {
          return l.indexOf(resultToolName + ':') === 0;
        });
        if (idx >= 0) {
          state.lines[idx] = state.lines[idx].replace(/( \[OK\]| \[ERR\])?$/, ' ' + marker);
        }
      } else if (role === 'user') {
        // User messages — could show "User: ..." but skip for now to reduce noise
      }

      return;
    }

    // Legacy format fallback (for compatibility)
    var legacyType = record.type;

    switch (legacyType) {
      case 'tool_call': {
        state.pendingToolCalls++;
        var tn = record.name || record.tool || 'unknown';
        var lb = resolveLabel(tn);
        state.lines.push(tn + ': ' + lb);
        break;
      }

      case 'tool_result': {
        if (state.pendingToolCalls > 0) state.pendingToolCalls--;
        var rtn = record.name || record.tool || 'unknown';
        var mk = record.error ? '[ERR]' : '[OK]';
        var ri = findLastIndex(state.lines, function (l) {
          return l.indexOf(rtn + ':') === 0;
        });
        if (ri >= 0) {
          state.lines[ri] = state.lines[ri].replace(/( \[OK\]| \[ERR\])?$/, ' ' + mk);
        }
        break;
      }

      case 'assistant': {
        var at = (record.text || record.content || '').trim();
        if (at) {
          var tr = at.length > 100 ? at.slice(0, 97) + '...' : at;
          state.lines.push(tr);
        }
        break;
      }

      case 'usage': {
        if (record.total_tokens) state.tokenCount = record.total_tokens;
        else if (record.input_tokens || record.output_tokens) {
          state.tokenCount = (record.input_tokens || 0) + (record.output_tokens || 0);
        }
        break;
      }

      case 'session_start': {
        state.startTime = record.timestamp ? new Date(record.timestamp).getTime() : state.startTime;
        break;
      }

      default:
        break;
    }
  }

  /**
   * Schedule an idle check for a session.
   * @private
   */
  _scheduleIdleCheck(sessionKey, state) {
    if (state.idleTimer) {
      clearTimeout(state.idleTimer);
    }

    state.idleTimer = setTimeout(() => {
      this._checkIdle(sessionKey);
    }, this.idleTimeoutS * 1000);
  }

  /**
   * Check if a session is idle.
   * @private
   */
  _checkIdle(sessionKey) {
    const state = this.sessions.get(sessionKey);
    if (!state) return;

    const elapsed = Date.now() - state.lastActivityAt;
    const idleMs = this.idleTimeoutS * 1000;

    if (elapsed >= idleMs && state.pendingToolCalls === 0) {
      if (this.logger) {
        this.logger.info({ sessionKey, elapsedS: Math.floor(elapsed / 1000) }, 'Session idle');
      }
      state.status = 'done';
      state.idleTimer = null;
      this.emit('session-idle', sessionKey, this._sanitizeState(state));
    } else {
      // Reschedule
      this._scheduleIdleCheck(sessionKey, state);
    }
  }

  /**
   * Return a safe copy of session state (without circular refs, timers).
   * @private
   */
  _sanitizeState(state) {
    return {
      sessionKey: state.sessionKey,
      transcriptFile: state.transcriptFile,
      status: state.status,
      startTime: state.startTime,
      lines: [...state.lines],
      pendingToolCalls: state.pendingToolCalls,
      lastOffset: state.lastOffset,
      lastActivityAt: state.lastActivityAt,
      agentId: state.agentId,
      depth: state.depth,
      tokenCount: state.tokenCount,
      children: state.children,
    };
  }
}

/**
 * Extract agent ID from session key.
 * @param {string} sessionKey
 * @returns {string}
 */
function extractAgentId(sessionKey) {
  if (!sessionKey) return 'unknown';
  const parts = sessionKey.split(':');
  if (parts[0] === 'agent' && parts[1]) return parts[1];
  return parts[0] || 'unknown';
}

/**
 * Find the last index in an array satisfying a predicate.
 * @param {Array} arr
 * @param {Function} predicate
 * @returns {number}
 */
function findLastIndex(arr, predicate) {
  for (let i = arr.length - 1; i >= 0; i--) {
    if (predicate(arr[i])) return i; // eslint-disable-line security/detect-object-injection
  }
  return -1;
}

module.exports = { StatusWatcher };
