// Copyright (c) 2026 defconxt. All rights reserved.
// Licensed under AGPL-3.0 - see LICENSE file for details.
// NERF is a trademark of defconxt.

/**
 * dispatch.js: Auto-mode state machine for NERF.
 *
 * Reads .nerf/, determines the next unit of work, creates a fresh agent
 * session, injects context, dispatches, and loops. Each task gets a clean
 * context window with only relevant artifacts pre-loaded.
 *
 * Features:
 * - Fresh session per task (no accumulated garbage)
 * - Context pre-loading (plans, summaries, decisions, knowledge)
 * - Lock file tracking for crash recovery
 * - Stuck detection (sliding window on repeated dispatches)
 * - Budget enforcement (stops when ceiling reached)
 * - Verification enforcement after task execution
 *
 * @module execution-engine/dispatch
 */

import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, readdirSync, openSync, closeSync } from 'node:fs';
import { compressChunks, estimateTokens } from '../memory/trajectory.js';
import { join } from 'node:path';
import {
  nerfDir,
  readState,
  writeState,
  engagementCost,
  completeTask,
  trackCost,
} from './engagement.js';
import { loadConfig } from '../config.js';
import { isTransientError } from '../gateway/retry-client.js';

const debug = process.env.NERF_DEBUG === '1'
  ? (/** @type {string} */ msg) => process.stderr.write(`[dispatch] ${msg}\n`)
  : () => {};

// ---------------------------------------------------------------------------
// Agent mode inference
// ---------------------------------------------------------------------------

/**
 * Map engagement types to their appropriate NERF agent mode.
 * Previously the auto-loop always dispatched RESEARCHER or ARCHITECT —
 * this infers the correct mode from the engagement type stored in ENGAGEMENT.md.
 *
 * @param {string} phase - Current engagement phase
 * @param {string} eid - Engagement ID
 * @param {string} [root=process.cwd()]
 * @returns {string} Agent mode name (RED, BLUE, INCIDENT, BUILD, ARCHITECT, RESEARCHER, ...)
 */
function inferAgentMode(phase, eid, root = process.cwd()) {
  if (phase === 'research') return 'RESEARCHER';

  // Read engagement type from ENGAGEMENT.md
  try {
    const engPath = join(nerfDir(root), 'ENGAGEMENT.md');
    if (existsSync(engPath)) {
      const content = readFileSync(engPath, 'utf-8');
      // TYPE field written by createEngagement(): pentest|ir|audit|build|review
      const typeMatch = content.match(/\*\*Type:\*\*\s*(\S+)/i) ||
                        content.match(/^type:\s*(\S+)/im);
      const engType = typeMatch?.[1]?.toLowerCase();
      if (engType) {
        const TYPE_TO_MODE = {
          pentest: 'RED',
          'red-team': 'RED',
          red: 'RED',
          ir: 'INCIDENT',
          incident: 'INCIDENT',
          forensics: 'INCIDENT',
          audit: 'ARCHITECT',
          review: 'ARCHITECT',
          build: 'BUILD',
          devsecops: 'BUILD',
          osint: 'RECON',
          recon: 'RECON',
          blue: 'BLUE',
          detection: 'BLUE',
          purple: 'PURPLE',
        };
        const mode = TYPE_TO_MODE[engType];
        if (mode) return mode;
      }
    }
  } catch {
    /* fall through to default */
  }

  return 'ARCHITECT';
}

// ---------------------------------------------------------------------------
// Lock file (crash recovery)
// ---------------------------------------------------------------------------

/**
 * @param {string} [root=process.cwd()]
 * @returns {string}
 */
function lockPath(root = process.cwd()) {
  return join(nerfDir(root), 'auto.lock');
}

/**
 * Acquire the auto-mode lock. Writes current unit info.
 * @param {Object} unit - { eid, cid, tid, phase }
 * @param {string} [root=process.cwd()]
 * @returns {boolean} true if acquired, false if already locked
 */
