/**
 * Security Event Store
 *
 * Unified JSONL store for security-relevant events from any source:
 *   - api          : DashCaddy API audit events (extends src/security/audit-logger.js)
 *   - caddy        : Caddy reverse-proxy access log events (parsed by tail-worker)
 *   - fail2ban     : SSH brute-force bans (read from /var/log/fail2ban.log)
 *   - shared-bans  : IP blocklist promotion events (read from /var/log/shared-bans-promote.log)
 *   - syslog       : (v2) Generic syslog messages
 *   - agent        : (v2) Events from a remote DCA (DashCaddy Agent) binary
 *
 * Storage format: JSONL (one JSON object per line). Why JSONL not JSON-array?
 *   - Append-only writes are O(1) — no read-modify-write race
 *   - Partial reads on crash (last line may be corrupt, but earlier lines survive)
 *   - Trivial to grep/jq for forensics
 *   - Easy to tail from a remote source
 *
 * Query strategy: in-memory index with file-backed persistence. For <100k events
 * this is fine. Beyond that we'd switch to SQLite — see BACKLOG.md (DC-???) to track.
 *
 * The store also emits events to subscribers (in-process EventEmitter), so the
 * dashboard can do live tailing via Server-Sent Events in v2.
 */

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { EventEmitter } = require('events');
const platformPaths = require('../../platform-paths');

const EVENT_STORE_FILE = process.env.SECURITY_EVENT_LOG_FILE
  || path.join(platformPaths.dataDir, 'security-events.jsonl');

const MAX_EVENTS_IN_MEMORY = parseInt(process.env.SECURITY_EVENT_MAX_MEMORY || '10000', 10);
const MAX_EVENTS_ON_DISK   = parseInt(process.env.SECURITY_EVENT_MAX_DISK   || '100000', 10);

const VALID_SOURCE_TYPES = new Set(['api', 'caddy', 'fail2ban', 'shared-bans', 'syslog', 'agent']);
const VALID_SEVERITIES   = new Set(['info', 'notice', 'warn', 'error', 'critical']);
const VALID_OUTCOMES     = new Set(['success', 'denied', 'blocked', 'rate-limited', 'error', 'unknown']);

class SecurityEventStore extends EventEmitter {
  constructor(opts = {}) {
    super();
    this.filePath       = opts.filePath || EVENT_STORE_FILE;
    this.maxMemory      = opts.maxMemory || MAX_EVENTS_IN_MEMORY;
    this.maxDisk        = opts.maxDisk   || MAX_EVENTS_ON_DISK;
    this.log            = opts.log || console;
    this.events         = [];   // newest first
    this.byId           = new Map();
    this.lastWriteLine  = 0;    // byte offset of last successfully-written line
    this.writeQueue     = [];   // serialized write buffer
    this.writing        = false;
    this._load();
  }

  /**
   * Load existing events from disk into memory (newest first).
   * Skips corrupt lines — logs warning but continues.
   */
  _load() {
    try {
      if (!fs.existsSync(this.filePath)) {
        this.log.info?.('security', 'event store: starting fresh (no file)', { path: this.filePath });
        return;
      }
      const content = fs.readFileSync(this.filePath, 'utf8');
      const lines = content.split('\n').filter(l => l.trim());
      this.log.info?.('security', 'event store: loading from disk', { total_lines: lines.length, path: this.filePath });

      let loaded = 0;
      let skipped = 0;
      // Walk from end backwards so newest are first
      for (let i = lines.length - 1; i >= 0 && loaded < this.maxMemory; i--) {
        const line = lines[i].trim();
        if (!line) continue;
        try {
          const ev = JSON.parse(line);
          if (!this._isValidShape(ev)) {
            skipped++;
            continue;
          }
          this.events.push(ev);
          this.byId.set(ev.id, ev);
          loaded++;
        } catch (e) {
          skipped++;
          // Don't log every line — could be thousands of corrupted lines
        }
      }
      this.log.info?.('security', 'event store: load complete', { loaded, skipped, kept_in_memory: this.events.length });
    } catch (e) {
      this.log.error?.('security', 'event store: load failed', { error: e.message });
    }
  }

  /**
   * Validate minimum shape of an event before storing/returning it.
   * Permissive — accepts extras, just requires the spine.
   */
  _isValidShape(ev) {
    if (!ev || typeof ev !== 'object') return false;
    if (typeof ev.id !== 'string' || !ev.id) return false;
    if (typeof ev.ts !== 'string' || !ev.ts) return false;
    if (typeof ev.source_host !== 'string') return false;
    if (typeof ev.source_type !== 'string' || !VALID_SOURCE_TYPES.has(ev.source_type)) return false;
    return true;
  }

