'use strict';
const EventEmitter = require('events');

let _instance = null;

/** Default cascade check: true if any numeric value in result is > 0 */
function _defaultCascadeCheck(result) {
  for (const v of Object.values(result)) {
    if (typeof v === 'number' && v > 0) return true;
  }
  return false;
}

function _retryDelayFromResult(result) {
  if (!result || typeof result !== 'object') return null;
  const raw = result.retry_after_ms ?? result.retryAfterMs ?? result.next_delay_ms ?? result.nextDelayMs;
  if (raw === undefined || raw === null || raw === '') return null;
  const delay = Number(raw);
  return Number.isFinite(delay) && delay >= 0 ? delay : null;
}

class Scheduler extends EventEmitter {
  /**
   * @param {Object} opts
   * @param {number} [opts.tickMs=5000]
   * @param {Object} [opts.pools] - Resource pool limits: { llm: 2, ollama: 1, ... }
   * @param {number} [opts.shutdownTimeoutMs=5000]
   */
  constructor(opts = {}) {
    super();
    this._tickMs = opts.tickMs || 5000;
    this._shutdownTimeoutMs = opts.shutdownTimeoutMs || 5000;
    this._shuttingDown = false;
    this._tickTimer = null;
    this._startedAt = null;

    this._poolLimits = {
      llm: 2,
      ollama: 1,
      embedding: 1,
      io: 3,
      ...opts.pools,
    };

    // Map<string, { descriptor, state, runPromise, _wakeTrigger, _lastWakeAt, _epoch }>
    this._jobs = new Map();

    // Track active jobs per pool: Map<string, Set<string>>
    this._poolActive = new Map();
    for (const pool of Object.keys(this._poolLimits)) {
      this._poolActive.set(pool, new Set());
    }
  }

  static getInstance() { return _instance; }
  static setInstance(s) { _instance = s; }

  /**
   * Register a job.
   * @param {Object} desc
   * @param {string}   desc.name
   * @param {Function} desc.run          - async ({ trigger }) => result
   * @param {number}   desc.intervalMs
   * @param {string}   [desc.pool]       - Resource pool name
   * @param {number}   [desc.priority=5] - 1 (highest) to 10 (lowest)
   * @param {string[]} [desc.dependsOn]  - Jobs that must complete once before this runs
   * @param {string[]} [desc.cascadeFrom] - Jobs whose results trigger immediate eligibility
   * @param {number}   [desc.startDelayMs=0]
   * @param {number}   [desc.timeoutMs]  - Watchdog timeout
   * @param {number}   [desc.debounceMs] - Min gap between event-driven wakes
   * @param {boolean}  [desc.enabled=true]
   * @param {Function} [desc.shouldRun]  - () => boolean guard
   * @param {Function} [desc.onResult]   - (result) => void
   * @param {Function} [desc.onError]    - (err) => void
   * @returns {Scheduler}
   */
  register(desc) {
    if (!desc.name || !desc.run || !desc.intervalMs) {
      throw new Error('Job requires name, run, and intervalMs');
    }
    // Clone to avoid mutating caller's object
    desc = { priority: 5, enabled: true, ...desc };
    // Auto-tag the run function so the event-loop lag detector reports the
    // job name when a block fires inside it. perf-tracker is a tiny shared
    // module — see lib/perf-tracker.js. Wrapping is idempotent since the
    // wrapper preserves the prev tag and restores it in finally.
    try {
      const perfTracker = require('./perf-tracker');
      desc.run = perfTracker.wrapAsync(`scheduler:${desc.name}`, desc.run);
    } catch { /* perf-tracker optional */ }

    const now = Date.now();
    this._jobs.set(desc.name, {
      descriptor: desc,
      state: {
        status: 'idle',
        lastRunAt: null,
        lastFinishedAt: null,
        lastDurationMs: null,
        lastResult: null,
        lastError: null,
        runCount: 0,
        errorCount: 0,
        nextEligibleAt: now + (desc.startDelayMs || 0),
        completedOnce: false,
      },
      runPromise: null,
      _wakeTrigger: null,
      _lastWakeAt: 0,
      _epoch: 0, // incremented on force-reset; stale promises check this
    });

    // Ensure pool tracking exists for custom pools
    if (desc.pool && !this._poolActive.has(desc.pool)) {
      this._poolActive.set(desc.pool, new Set());
      if (this._poolLimits[desc.pool] === undefined) this._poolLimits[desc.pool] = 1;
    }

    return this;
  }

  unregister(name) {
    this._jobs.delete(name);
    // Clean up pool tracking
    for (const [, active] of this._poolActive) {
      active.delete(name);
    }
  }

  start() {
    if (this._tickTimer) return;
    this._startedAt = Date.now();
    this._tickTimer = setInterval(() => this._tick(), this._tickMs);
  }