export function acquireLock(unit, root = process.cwd()) {
  const lp = lockPath(root);
  // Use O_CREAT|O_EXCL for an atomic create-exclusive operation.
  // This eliminates the TOCTOU race between existsSync and writeFileSync:
  // only one process can succeed the open() — the OS guarantees exclusivity.
  // CWE-367: TOCTOU Race Condition prevention.
  try {
    const fd = openSync(lp, 'wx'); // 'wx' = O_WRONLY|O_CREAT|O_EXCL
    try {
      writeFileSync(fd, JSON.stringify({ ...unit, acquired: new Date().toISOString() }), 'utf-8');
    } finally {
      closeSync(fd);
    }
    return true;
  } catch (err) {
    if (err.code === 'EEXIST') {
      try {
        const existing = JSON.parse(readFileSync(lp, 'utf-8'));
        debug(`Lock exists: ${JSON.stringify(existing)}`);
      } catch {
        debug('Lock file corrupted, treating as stale');
      }
      return false;
    }
    throw err; // unexpected filesystem error — propagate
  }
}

/**
 * Release the auto-mode lock.
 * @param {string} [root=process.cwd()]
 */
export function releaseLock(root = process.cwd()) {
  const lp = lockPath(root);
  if (existsSync(lp)) unlinkSync(lp);
}

/**
 * Read the lock file for crash recovery.
 * @param {string} [root=process.cwd()]
 * @returns {Object|null}
 */
export function readLock(root = process.cwd()) {
  const lp = lockPath(root);
  if (!existsSync(lp)) return null;
  try {
    return JSON.parse(readFileSync(lp, 'utf-8'));
  } catch {
    return null;
  }
}

// ---------------------------------------------------------------------------
// Stuck detection
// ---------------------------------------------------------------------------

/** @type {string[]} */
const dispatchHistory = [];
const MAX_HISTORY = 10;
const STUCK_THRESHOLD = 3;

/**
 * Check if the state machine is stuck (same unit dispatched repeatedly).
 * @param {string} unitKey - e.g. "E001/C01/T01"
 * @returns {boolean}
 */
export function isStuck(unitKey) {
  dispatchHistory.push(unitKey);
  if (dispatchHistory.length > MAX_HISTORY) dispatchHistory.shift();

  // Count occurrences of this key in recent history
  const count = dispatchHistory.filter(k => k === unitKey).length;
  return count >= STUCK_THRESHOLD;
}

/** Reset stuck detection state (for testing). */
export function _resetStuckDetection() {
  dispatchHistory.length = 0;
}

// ---------------------------------------------------------------------------
// Context builder
// ---------------------------------------------------------------------------

/**
 * Build the context payload for a task dispatch.
 * Reads relevant artifacts from .nerf/ and pre-loads them.
 *
 * @param {Object} params
 * @param {string} params.eid
 * @param {string} params.cid
 * @param {string} params.tid
 * @param {string} [root=process.cwd()]
 * @returns {string} - Assembled context string for the LLM
 */
