/**
 * Jail Runner for Clawdie
 * Spawns agent execution in FreeBSD jails and handles IPC
 */
import { ChildProcess, exec, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';

import {
  AGENT_ENGINE,
  DATA_DIR,
  GROUPS_DIR,
  IDLE_TIMEOUT,
  JAIL_MAX_OUTPUT_SIZE,
  JAIL_TIMEOUT,
  PI_TUI_BIN,
  PI_TUI_MODEL,
  PI_TUI_PROVIDER,
} from './config.js';
import { readEnvFile } from './env.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { markWardenRunFinished, markWardenRunStarted } from './health.js';
import { logger } from './logger.js';
import { readonlyMountArgs, stopJail } from './jail-ops.js';
import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup } from './types.js';

// Sentinel markers for robust output parsing (must match agent-runner)
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';

// Jail to execute agents in, and the agent runner entrypoint inside the jail
const JAIL_NAME = process.env.JAIL_NAME || 'controlplane';
const AGENT_RUNNER_BIN =
  process.env.AGENT_RUNNER_BIN ||
  path.join(process.cwd(), 'jail', 'agent-runner', 'dist', 'index.js');

export interface JailInput {
  prompt: string;
  sessionId?: string;
  groupFolder: string;
  chatJid: string;
  isMain: boolean;
  isScheduledTask?: boolean;
  assistantName?: string;
  secrets?: Record<string, string>;
}

export interface JailOutput {
  status: 'success' | 'error';
  result: string | null;
  newSessionId?: string;
  error?: string;
}

interface JailMount {
  hostPath: string;
  jailPath: string;
  readonly: boolean;
}

function buildJailMounts(group: RegisteredGroup, isMain: boolean): JailMount[] {
  const mounts: JailMount[] = [];
  const projectRoot = process.cwd();
  const groupDir = resolveGroupFolderPath(group.folder);

  if (isMain) {
    // Main gets the project root read-only. Writable paths the agent needs
    // (group folder, IPC, .agent/) are mounted separately below.
    // Read-only prevents the agent from modifying host application code
    // (src/, dist/, package.json, etc.) which would bypass the sandbox
    // entirely on next restart.
    mounts.push({
      hostPath: projectRoot,
      jailPath: '/workspace/project',
      readonly: true,
    });

    // Main also gets its group folder as the working directory
    mounts.push({
      hostPath: groupDir,
      jailPath: '/workspace/group',
      readonly: false,
    });
  } else {
    // Other groups only get their own folder
    mounts.push({
      hostPath: groupDir,
      jailPath: '/workspace/group',
      readonly: false,
    });

    // Global memory directory (read-only for non-main)
    // Only directory mounts are supported, not file mounts
    const globalDir = path.join(GROUPS_DIR, 'global');
    if (fs.existsSync(globalDir)) {
      mounts.push({
        hostPath: globalDir,
        jailPath: '/workspace/global',
        readonly: true,
      });
    }
  }

  // Per-group agent sessions directory (isolated from other groups)
  // Each group gets their own .agent/ to prevent cross-group session access
  const groupSessionsDir = path.join(
    DATA_DIR,
    'sessions',
    group.folder,
    '.agent',
  );
  fs.mkdirSync(groupSessionsDir, { recursive: true });
  const settingsFile = path.join(groupSessionsDir, 'settings.json');
  if (!fs.existsSync(settingsFile)) {
    fs.writeFileSync(
      settingsFile,
      JSON.stringify(
        {
          env: {
            // Enable agent swarms (subagent orchestration)
            AGENT_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
            // Load AGENTS.md from additional mounted directories
            AGENT_CODE_ADDITIONAL_DIRECTORIES_AGENT_MD: '1',
            // Enable agent's memory feature (persists user preferences between sessions)
            AGENT_CODE_DISABLE_AUTO_MEMORY: '0',
          },
        },
        null,
        2,
      ) + '\n',
    );
  }

  // Sync skills from jail/skills/ into each group's .agent/skills/
  const skillsSrc = path.join(process.cwd(), 'jail', 'skills');
  const skillsDst = path.join(groupSessionsDir, 'skills');
  if (fs.existsSync(skillsSrc)) {
    for (const skillDir of fs.readdirSync(skillsSrc)) {
      const srcDir = path.join(skillsSrc, skillDir);
      if (!fs.statSync(srcDir).isDirectory()) continue;
      const dstDir = path.join(skillsDst, skillDir);
      fs.cpSync(srcDir, dstDir, { recursive: true });
    }
  }
  mounts.push({
    hostPath: groupSessionsDir,
    jailPath: '/home/node/.agent',
    readonly: false,
  });

  // Per-group IPC namespace: each group gets its own IPC directory
  // This prevents cross-group privilege escalation via IPC
  const groupIpcDir = resolveGroupIpcPath(group.folder);
  fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
  fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
  fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
  mounts.push({
    hostPath: groupIpcDir,
    jailPath: '/workspace/ipc',
    readonly: false,
  });

  // Copy jail agent-runner source into a per-group writable location so agents
  // can customize it (add tools, change behavior) without affecting other
  // groups. Recompiled on jail startup via entrypoint.sh.
  const agentRunnerSrc = path.join(
    projectRoot,
    'jail',
    'agent-runner',
    'src',
  );
  const groupAgentRunnerDir = path.join(
    DATA_DIR,
    'sessions',
    group.folder,
    'agent-runner-src',
  );
  if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
    fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
  }
  mounts.push({
    hostPath: groupAgentRunnerDir,
    jailPath: '/app/src',
    readonly: false,
  });

  // Additional mounts validated against external allowlist (tamper-proof from jails)
  const jailConfig = group.jailConfig;
  if (jailConfig?.additionalMounts) {
    const validatedMounts = validateAdditionalMounts(
      jailConfig.additionalMounts,
      group.name,
      isMain,
    );
    mounts.push(
      ...validatedMounts.map((m) => ({
        hostPath: m.hostPath,
        jailPath: m.jailPath,
        readonly: m.readonly,
      })),
    );
  }

  return mounts;
}