  /**
   * Append a new event. Validates shape, writes to disk, indexes in memory,
   * emits 'event' for live-tail subscribers.
   *
   * @param {object} partial - event without id (one will be generated)
   * @returns {object} the stored event
   */
  append(partial) {
    const event = this._normalize(partial);
    const validationError = this._validate(event);
    if (validationError) {
      this.log.warn?.('security', 'rejected invalid event', { error: validationError, partial });
      throw new Error(`Invalid event: ${validationError}`);
    }

    // Write to disk first (durability), then index in memory.
    // We queue the write so multiple append() calls don't interleave on the same fd.
    this.writeQueue.push(event);
    this._flushQueue();

    // Index (in-memory only — newest first)
    this.events.unshift(event);
    this.byId.set(event.id, event);
    if (this.events.length > this.maxMemory) {
      const evicted = this.events.pop();
      this.byId.delete(evicted.id);
    }

    this.emit('event', event);
    return event;
  }

  /**
   * Normalize partial event — fill in defaults, generate id+ts.
   */
  _normalize(p) {
    const now = new Date().toISOString();
    return {
      id:           p.id           || crypto.randomUUID(),
      ts:           p.ts           || now,
      source_host:  p.source_host  || 'unknown',
      source_type:  p.source_type  || 'api',
      actor:        p.actor        || null,           // IP, user, agent_id
      target:       p.target       || null,           // endpoint, service id, host
      action:       p.action       || 'unknown',      // free-form but stable per source_type
      outcome:      p.outcome      || 'unknown',
      severity:     p.severity     || 'info',
      message:      p.message      || null,           // human-readable one-liner
      metadata:     p.metadata     && typeof p.metadata === 'object' ? p.metadata : {},
      ...(p.tags && Array.isArray(p.tags) ? { tags: p.tags } : {}),
    };
  }

  _validate(ev) {
    if (!VALID_SOURCE_TYPES.has(ev.source_type)) return `bad source_type: ${ev.source_type}`;
    if (!VALID_SEVERITIES.has(ev.severity)) return `bad severity: ${ev.severity}`;
    if (!VALID_OUTCOMES.has(ev.outcome)) return `bad outcome: ${ev.outcome}`;
    if (typeof ev.actor === 'string' && ev.actor.length > 256) return 'actor too long';
    if (typeof ev.target === 'string' && ev.target.length > 512) return 'target too long';
    return null;
  }

  /**
   * Serialize appends to disk. Writes one line at a time, doesn't truncate.
   * Disk trimming happens separately via _trim().
   */
  _flushQueue() {
    if (this.writing) return;
    const next = this.writeQueue.shift();
    if (!next) return;
    this.writing = true;
    const line = JSON.stringify(next) + '\n';
    fs.appendFile(this.filePath, line, 'utf8', (err) => {
      this.writing = false;
      if (err) {
        this.log.error?.('security', 'write failed', { error: err.message });
        // Re-queue so we don't lose the event on transient errors
        this.writeQueue.unshift(next);
      } else {
        // Try next
        if (this.writeQueue.length > 0) setImmediate(() => this._flushQueue());
        this._maybeTrim();
      }
    });
  }

  /**
   * Trim disk log if it exceeds maxDisk lines. Done in the background — never
   * blocks an append(). Strategy: rewrite the file keeping the most recent
   * maxDisk lines, atomically (write tmp + rename).
   */
  _maybeTrim() {
    fs.stat(this.filePath, (err, st) => {
      if (err || !st) return;
      // Cheap heuristic: if file is > 50MB we always trim. Otherwise count lines.
      const SIZE_LIMIT = 50 * 1024 * 1024;
      if (st.size < SIZE_LIMIT) return;
      this._trim();
    });
  }

  _trim() {
    this.log.info?.('security', 'trimming event store', { file: this.filePath });
    fs.readFile(this.filePath, 'utf8', (err, content) => {
      if (err) return;
      const lines = content.split('\n').filter(l => l.trim());
      if (lines.length <= this.maxDisk) return;
      const kept = lines.slice(-this.maxDisk).join('\n') + '\n';
      const tmp = this.filePath + '.tmp';
      fs.writeFile(tmp, kept, 'utf8', (e) => {
        if (e) {
          this.log.error?.('security', 'trim write failed', { error: e.message });
          return;
        }
        fs.rename(tmp, this.filePath, (e2) => {
          if (e2) this.log.error?.('security', 'trim rename failed', { error: e2.message });
        });
      });
    });
  }