export function buildTaskContext({ eid, cid, tid }, root = process.cwd()) {
  const sections = [];
  const cd = nerfDir(root);

  // STATE.md
  const statePath = join(cd, 'STATE.md');
  if (existsSync(statePath)) {
    sections.push(`## Current State\n${readFileSync(statePath, 'utf-8')}`);
  }

  // ENGAGEMENT.md
  const engPath = join(cd, 'ENGAGEMENT.md');
  if (existsSync(engPath)) {
    sections.push(`## Engagement\n${readFileSync(engPath, 'utf-8').slice(0, 3000)}`);
  }

  // DECISIONS.md
  const decPath = join(cd, 'DECISIONS.md');
  if (existsSync(decPath)) {
    const content = readFileSync(decPath, 'utf-8');
    if (content.length > 50) sections.push(`## Decisions\n${content.slice(0, 2000)}`);
  }

  // KNOWLEDGE.md
  const knowPath = join(cd, 'KNOWLEDGE.md');
  if (existsSync(knowPath)) {
    const content = readFileSync(knowPath, 'utf-8');
    if (content.length > 50) sections.push(`## Knowledge\n${content.slice(0, 2000)}`);
  }

  // Campaign plan
  const planPath = join(cd, 'engagements', eid, 'campaigns', cid, `${cid}-PLAN.md`);
  if (existsSync(planPath)) {
    sections.push(`## Campaign Plan\n${readFileSync(planPath, 'utf-8')}`);
  }

  // Task plan
  const taskPlanPath = join(cd, 'engagements', eid, 'campaigns', cid, 'tasks', `${tid}-PLAN.md`);
  if (existsSync(taskPlanPath)) {
    sections.push(`## Task Plan\n${readFileSync(taskPlanPath, 'utf-8')}`);
  }

  // Prior task summaries in this campaign — HEAD+TAIL compressed.
  // Instead of uniform slice(-3) + hard char truncation, we protect the
  // first 2 (establishes campaign direction) and last 2 (most recent state),
  // and extractively summarize everything in between. This preserves coherence
  // across long campaigns (>9 tasks) without silently dropping early findings.
  const tasksDir = join(cd, 'engagements', eid, 'campaigns', cid, 'tasks');
  if (existsSync(tasksDir)) {
    const taskNum = (f) => parseInt(f.match(/^T(\d+)/)?.[1] ?? '0', 10);
    const currentNum = taskNum(tid + '-');
    const summaryFiles = readdirSync(tasksDir)
      .filter(f => f.endsWith('-SUMMARY.md') && taskNum(f) < currentNum)
      .sort((a, b) => taskNum(a) - taskNum(b));

    if (summaryFiles.length > 0) {
      const rawChunks = summaryFiles.map(sf => {
        const content = readFileSync(join(tasksDir, sf), 'utf-8');
        const label = sf.replace('-SUMMARY.md', '');
        return `## Prior Task: ${label}\n${content}`;
      });

      // HEAD+TAIL compress all summaries to fit a 4000-token budget.
      // HEAD: first 2 tasks (establish campaign scope/early decisions)
      // TAIL: last 2 tasks (most recent findings and direction)
      // MIDDLE: extractive summary of everything between
      const { chunks, compressed } = compressChunks(rawChunks, {
        targetTokens: 4000,
        headCount: 2,
        tailCount: 2,
        summaryTokens: 500,
        summaryLabel: '## Prior Tasks [summarized — middle tasks compressed to fit context]',
      });

      if (compressed) {
        debug(`buildTaskContext: compressed ${rawChunks.length} task summaries → ${chunks.length} sections`);
      }

      sections.push(...chunks);
    }
  }

  return sections.join('\n\n---\n\n');
}

// ---------------------------------------------------------------------------
// Verification
// ---------------------------------------------------------------------------

/**
 * Run verification commands after task execution.
 * @param {string[]} commands - Shell commands to run
 * @param {number} maxRetries
 * @param {boolean} autoFix
 * @param {string} [root=process.cwd()]
 * @returns {Promise<{ passed: boolean, results: Array<{ command: string, exitCode: number, output: string }> }>}
 */
export async function runVerification(commands, maxRetries = 2, autoFix = true, root = process.cwd()) {
  if (!commands || commands.length === 0) return { passed: true, results: [] };

  const { execSync } = await import('node:child_process');
  const results = [];

  for (const cmd of commands) {
    let attempts = 0;
    let passed = false;

    while (attempts <= maxRetries && !passed) {
      try {
        const output = execSync(cmd, {
          encoding: 'utf-8',
          cwd: root,
          timeout: 120000,
          maxBuffer: 2 * 1024 * 1024,
        });
        results.push({ command: cmd, exitCode: 0, output: output.slice(0, 2000) });
        passed = true;
      } catch (err) {
        attempts++;
        if (attempts > maxRetries || !autoFix) {
          results.push({
            command: cmd,
            exitCode: err.status || 1,
            output: (err.stderr || err.message || '').slice(0, 2000),
          });
        } else {
          debug(`Verification failed (attempt ${attempts}/${maxRetries}): ${cmd}`);
        }
      }
    }
  }

  const allPassed = results.every(r => r.exitCode === 0);
  return { passed: allPassed, results };
}