  wake(name, reason = 'manual') {
    const job = this._jobs.get(name);
    if (!job || this._shuttingDown) return;

    if (job.descriptor.debounceMs) {
      if (Date.now() - job._lastWakeAt < job.descriptor.debounceMs) return;
      job._lastWakeAt = Date.now();
    }

    if (job.state.status === 'running') return;

    job._wakeTrigger = reason;
    job.state.nextEligibleAt = Date.now();
  }

  pause(name) {
    const job = this._jobs.get(name);
    if (job) job.descriptor.enabled = false;
  }

  resume(name) {
    const job = this._jobs.get(name);
    if (job) job.descriptor.enabled = true;
  }

  hasJob(name) {
    return this._jobs.has(name);
  }

  updateInterval(name, newMs) {
    const entry = this._jobs.get(name);
    if (!entry) return false;
    entry.descriptor.intervalMs = newMs;
    return true;
  }

  getState(name) {
    if (name) {
      const job = this._jobs.get(name);
      if (!job) return null;
      return this._stateSnapshot(job);
    }
    const result = {};
    for (const [n, job] of this._jobs) {
      result[n] = this._stateSnapshot(job);
    }
    return result;
  }

  _stateSnapshot(job) {
    const { lastResult, ...rest } = job.state; // exclude lastResult — can be large
    return { ...rest, ...this._descriptorInfo(job.descriptor) };
  }

  _descriptorInfo(desc) {
    return {
      enabled: desc.enabled,
      pool: desc.pool || null,
      priority: desc.priority,
      intervalMs: desc.intervalMs,
    };
  }

  getPoolState() {
    const result = {};
    for (const [pool, active] of this._poolActive) {
      const queued = [];
      for (const [name, job] of this._jobs) {
        if (job.descriptor.pool === pool && job.state.status === 'queued') {
          queued.push(name);
        }
      }
      result[pool] = {
        active: [...active],
        max: this._poolLimits[pool],
        queued,
      };
    }
    return result;
  }

  uptime() {
    return this._startedAt ? Date.now() - this._startedAt : 0;
  }

  async shutdown() {
    if (this._shuttingDown) return;
    this._shuttingDown = true;
    this.emit('shuttingDown');

    if (this._tickTimer) {
      clearInterval(this._tickTimer);
      this._tickTimer = null;
    }

    // Reset queued jobs
    for (const [, job] of this._jobs) {
      if (job.state.status === 'queued') {
        job.state.status = 'idle';
      }
    }

    // Wait for in-flight jobs
    const inFlight = [];
    for (const [name, job] of this._jobs) {
      if (job.state.status === 'running' && job.runPromise) {
        inFlight.push(name);
      }
    }

    if (inFlight.length > 0) {
      const deadline = Date.now() + this._shutdownTimeoutMs;
      while (Date.now() < deadline) {
        const stillRunning = inFlight.filter(n => this._jobs.get(n)?.state.status === 'running');
        if (stillRunning.length === 0) break;
        await new Promise(r => setTimeout(r, 100));
      }
      const stillRunning = inFlight.filter(n => this._jobs.get(n)?.state.status === 'running');
      if (stillRunning.length > 0) {
        console.warn(`[scheduler] Force-closing with ${stillRunning.length} job(s) still running: ${stillRunning.join(', ')}`);
      }
    }

    this.emit('shutdown');
  }

  _tick() {
    if (this._shuttingDown) return;

    try {
      this._tickInner();
    } catch (err) {
      // Log and recover — don't let a single bad tick kill the scheduler loop.
      // setInterval keeps firing, so the next tick will retry.
      console.error('[scheduler] Tick error (recovering):', err);
      this.emit('tickError', err);
    }
  }

  _tickInner() {
    const now = Date.now();

    // 0. Reset queued→idle so they're reconsidered this tick
    for (const [, job] of this._jobs) {
      if (job.state.status === 'queued') job.state.status = 'idle';
    }

    // 1. Watchdog: check for stuck jobs
    for (const [name, job] of this._jobs) {
      if (job.state.status === 'running' && job.descriptor.timeoutMs && job.state.lastRunAt) {
        if (now - job.state.lastRunAt > job.descriptor.timeoutMs) {
          console.error(`[scheduler] Job "${name}" stuck for ${now - job.state.lastRunAt}ms, force-resetting`);
          this._forceReset(name);
        }
      }
    }

    // 2. Collect due candidates (idle, past nextEligibleAt, dependencies met, guard passes)
    const candidates = [];
    for (const [, job] of this._jobs) {
      if (job.state.status !== 'idle') continue;
      if (!job.descriptor.enabled) continue;
      if (job.state.nextEligibleAt && now < job.state.nextEligibleAt) continue;
      if (!this._dependenciesMet(job.descriptor)) continue;
      if (job.descriptor.shouldRun && !job.descriptor.shouldRun()) continue;
      candidates.push(job);
    }

    // 3. Sort by priority (lower = higher priority)
    candidates.sort((a, b) => a.descriptor.priority - b.descriptor.priority);

    // 4. Dispatch respecting pool limits + a per-tick cap on heavy jobs.
    // Jobs flagged `heavy: true` each run a long synchronous DB/fs/parse span
    // that blocks the event loop. Even within the io pool's concurrency limit,
    // letting several start on the same tick stacks those spans back-to-back and
    // produces the multi-second stalls. Cap heavy dispatch to ONE per tick
    // (others queue and are reconsidered next tick, 5s later) so their blocking
    // spans spread out instead of piling up. Non-heavy jobs are unaffected.
    let heavyDispatchedThisTick = this._countActiveHeavyJobs();
    for (const job of candidates) {
      const pool = job.descriptor.pool;
      if (pool && !this._poolHasCapacity(pool)) {
        job.state.status = 'queued';
        continue;
      }
      if (job.descriptor.heavy && heavyDispatchedThisTick >= 1) {
        job.state.status = 'queued';
        continue;
      }
      if (job.descriptor.heavy) heavyDispatchedThisTick++;
      this._dispatch(job);
    }
  }