  /**
   * Query events. All filters are AND-combined. Results are newest-first.
   *
   * @param {object} q - query
   *   limit     : number, default 100, max 1000
   *   offset    : number, default 0
   *   source_type: string or array
   *   source_host: string
   *   severity  : string or array
   *   outcome   : string or array
   *   actor     : string (exact or prefix match with `actor_prefix`)
   *   action    : string
   *   since     : ISO timestamp (inclusive)
   *   until     : ISO timestamp (exclusive)
   */
  query(q = {}) {
    const limit  = Math.min(parseInt(q.limit  || '100', 10), 1000);
    const offset = parseInt(q.offset || '0', 10);
    const sourceTypes = this._toArr(q.source_type);
    const severities = this._toArr(q.severity);
    const outcomes   = this._toArr(q.outcome);

    const matches = [];
    for (const ev of this.events) {
      if (sourceTypes.length && !sourceTypes.includes(ev.source_type)) continue;
      if (q.source_host && ev.source_host !== q.source_host) continue;
      if (severities.length && !severities.includes(ev.severity)) continue;
      if (outcomes.length && !outcomes.includes(ev.outcome)) continue;
      if (q.actor && ev.actor !== q.actor) continue;
      if (q.actor_prefix && (!ev.actor || !ev.actor.startsWith(q.actor_prefix))) continue;
      if (q.action && ev.action !== q.action) continue;
      if (q.since && ev.ts < q.since) continue;
      if (q.until && ev.ts >= q.until) continue;
      if (q.target && ev.target !== q.target) continue;
      matches.push(ev);
      if (matches.length >= offset + limit) break; // avoid scanning further
    }

    return {
      total: matches.length,
      events: matches.slice(offset, offset + limit),
    };
  }

  _toArr(v) {
    if (!v) return [];
    if (Array.isArray(v)) return v;
    return String(v).split(',').map(s => s.trim()).filter(Boolean);
  }

  /**
   * Compute aggregates for dashboards. Returns counts + top-N per dimension.
   * Cheap because events array is bounded by maxMemory.
   */
  stats(opts = {}) {
    const sinceMs = opts.since ? Date.parse(opts.since) : null;
    const filtered = sinceMs
      ? this.events.filter(e => Date.parse(e.ts) >= sinceMs)
      : this.events;

    const bySeverity  = {};
    const byOutcome   = {};
    const bySource    = {};
    const byHost      = {};
    const byAction    = {};
    const actorCount  = {};
    const targetCount = {};

    for (const ev of filtered) {
      bySeverity[ev.severity]  = (bySeverity[ev.severity]  || 0) + 1;
      byOutcome[ev.outcome]    = (byOutcome[ev.outcome]    || 0) + 1;
      bySource[ev.source_type] = (bySource[ev.source_type] || 0) + 1;
      byHost[ev.source_host]   = (byHost[ev.source_host]   || 0) + 1;
      byAction[ev.action]      = (byAction[ev.action]      || 0) + 1;
      if (ev.actor)  actorCount[ev.actor]   = (actorCount[ev.actor]   || 0) + 1;
      if (ev.target) targetCount[ev.target] = (targetCount[ev.target] || 0) + 1;
    }

    return {
      window: { since: opts.since || null, count: filtered.length },
      by_severity: bySeverity,
      by_outcome:  byOutcome,
      by_source:   bySource,
      by_host:     byHost,
      top_actions: this._topN(byAction, 10),
      top_actors:  this._topN(actorCount, 10),
      top_targets: this._topN(targetCount, 10),
    };
  }

  _topN(obj, n) {
    return Object.entries(obj)
      .sort((a, b) => b[1] - a[1])
      .slice(0, n)
      .map(([key, count]) => ({ key, count }));
  }

  /**
   * Get one event by id, or null.
   */
  get(id) {
    return this.byId.get(id) || null;
  }

  /**
   * Total events currently in memory.
   */
  size() {
    return this.events.length;
  }
}

// Singleton accessor. Routes call this; tests can construct their own.
let _instance = null;
function getStore(opts) {
  if (_instance) return _instance;
  _instance = new SecurityEventStore(opts);
  return _instance;
}

module.exports = { SecurityEventStore, getStore, VALID_SOURCE_TYPES, VALID_SEVERITIES, VALID_OUTCOMES };