// ---------------------------------------------------------------------------
// Auto-mode state machine
// ---------------------------------------------------------------------------

/**
 * @typedef {Object} DispatchResult
 * @property {string} unitKey - e.g. "E001/C01/T01"
 * @property {boolean} success
 * @property {string} [error]
 * @property {number} [durationMs]
 * @property {Object} [cost]
 */

/**
 * Determine the next unit of work by reading .nerf/ state.
 *
 * Scans for the first incomplete task in the first incomplete campaign
 * of the active engagement.
 *
 * @param {string} [root=process.cwd()]
 * @returns {{ eid: string, cid: string, tid: string, phase: string } | null}
 */
export function determineNextUnit(root = process.cwd()) {
  const state = readState(root);
  if (!state.activeEngagement) return null;

  const eid = state.activeEngagement;
  const cd = nerfDir(root);

  // Scan campaigns
  const campaignsDir = join(cd, 'engagements', eid, 'campaigns');
  if (!existsSync(campaignsDir)) return null;

  const campaigns = readdirSync(campaignsDir)
    .filter(d => /^C\d{2}$/.test(d))
    .sort();

  for (const cid of campaigns) {
    const tDir = join(campaignsDir, cid, 'tasks');
    if (!existsSync(tDir)) continue;

    const plans = readdirSync(tDir)
      .filter(f => f.endsWith('-PLAN.md'))
      .sort();

    for (const plan of plans) {
      const tid = plan.replace('-PLAN.md', '');
      const summaryPath = join(tDir, `${tid}-SUMMARY.md`);
      if (!existsSync(summaryPath)) {
        return { eid, cid, tid, phase: 'executing' };
      }
    }
  }

  // All tasks complete
  return null;
}

/**
 * Run the auto-mode loop.
 *
 * Reads .nerf/, determines the next task, dispatches a fresh agent session,
 * and loops until all tasks are complete, budget is exceeded, or stuck.
 *
 * @param {Object} [opts={}]
 * @param {string} [opts.root=process.cwd()]
 * @param {number} [opts.maxIterations=50]
 * @param {AbortSignal} [opts.signal]
 * @param {Function} [opts.onDispatch] - Called before each dispatch with (unit)
 * @param {Function} [opts.onComplete] - Called after each task with (unit, result)
 * @param {Function} [opts.onStop] - Called when loop stops with (reason)
 * @returns {Promise<{ dispatched: number, completed: number, reason: string }>}
 */