  _countActiveHeavyJobs() {
    let n = 0;
    for (const [, job] of this._jobs) {
      if (job.descriptor.heavy && job.state.status === 'running') n++;
    }
    return n;
  }

  _poolHasCapacity(pool) {
    const active = this._poolActive.get(pool);
    const limit = this._poolLimits[pool];
    if (!active || limit === undefined) return true;
    return active.size < limit;
  }

  _dependenciesMet(descriptor) {
    if (!descriptor.dependsOn || descriptor.dependsOn.length === 0) return true;
    return descriptor.dependsOn.every(dep => {
      const depJob = this._jobs.get(dep);
      return depJob && depJob.state.completedOnce;
    });
  }

  _dispatch(job) {
    const { name, pool, run, onResult, onError } = job.descriptor;
    job.state.status = 'running';
    job.state.lastRunAt = Date.now();
    const epoch = job._epoch; // capture epoch to detect force-reset

    if (pool) this._poolActive.get(pool)?.add(name);

    job.runPromise = (async () => {
      let retryDelayMs = null;
      try {
        const result = await run({ trigger: job._wakeTrigger || 'interval' });
        // If force-reset happened while we were running, this promise is stale — bail
        if (job._epoch !== epoch) return;
        job._wakeTrigger = null;
        job.state.lastResult = result;
        retryDelayMs = _retryDelayFromResult(result);
        job.state.lastError = null;
        job.state.runCount++;
        job.state.completedOnce = true;

        if (onResult) onResult(result);
        this.emit('jobComplete', name, result);

        // Self-stopping
        if (result && result._done) {
          this.unregister(name);
          return;
        }

        this._checkCascades(name, result);
      } catch (err) {
        if (job._epoch !== epoch) return; // stale after force-reset
        job.state.lastError = err.message;
        job.state.errorCount++;
        if (onError) onError(err);
        this.emit('jobError', name, err);
      } finally {
        if (job._epoch !== epoch) return; // stale — _forceReset already cleaned up
        // Job may have been unregistered by self-stop
        if (this._jobs.has(name)) {
          job.state.status = 'idle';
          job.state.lastFinishedAt = Date.now();
          job.state.lastDurationMs = job.state.lastFinishedAt - job.state.lastRunAt;
          job.state.nextEligibleAt = job.state.lastFinishedAt + (retryDelayMs !== null ? retryDelayMs : job.descriptor.intervalMs);
          job.runPromise = null;
        }
        if (pool) this._poolActive.get(pool)?.delete(name);
      }
    })();
  }

  _checkCascades(completedJobName, result) {
    if (!result || typeof result !== 'object') return;

    for (const [, job] of this._jobs) {
      if (!job.descriptor.cascadeFrom?.includes(completedJobName)) continue;
      // Use job's custom cascadeCheck if provided, otherwise check any numeric value > 0
      const check = job.descriptor.cascadeCheck || _defaultCascadeCheck;
      if (check(result)) {
        job.state.nextEligibleAt = Date.now();
      }
    }
  }

  _forceReset(name) {
    const job = this._jobs.get(name);
    if (!job) return;
    job._epoch++; // invalidate the orphaned promise so its finally block no-ops
    job.state.status = 'idle';
    job.state.errorCount++;
    job.state.lastError = 'watchdog timeout';
    job.state.lastFinishedAt = Date.now();
    if (job.state.lastRunAt) {
      job.state.lastDurationMs = job.state.lastFinishedAt - job.state.lastRunAt;
    }
    job.state.nextEligibleAt = Date.now() + job.descriptor.intervalMs;
    job.runPromise = null;
    if (job.descriptor.pool) {
      this._poolActive.get(job.descriptor.pool)?.delete(name);
    }
    this.emit('jobTimeout', name);
  }
}

module.exports = { Scheduler };