/**
 * Read allowed secrets from .env for passing to the jail via stdin.
 * Secrets are never written to disk or mounted as files.
 */
function readSecrets(): Record<string, string> {
  return readEnvFile([
    'ANTHROPIC_API_KEY',
    'OPENAI_API_KEY',
    'AZURE_OPENAI_API_KEY',
    'GEMINI_API_KEY',
    'GROQ_API_KEY',
    'CEREBRAS_API_KEY',
    'XAI_API_KEY',
    'OPENROUTER_API_KEY',
    'AI_GATEWAY_API_KEY',
    'ZAI_API_KEY',
    'MISTRAL_API_KEY',
    'MINIMAX_API_KEY',
    'OPENCODE_API_KEY',
    'KIMI_API_KEY',
    'AWS_PROFILE',
    'AWS_ACCESS_KEY_ID',
    'AWS_SECRET_ACCESS_KEY',
    'AWS_BEARER_TOKEN_BEDROCK',
    'AWS_REGION',
  ]);
}

/** Returns jexec args to run the agent inside the jail. */
function buildJailArgs(): string[] {
  return [JAIL_NAME, 'node', AGENT_RUNNER_BIN];
}

export async function runJailAgent(
  group: RegisteredGroup,
  input: JailInput,
  onProcess: (proc: ChildProcess, jailRunId: string) => void,
  onOutput?: (output: JailOutput) => Promise<void>,
): Promise<JailOutput> {
  const startTime = Date.now();

  const groupDir = resolveGroupFolderPath(group.folder);
  fs.mkdirSync(groupDir, { recursive: true });

  const mounts = buildJailMounts(group, input.isMain);
  const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
  const jailRunId = `clawdie-cp-${safeName}-${Date.now()}`;
  const jailArgs = buildJailArgs();

  logger.debug(
    {
      group: group.name,
      jailRunId,
      mounts: mounts.map(
        (m) => `${m.hostPath} -> ${m.jailPath}${m.readonly ? ' (ro)' : ''}`,
      ),
      jailArgs: jailArgs.join(' '),
    },
    'Jail mount configuration',
  );

  logger.info(
    {
      group: group.name,
      jailRunId,
      mountCount: mounts.length,
      isMain: input.isMain,
    },
    'Spawning jail agent',
  );

  const logsDir = path.join(groupDir, 'logs');
  fs.mkdirSync(logsDir, { recursive: true });

  return new Promise((resolve) => {
    let settled = false;
    const resolveOnce = (output: JailOutput) => {
      if (settled) return;
      settled = true;
      resolve(output);
    };

    const jailProc = spawn('jexec', jailArgs, {
      env: {
        ...process.env,
        AGENT_ENGINE,
        ...(PI_TUI_BIN ? { PI_TUI_BIN } : {}),
        ...(PI_TUI_PROVIDER ? { PI_TUI_PROVIDER } : {}),
        ...(PI_TUI_MODEL ? { PI_TUI_MODEL } : {}),
      },
      stdio: ['pipe', 'pipe', 'pipe'],
    });

    markWardenRunStarted(group.folder, input.chatJid, jailRunId);
    onProcess(jailProc, jailRunId);

    let stdout = '';
    let stderr = '';
    let stdoutTruncated = false;
    let stderrTruncated = false;

    // Pass secrets via stdin (never written to disk or mounted as files)
    input.secrets = readSecrets();
    const payload = JSON.stringify(input);
    // Remove secrets from input so they don't appear in logs
    delete input.secrets;

    jailProc.stdin.on('error', (err) => {
      logger.error(
        { group: group.name, jailRunId, error: err },
        'Jail stdin error',
      );
      markWardenRunFinished(group.folder, input.chatJid, jailRunId, {
        success: false,
        durationMs: Date.now() - startTime,
        errorCode: 'warden_stdin_failed',
        errorMessage: err.message,
        exitCode: null,
      });
      resolveOnce({
        status: 'error',
        result: null,
        error: `Jail stdin error: ${err.message}`,
      });
    });

    try {
      jailProc.stdin.end(payload);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      logger.error(
        { group: group.name, jailRunId, error: err },
        'Failed to write input to jail stdin',
      );
      markWardenRunFinished(group.folder, input.chatJid, jailRunId, {
        success: false,
        durationMs: Date.now() - startTime,
        errorCode: 'warden_stdin_failed',
        errorMessage: message,
        exitCode: null,
      });
      resolveOnce({
        status: 'error',
        result: null,
        error: `Jail stdin error: ${message}`,
      });
      return;
    }

    // Streaming output: parse OUTPUT_START/END marker pairs as they arrive
    let parseBuffer = '';
    let newSessionId: string | undefined;
    let outputChain = Promise.resolve();

    jailProc.stdout.on('data', (data) => {
      const chunk = data.toString();

      // Always accumulate for logging
      if (!stdoutTruncated) {
        const remaining = JAIL_MAX_OUTPUT_SIZE - stdout.length;
        if (chunk.length > remaining) {
          stdout += chunk.slice(0, remaining);
          stdoutTruncated = true;
          logger.warn(
            { group: group.name, size: stdout.length },
            'Jail stdout truncated due to size limit',
          );
        } else {
          stdout += chunk;
        }
      }

      // Stream-parse for output markers
      if (onOutput) {
        parseBuffer += chunk;
        let startIdx: number;
        while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
          const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
          if (endIdx === -1) break; // Incomplete pair, wait for more data

          const jsonStr = parseBuffer
            .slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
            .trim();
          parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);

          try {
            const parsed: JailOutput = JSON.parse(jsonStr);
            if (parsed.newSessionId) {
              newSessionId = parsed.newSessionId;
            }
            hadStreamingOutput = true;
            // Activity detected — reset the hard timeout
            resetTimeout();
            // Call onOutput for all markers (including null results)
            // so idle timers start even for "silent" query completions.
            outputChain = outputChain.then(() => onOutput(parsed));
          } catch (err) {
            logger.warn(
              { group: group.name, error: err },
              'Failed to parse streamed output chunk',
            );
          }
        }
      }
    });

    jailProc.stderr.on('data', (data) => {
      const chunk = data.toString();
      const lines = chunk.trim().split('\n');
      for (const line of lines) {
        if (line) logger.debug({ jail: group.folder }, line);
      }
      // Don't reset timeout on stderr — SDK writes debug logs continuously.
      // Timeout only resets on actual output (OUTPUT_MARKER in stdout).
      if (stderrTruncated) return;
      const remaining = JAIL_MAX_OUTPUT_SIZE - stderr.length;
      if (chunk.length > remaining) {
        stderr += chunk.slice(0, remaining);
        stderrTruncated = true;
        logger.warn(
          { group: group.name, size: stderr.length },
          'Jail stderr truncated due to size limit',
        );
      } else {
        stderr += chunk;
      }
    });

    let timedOut = false;
    let hadStreamingOutput = false;
    const jailConfig = group.jailConfig;
    const configTimeout = jailConfig?.timeout || JAIL_TIMEOUT;
    // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
    // graceful _close sentinel has time to trigger before the hard kill fires.
    const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);

    const killOnTimeout = () => {
      timedOut = true;
      logger.error(
        { group: group.name, jailRunId },
        'Jail timeout, stopping gracefully',
      );
      exec(stopJail(jailRunId), { timeout: 15000 }, (err) => {
        if (err) {
          logger.warn(
            { group: group.name, jailRunId, err },
            'Graceful stop failed, force killing',
          );
          jailProc.kill('SIGKILL');
        }
      });
    };

    let timeout = setTimeout(killOnTimeout, timeoutMs);

    // Reset the timeout whenever there's activity (streaming output)
    const resetTimeout = () => {
      clearTimeout(timeout);
      timeout = setTimeout(killOnTimeout, timeoutMs);
    };

    jailProc.on('close', (code) => {
      clearTimeout(timeout);
      const duration = Date.now() - startTime;

      if (timedOut) {
        const ts = new Date().toISOString().replace(/[:.]/g, '-');
        const timeoutLog = path.join(logsDir, `jail-${ts}.log`);
        fs.writeFileSync(
          timeoutLog,
          [
            `=== Jail Run Log (TIMEOUT) ===`,
            `Timestamp: ${new Date().toISOString()}`,
            `Group: ${group.name}`,
            `JailRunId: ${jailRunId}`,
            `Duration: ${duration}ms`,
            `Exit Code: ${code}`,
            `Had Streaming Output: ${hadStreamingOutput}`,
          ].join('\n'),
        );

        // Timeout after output = idle cleanup, not failure.
        // The agent already sent its response; this is just the
        // jail being reaped after the idle period expired.
        if (hadStreamingOutput) {
          logger.info(
            { group: group.name, jailRunId, duration, code },
            'Jail timed out after output (idle cleanup)',
          );
          outputChain.then(() => {
            markWardenRunFinished(group.folder, input.chatJid, jailRunId, {
              success: true,
              durationMs: duration,
              exitCode: code,
            });
            resolveOnce({
              status: 'success',
              result: null,
              newSessionId,
            });
          });
          return;
        }

        logger.error(
          { group: group.name, jailRunId, duration, code },
          'Jail timed out with no output',
        );
        markWardenRunFinished(group.folder, input.chatJid, jailRunId, {
          success: false,
          durationMs: duration,
          errorCode: 'warden_timeout',
          errorMessage: `Jail timed out after ${configTimeout}ms`,
          exitCode: code,
        });

        resolveOnce({
          status: 'error',
          result: null,
          error: `Jail timed out after ${configTimeout}ms`,
        });
        return;
      }

      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
      const logFile = path.join(logsDir, `jail-${timestamp}.log`);
      const isVerbose =
        process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';

      const logLines = [
        `=== Jail Run Log ===`,
        `Timestamp: ${new Date().toISOString()}`,
        `Group: ${group.name}`,
        `IsMain: ${input.isMain}`,
        `Duration: ${duration}ms`,
        `Exit Code: ${code}`,
        `Stdout Truncated: ${stdoutTruncated}`,
        `Stderr Truncated: ${stderrTruncated}`,
        ``,
      ];

      const isError = code !== 0;

      if (isVerbose || isError) {
        logLines.push(
          `=== Input ===`,
          JSON.stringify(input, null, 2),
          ``,
          `=== Jail Args ===`,
          jailArgs.join(' '),
          ``,
          `=== Mounts ===`,
          mounts
            .map(
              (m) =>
                `${m.hostPath} -> ${m.jailPath}${m.readonly ? ' (ro)' : ''}`,
            )
            .join('\n'),
          ``,
          `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
          stderr,
          ``,
          `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
          stdout,
        );
      } else {
        logLines.push(
          `=== Input Summary ===`,
          `Prompt length: ${input.prompt.length} chars`,
          `Session ID: ${input.sessionId || 'new'}`,
          ``,
          `=== Mounts ===`,
          mounts.map((m) => `${m.jailPath}${m.readonly ? ' (ro)' : ''}`).join('\n'),
          ``,
        );
      }

      fs.writeFileSync(logFile, logLines.join('\n'));
      logger.debug({ logFile, verbose: isVerbose }, 'Jail log written');

      if (code !== 0) {
        logger.error(
          {
            group: group.name,
            code,
            duration,
            stderr,
            stdout,
            logFile,
          },
          'Jail exited with error',
        );
        markWardenRunFinished(group.folder, input.chatJid, jailRunId, {
          success: false,
          durationMs: duration,
          errorCode: 'warden_exit_nonzero',
          errorMessage: `Jail exited with code ${code}: ${stderr.slice(-200)}`,
          exitCode: code,
        });

        resolveOnce({
          status: 'error',
          result: null,
          error: `Jail exited with code ${code}: ${stderr.slice(-200)}`,
        });
        return;
      }

      // Streaming mode: wait for output chain to settle, return completion marker
      if (onOutput) {
        outputChain.then(() => {
          logger.info(
            { group: group.name, duration, newSessionId },
            'Jail completed (streaming mode)',
          );
          markWardenRunFinished(group.folder, input.chatJid, jailRunId, {
            success: true,
            durationMs: duration,
            exitCode: code,
          });
          resolveOnce({
            status: 'success',
            result: null,
            newSessionId,
          });
        });
        return;
      }

      // Legacy mode: parse the last output marker pair from accumulated stdout
      try {
        // Extract JSON between sentinel markers for robust parsing
        const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
        const endIdx = stdout.indexOf(OUTPUT_END_MARKER);

        let jsonLine: string;
        if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
          jsonLine = stdout
            .slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
            .trim();
        } else {
          // Fallback: last non-empty line (backwards compatibility)
          const lines = stdout.trim().split('\n');
          jsonLine = lines[lines.length - 1];
        }

        const output: JailOutput = JSON.parse(jsonLine);

        logger.info(
          {
            group: group.name,
            duration,
            status: output.status,
            hasResult: !!output.result,
          },
          'Jail completed',
        );
        markWardenRunFinished(group.folder, input.chatJid, jailRunId, {
          success: output.status === 'success',
          durationMs: duration,
          errorCode:
            output.status === 'error' ? 'warden_output_error' : undefined,
          errorMessage: output.error,
          exitCode: code,
        });

        resolveOnce(output);
      } catch (err) {
        logger.error(
          {
            group: group.name,
            stdout,
            stderr,
            error: err,
          },
          'Failed to parse jail output',
        );
        markWardenRunFinished(group.folder, input.chatJid, jailRunId, {
          success: false,
          durationMs: duration,
          errorCode: 'warden_output_parse_failed',
          errorMessage:
            err instanceof Error ? err.message : String(err),
          exitCode: code,
        });

        resolveOnce({
          status: 'error',
          result: null,
          error: `Failed to parse jail output: ${err instanceof Error ? err.message : String(err)}`,
        });
      }
    });

    jailProc.on('error', (err) => {
      clearTimeout(timeout);
      logger.error(
        { group: group.name, jailRunId, error: err },
        'Jail spawn error',
      );
      markWardenRunFinished(group.folder, input.chatJid, jailRunId, {
        success: false,
        durationMs: Date.now() - startTime,
        errorCode: 'warden_spawn_failed',
        errorMessage: err.message,
        exitCode: null,
      });
      resolveOnce({
        status: 'error',
        result: null,
        error: `Jail spawn error: ${err.message}`,
      });
    });
  });
}