export async function autoLoop(opts = {}) {
  const root = opts.root || process.cwd();
  const maxIterations = opts.maxIterations || 50;
  const config = loadConfig();
  const budgetCeiling = config.budget_ceiling || 50.0;
  const verificationCommands = config.verification_commands || [];
  const verificationAutoFix = config.verification_auto_fix !== false;
  const verificationMaxRetries = config.verification_max_retries || 2;
  const softTimeout = (config.auto_supervisor?.soft_timeout_minutes || 20) * 60 * 1000;
  const hardTimeout = (config.auto_supervisor?.hard_timeout_minutes || 30) * 60 * 1000;

  const startTime = Date.now();
  let dispatched = 0;
  let completed = 0;

  _resetStuckDetection();

  // Hoist imports outside the loop to avoid re-importing every iteration
  const { resolvePhaseModel, inferPhase } = await import('./phase-router.js');
  const { runAutonomous } = await import('./runner.js');

  debug(`Auto loop starting: budget=$${budgetCeiling}, maxIter=${maxIterations}`);

  for (let i = 0; i < maxIterations; i++) {
    // Check abort signal
    if (opts.signal?.aborted) {
      const reason = 'Aborted by signal';
      opts.onStop?.(reason);
      return { dispatched, completed, reason };
    }

    // Check hard timeout
    if (Date.now() - startTime > hardTimeout) {
      const reason = `Hard timeout (${config.auto_supervisor?.hard_timeout_minutes || 30}m)`;
      opts.onStop?.(reason);
      return { dispatched, completed, reason };
    }

    // Check soft timeout (warn but continue)
    if (Date.now() - startTime > softTimeout && dispatched > 0) {
      debug('Soft timeout reached, consider compacting');
    }

    // Check budget
    const state = readState(root);
    if (state.activeEngagement) {
      const cost = engagementCost(state.activeEngagement, root);
      if (cost.totalCost >= budgetCeiling) {
        const reason = `Budget ceiling ($${budgetCeiling}) reached: $${cost.totalCost.toFixed(4)}`;
        opts.onStop?.(reason);
        return { dispatched, completed, reason };
      }
    }

    // Determine next unit
    const unit = determineNextUnit(root);
    if (!unit) {
      const reason = 'All tasks complete';
      opts.onStop?.(reason);
      return { dispatched, completed, reason };
    }

    const unitKey = `${unit.eid}/${unit.cid}/${unit.tid}`;

    // Stuck detection
    if (isStuck(unitKey)) {
      const reason = `Stuck: ${unitKey} dispatched ${STUCK_THRESHOLD}+ times`;
      debug(reason);
      opts.onStop?.(reason);
      return { dispatched, completed, reason };
    }

    // Acquire lock
    if (!acquireLock(unit, root)) {
      // Lock exists from a previous crash. Read it for recovery context.
      const stale = readLock(root);
      debug(`Recovering from stale lock: ${JSON.stringify(stale)}`);
      releaseLock(root);
      if (!acquireLock(unit, root)) {
        const reason = 'Cannot acquire lock after recovery';
        opts.onStop?.(reason);
        return { dispatched, completed, reason };
      }
    }

    // Hoist variables needed by both try and catch (fallback retry)
    let context, planContent, taskTitle, phaseModel, startMs;

    try {
      opts.onDispatch?.(unit);
      dispatched++;

      // Build context
      context = buildTaskContext(unit, root);

      // Read task plan for title/description
      const cd = nerfDir(root);
      const planPath = join(cd, 'engagements', unit.eid, 'campaigns', unit.cid, 'tasks', `${unit.tid}-PLAN.md`);
      planContent = existsSync(planPath) ? readFileSync(planPath, 'utf-8') : '';
      const titleMatch = planContent.match(/^# \w+: (.+)/m);
      taskTitle = titleMatch ? titleMatch[1] : unit.tid;

      debug(`Dispatching: ${unitKey} "${taskTitle}"`);

      // Resolve phase-appropriate model
      const phase = inferPhase(state.phase);
      phaseModel = resolvePhaseModel(phase);
      debug(`Phase: ${phase}, model: ${phaseModel.provider || 'default'}/${phaseModel.model}`);

      // Dispatch to runner
      startMs = Date.now();

      // Infer agent mode from engagement type and phase.
      // Previously always dispatched RESEARCHER or ARCHITECT regardless of
      // engagement type — RED engagements now get RED agent, IR gets INCIDENT, etc.
      const agentMode = inferAgentMode(state.phase, unit.eid, root);
      debug(`Agent mode: ${agentMode}`);

      const result = await runAutonomous(
        agentMode,
        {
          task: taskTitle,
          context,
          description: planContent,
          pi_provider: phaseModel.provider || undefined,
          pi_model: phaseModel.model || undefined,
        },
      );

      const durationMs = Date.now() - startMs;

      // Track cost
      if (result.tokenUsage) {
        trackCost({
          unit: unitKey,
          model: result.model || 'unknown',
          inputTokens: result.tokenUsage.input || 0,
          outputTokens: result.tokenUsage.output || 0,
          cost: result.tokenUsage.cost || 0,
        }, root);
      }

      // Write task summary
      completeTask({
        eid: unit.eid,
        cid: unit.cid,
        tid: unit.tid,
        oneLiner: result.summary || taskTitle,
        narrative: result.output || result.rawOutput || 'No output captured.',
        verification: 'Auto-mode dispatch',
        keyFiles: result.files || [],
        findings: result.findings || [],
      }, root);

      // Run verification — a failing verification blocks task completion.
      // CWE-693: Protection Mechanism Failure prevention.
      if (verificationCommands.length > 0) {
        const vResult = await runVerification(
          verificationCommands,
          verificationMaxRetries,
          verificationAutoFix,
          root,
        );
        if (!vResult.passed) {
          const failSummary = vResult.results
            .filter(r => r.exitCode !== 0)
            .map(r => `${r.command}: exit ${r.exitCode}`)
            .join('; ');
          process.stderr.write(
            `[nerf-dispatch] Verification FAILED for ${unitKey} — ${failSummary}\n` +
            'Task marked failed. Auto-loop will not proceed to dependent tasks.\n',
          );
          opts.onComplete?.(unit, { success: false, durationMs, verificationFailed: true });
          // Do not increment completed — task is not done.
          continue;
        }
      }

      completed++;
      opts.onComplete?.(unit, { success: true, durationMs });

    } catch (err) {
      const errMsg = err.message || String(err);
      debug(`Dispatch error for ${unitKey}: ${errMsg}`);

      // Fallback retry on transient errors
      if (isTransientError(errMsg) && phaseModel.fallbacks && phaseModel.fallbacks.length > 0) {
        let retried = false;
        const maxFallbackRetries = 2;
        const fallbacks = phaseModel.fallbacks;

        for (let fi = 0; fi < Math.min(fallbacks.length, maxFallbackRetries); fi++) {
          const nextModel = fallbacks[fi];
          debug(`Provider error on ${phaseModel.model}: ${errMsg}. Retrying with fallback: ${nextModel}`);

          try {
            // Parse fallback model spec
            const parts = nextModel.split('/');
            const fbProvider = parts.length >= 2 ? parts[0] : undefined;
            const fbModel = parts.length >= 2 ? parts.slice(1).join('/') : nextModel;

            const retryResult = await runAutonomous(
              agentMode,
              {
                task: taskTitle,
                context,
                description: planContent,
                pi_provider: fbProvider,
                pi_model: fbModel,
              },
            );

            const durationMs = Date.now() - startMs;

            if (retryResult.tokenUsage) {
              trackCost({
                unit: unitKey,
                model: retryResult.model || nextModel,
                inputTokens: retryResult.tokenUsage.input || 0,
                outputTokens: retryResult.tokenUsage.output || 0,
                cost: retryResult.tokenUsage.cost || 0,
              }, root);
            }

            completeTask({
              eid: unit.eid,
              cid: unit.cid,
              tid: unit.tid,
              oneLiner: retryResult.summary || taskTitle,
              narrative: retryResult.output || retryResult.rawOutput || 'No output captured.',
              verification: `Auto-mode dispatch (fallback: ${nextModel})`,
              keyFiles: retryResult.files || [],
              findings: retryResult.findings || [],
            }, root);

            if (verificationCommands.length > 0) {
              const vResult = await runVerification(
                verificationCommands,
                verificationMaxRetries,
                verificationAutoFix,
                root,
              );
              if (!vResult.passed) {
                debug(`Verification failed for ${unitKey} (fallback): ${JSON.stringify(vResult.results)}`);
              }
            }

            completed++;
            opts.onComplete?.(unit, { success: true, durationMs, fallbackModel: nextModel });
            retried = true;
            break;
          } catch (fbErr) {
            debug(`Fallback ${nextModel} also failed: ${fbErr.message}`);
          }
        }

        if (!retried) {
          debug(`All fallbacks exhausted for ${unitKey}`);
          opts.onComplete?.(unit, { success: false, error: errMsg });
        }
      } else {
        opts.onComplete?.(unit, { success: false, error: errMsg });
      }
    } finally {
      releaseLock(root);
    }
  }

  const reason = `Max iterations (${maxIterations}) reached`;
  opts.onStop?.(reason);
  return { dispatched, completed, reason };
}