export function writeTasksSnapshot(
  groupFolder: string,
  isMain: boolean,
  tasks: Array<{
    id: string;
    groupFolder: string;
    prompt: string;
    schedule_type: string;
    schedule_value: string;
    status: string;
    next_run: string | null;
  }>,
): void {
  // Write filtered tasks to the group's IPC directory
  const groupIpcDir = resolveGroupIpcPath(groupFolder);
  fs.mkdirSync(groupIpcDir, { recursive: true });

  // Main sees all tasks, others only see their own
  const filteredTasks = isMain
    ? tasks
    : tasks.filter((t) => t.groupFolder === groupFolder);

  const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
  fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
}

export interface AvailableGroup {
  jid: string;
  name: string;
  lastActivity: string;
  isRegistered: boolean;
}

/**
 * Write available groups snapshot for the agent to read.
 * Only main group can see all available groups (for activation).
 * Non-main groups only see their own registration status.
 */
export function writeGroupsSnapshot(
  groupFolder: string,
  isMain: boolean,
  groups: AvailableGroup[],
  registeredJids: Set<string>,
): void {
  const groupIpcDir = resolveGroupIpcPath(groupFolder);
  fs.mkdirSync(groupIpcDir, { recursive: true });

  // Main sees all groups; others see nothing (they can't activate groups)
  const visibleGroups = isMain ? groups : [];

  const groupsFile = path.join(groupIpcDir, 'available_groups.json');
  fs.writeFileSync(
    groupsFile,
    JSON.stringify(
      {
        groups: visibleGroups,
        lastSync: new Date().toISOString(),
      },
      null,
      2,
    ),
  );
}
