/**
 * Container Runner for NanoClaw
 * Spawns agent execution in containers and handles IPC
 */
import { ChildProcess, exec, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

import {
  CONTAINER_IMAGE,
  CONTAINER_MAX_OUTPUT_SIZE,
  CONTAINER_TIMEOUT,
  GROUPS_DIR,
  IDLE_TIMEOUT,
  TENANT_HOME_BASE,
  TIMEZONE,
} from './config.js';
import { readEnvFile } from './env.js';
import {
  resolveGroupFolderPath,
  resolveGroupIpcPath,
  resolveGroupSessionsPath,
} from './group-folder.js';
import {
  ATTACHMENTS_CONTAINER_DIR,
  ensureGroupAttachmentHostDir,
  stageAttachmentsForGroup,
} from './attachment-spool.js';
import { logger, perfTimer } from './logger.js';
import {
  CONTAINER_RUNTIME_BIN,
  ensureTenantDockerNetwork,
  getTenantDockerNetworkName,
  readonlyMountArgs,
  stopContainer,
} from './container-runtime.js';
import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup, Tenant } from './types.js';
import { ensureWorkspaceContract } from './workspace-contract.js';
import { assembleLcmContext } from './lcm.js';
import {
  getRuntimeCredentialEnvKeys,
  getRuntimeSessionDir,
  getTenantPiBackend,
  getTenantProvider,
  normalizeProvider,
  RuntimeProvider,
} from './runtime-provider.js';
import { getTenantSystemdSlice } from './tenant-cgroups.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---';

export interface ContainerInput {
  provider?: RuntimeProvider;
  pi_backend?: string; // LLM backend for pi: 'anthropic' | 'openai' | 'gemini'
  prompt: string;
  sessionId?: string;
  groupFolder: string;
  /** Optional separate IPC dir (used by hook containers for isolation) */
  ipcFolder?: string;
  chatJid: string;
  isMain: boolean;
  mainGroupFolder?: string;
  isScheduledTask?: boolean;
  assistantName?: string;
  secrets?: Record<string, string>;
  tenantId?: string;
  model?: string;
  /** Start container in warm-standby mode — skip initial query, wait for IPC */
  warmOnly?: boolean;
  attachments?: Array<{
    kind: 'image' | 'document' | 'text' | 'audio' | 'file';
    data?: string;
    mediaType: string;
    name: string;
    sourcePath?: string;
    mountedPath?: string;
  }>;
}

export interface ContainerOutput {
  status: 'success' | 'error';
  result: string | null;
  newSessionId?: string;
  error?: string;
  /** Tool use progress update (not a final result) */
  progress?: string;
  /** Streamed assistant prose text */
  assistantText?: string;
  /** Query lifecycle signal derived from agent-runner logs */
  typingIndicator?: 'start' | 'stop';
}

interface VolumeMount {
  hostPath: string;
  containerPath: string;
  readonly: boolean;
}

function classifyTypingIndicatorLog(
  line: string,
): 'start' | 'stop' | null {
  if (
    line.includes('[agent-runner] Received input for group:') ||
    line.includes('[agent-runner] Got new message (')
  ) {
    return 'start';
  }
  if (line.includes('[agent-runner] Query ended, waiting for next IPC message...')) {
    return 'stop';
  }
  return null;
}

function truncateLogLine(line: string, maxLength = 240): string {
  if (line.length <= maxLength) return line;
  return `${line.slice(0, maxLength - 1)}…`;
}

function shouldSkipSelfCopy(srcDir: string, dstDir: string): boolean {
  if (!fs.existsSync(dstDir)) return false;
  try {
    return fs.realpathSync.native(srcDir) === fs.realpathSync.native(dstDir);
  } catch {
    return false;
  }
}

function summarizeAttachments(
  attachments?: Array<{ kind: 'image' | 'document' | 'text' | 'audio' | 'file'; data?: string; mediaType: string; name: string; sourcePath?: string; mountedPath?: string }>,
): Array<{ kind: string; mediaType: string; name: string }> {
  return (attachments || []).map((attachment) => ({
    kind: attachment.kind,
    mediaType: attachment.mediaType,
    name: attachment.name,
  }));
}

const WHISPER_MODEL_FILE_CONTAINER = '/models/whisper/ggml-base.en.bin';
const QWEN_TTS_MODEL_DIR_CONTAINER = '/models/qwen/qwen3-tts-12hz-0.6b-customvoice';

const ADMIN_SSH_HOST_ALIAS = 'treehouse-host';
const ADMIN_SSH_TARGET = 'admin@172.17.0.1';
const ADMIN_SSH_OPTS = [
  '-i',
  '/home/node/.ssh/id_ed25519',
  '-o',
  'UserKnownHostsFile=/home/node/.ssh/known_hosts',
  '-o',
  'StrictHostKeyChecking=yes',
  '-o',
  'UpdateHostKeys=no',
];

function ensureAdminShellInit(groupSessionsDir: string): {
  sshConfigPath: string;
  profileScriptPath: string;
  hostsshBinPath: string;
} {
  const sshConfigPath = path.join(groupSessionsDir, 'admin-ssh-config');
  const sshConfig =
    `Host ${ADMIN_SSH_HOST_ALIAS}\n` +
    `  HostName 172.17.0.1\n` +
    `  User admin\n` +
    `  IdentityFile /home/node/.ssh/id_ed25519\n` +
    `  IdentitiesOnly yes\n` +
    `  UserKnownHostsFile /home/node/.ssh/known_hosts\n` +
    `  StrictHostKeyChecking yes\n` +
    `  UpdateHostKeys no\n`;

  const profileScriptPath = path.join(groupSessionsDir, '90-nanoclaw-admin.sh');
  const sshBase = `ssh ${ADMIN_SSH_OPTS.join(' ')} ${ADMIN_SSH_TARGET}`;
  // zsh is in the Nix store, not in SSH's minimal PATH — use the absolute path.
  const ZSH = '/run/current-system/sw/bin/zsh';
  const profileScript =
    `# Auto-generated by nanoclaw (admin-only)\n` +
    `# Shell helpers for host administration from inside agent containers.\n\n` +
    `hostssh() {\n  ssh ${ADMIN_SSH_HOST_ALIAS} "\$@"\n}\n`;

  const binDir = path.join(groupSessionsDir, 'admin-bin');
  const hostsshBinPath = path.join(binDir, 'hostssh');
  const binHostssh = `#!/bin/sh\nexec ssh ${ADMIN_SSH_HOST_ALIAS} "\$@"\n`;

  try {
    const existing = fs.existsSync(sshConfigPath)
      ? fs.readFileSync(sshConfigPath, 'utf8')
      : null;
    if (existing !== sshConfig) {
      fs.writeFileSync(sshConfigPath, sshConfig, { mode: 0o600 });
    }
    fs.chmodSync(sshConfigPath, 0o600);
  } catch {}

  try {
    const existing = fs.existsSync(profileScriptPath)
      ? fs.readFileSync(profileScriptPath, 'utf8')
      : null;
    if (existing !== profileScript) {
      fs.writeFileSync(profileScriptPath, profileScript, { mode: 0o644 });
    }
    fs.chmodSync(profileScriptPath, 0o644);
  } catch {}

  try {
    fs.mkdirSync(binDir, { recursive: true });
    const writeExec = (p: string, content: string): void => {
      const existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
      if (existing !== content) fs.writeFileSync(p, content, { mode: 0o755 });
      fs.chmodSync(p, 0o755);
    };
    writeExec(hostsshBinPath, binHostssh);
  } catch {}

  return {
    sshConfigPath,
    profileScriptPath,
    hostsshBinPath,
  };
}

function getWhisperModelFileHost(): string {
  return process.env.NANOCLAW_WHISPER_MODEL_FILE || '/var/lib/nanoclaw/models/whisper/ggml-base.en.bin';
}

function getQwenTtsModelDirHost(): string {
  return process.env.NANOCLAW_QWEN_TTS_MODEL_DIR || '/var/lib/nanoclaw/models/qwen/qwen3-tts-12hz-0.6b-customvoice';
}

function prepareAttachmentsForContainer(
  groupFolder: string,
  attachments?: ContainerInput['attachments'],
): { attachments?: ContainerInput['attachments']; mounts: VolumeMount[] } {
  const mounts: VolumeMount[] = [];
  const prepared = attachments?.length
    ? stageAttachmentsForGroup(groupFolder, attachments)
    : undefined;

  const whisperModelFileHost = getWhisperModelFileHost();
  // Mount the shared Whisper model on every container run when available.
  // Audio can arrive later over IPC into an already-warm container, so
  // conditioning this mount on initial attachments breaks follow-up voice messages.
  if (fs.existsSync(whisperModelFileHost)) {
    mounts.push({
      hostPath: whisperModelFileHost,
      containerPath: WHISPER_MODEL_FILE_CONTAINER,
      readonly: true,
    });
  }

  const qwenTtsModelDirHost = getQwenTtsModelDirHost();
  if (fs.existsSync(qwenTtsModelDirHost)) {
    mounts.push({
      hostPath: qwenTtsModelDirHost,
      containerPath: QWEN_TTS_MODEL_DIR_CONTAINER,
      readonly: true,
    });
  }

  return {
    attachments: prepared,
    mounts,
  };
}

function buildVolumeMounts(
  group: RegisteredGroup,
  input: ContainerInput,
  isMain: boolean,
  tenant?: Tenant,
): VolumeMount[] {
  const mounts: VolumeMount[] = [];
  const projectRoot = process.cwd();
  const groupDir = resolveGroupFolderPath(group.folder);
  const provider = normalizeProvider(input.provider ?? tenant?.provider);
  ensureWorkspaceContract(groupDir);
  const attachmentHostDir = ensureGroupAttachmentHostDir(group.folder);

  if (isMain) {
    // Main gets the project root read-only. Writable paths the agent needs
    // (group folder, IPC, .claude/) 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,
      containerPath: '/workspace/project',
      readonly: true,
    });

    // Shadow .env so the agent cannot read secrets from the mounted project root.
    // Secrets are passed via stdin instead (see readSecrets()).
    const envFile = path.join(projectRoot, '.env');
    if (fs.existsSync(envFile)) {
      mounts.push({
        hostPath: '/dev/null',
        containerPath: '/workspace/project/.env',
        readonly: true,
      });
    }

    // Main also gets its group folder as the working directory
    mounts.push({
      hostPath: groupDir,
      containerPath: '/workspace/group',
      readonly: false,
    });
  } else {
    // Other groups only get their own folder
    mounts.push({
      hostPath: groupDir,
      containerPath: '/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,
        containerPath: '/workspace/global',
        readonly: true,
      });
    }

    if (input.mainGroupFolder) {
      const mainGroupDir = resolveGroupFolderPath(input.mainGroupFolder);
      if (mainGroupDir !== groupDir && fs.existsSync(mainGroupDir)) {
        mounts.push({
          hostPath: mainGroupDir,
          containerPath: '/workspace/main-profile',
          readonly: true,
        });
      }
    }
  }

  // Per-group runtime sessions directory (isolated from other groups)
  const runtimeHomeDir = getRuntimeSessionDir(provider);
  const groupSessionsDir = path.join(
    resolveGroupSessionsPath(group.folder),
    runtimeHomeDir,
  );
  fs.mkdirSync(groupSessionsDir, { recursive: true });
  if (provider === 'claude') {
    const settingsFile = path.join(groupSessionsDir, 'settings.json');
    if (!fs.existsSync(settingsFile)) {
      fs.writeFileSync(
        settingsFile,
        JSON.stringify(
          {
            env: {
              CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
              CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
              CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
            },
          },
          null,
          2,
        ) + '\n',
      );
    }
  }

  mounts.push({
    hostPath: attachmentHostDir,
    containerPath: ATTACHMENTS_CONTAINER_DIR,
    readonly: true,
  });

  // Sync skills into each group's .claude/skills/ (Claude) and .agents/skills/ (Codex)
  // Order: base -> tenant -> platform -> admin
  // Later sources override earlier ones with the same directory name.
  // Platform and admin load last so tenants cannot override them.
  const skillsDst = path.join(groupSessionsDir, 'skills');
  // Clean skills dir before sync so removed skills don't persist across runs
  if (fs.existsSync(skillsDst)) {
    fs.rmSync(skillsDst, { recursive: true });
  }
  const agentsSkillsDst = path.join(resolveGroupSessionsPath(group.folder), '.agents', 'skills');
  if (fs.existsSync(agentsSkillsDst)) {
    fs.rmSync(agentsSkillsDst, { recursive: true });
  }
  const skillSources: string[] = [];

  // Base skills (visible in tenant dashboard)
  const baseSkillsSrc = path.join(process.cwd(), 'container', 'skills');
  if (fs.existsSync(baseSkillsSrc)) {
    skillSources.push(baseSkillsSrc);
  }

  // Per-tenant skills directory (persistent store at /home/<tenant>/workspace/skills)
  const tenantParts = group.folder.includes('/')
    ? group.folder.split('/')
    : null;
  if (tenantParts) {
    const tenantSkillsSrc = path.join(
      TENANT_HOME_BASE,
      tenantParts[0],
      'workspace',
      'skills',
    );
    if (fs.existsSync(tenantSkillsSrc)) {
      skillSources.push(tenantSkillsSrc);
    }
  }

  // Platform skills (always injected, hidden from dashboard, cannot be overridden)
  const platformSkillsSrc = path.join(process.cwd(), 'container', 'skills-platform');
  if (fs.existsSync(platformSkillsSrc)) {
    skillSources.push(platformSkillsSrc);
  }

  // Admin-only skills (only for is_admin tenants, cannot be overridden)
  const isAdmin = tenant?.is_admin === true || (tenant?.is_admin as unknown) === 1;
  if (isAdmin) {
    const adminSkillsSrc = path.join(process.cwd(), 'container', 'skills-admin');
    if (fs.existsSync(adminSkillsSrc)) {
      skillSources.push(adminSkillsSrc);
    }
  }

  for (const src of skillSources) {
    for (const skillDir of fs.readdirSync(src)) {
      const srcDir = path.join(src, skillDir);
      if (!fs.statSync(srcDir).isDirectory()) continue;
      // Sync to .claude/skills/ (or .codex/skills/)
      const dstDir = path.join(skillsDst, skillDir);
      // Resolve symlinks so cpSync copies actual files instead of creating a symlink
      // in the destination (symlink targets are not accessible inside containers).
      const resolvedSrcDir = fs.lstatSync(srcDir).isSymbolicLink()
        ? fs.realpathSync(srcDir)
        : srcDir;
      if (!shouldSkipSelfCopy(resolvedSrcDir, dstDir)) {
        fs.rmSync(dstDir, { recursive: true, force: true });
        fs.cpSync(resolvedSrcDir, dstDir, { recursive: true });
      }
      // Sync to .agents/skills/ (for Codex compatibility)
      const agentsDstDir = path.join(agentsSkillsDst, skillDir);
      fs.mkdirSync(agentsSkillsDst, { recursive: true });
      fs.rmSync(agentsDstDir, { recursive: true, force: true });
      fs.cpSync(resolvedSrcDir, agentsDstDir, { recursive: true });
    }
  }

  if (isAdmin) {
    const init = ensureAdminShellInit(groupSessionsDir);
    if (fs.existsSync(init.sshConfigPath)) {
      mounts.push({
        hostPath: init.sshConfigPath,
        containerPath: '/home/node/.ssh/config',
        readonly: true,
      });
    }
    if (fs.existsSync(init.profileScriptPath)) {
      mounts.push({
        hostPath: init.profileScriptPath,
        containerPath: '/etc/profile.d/90-nanoclaw-admin.sh',
        readonly: true,
      });
    }
    if (fs.existsSync(init.hostsshBinPath)) {
      mounts.push({
        hostPath: init.hostsshBinPath,
        containerPath: '/usr/local/bin/hostssh',
        readonly: true,
      });
    }
  }

  // Sync agents into each group's .claude/agents/
  // Same two-layer model as skills: base then tenant overrides.
  const agentsDst = path.join(groupSessionsDir, 'agents');
  const agentSources: string[] = [];

  const baseAgentsSrc = path.join(process.cwd(), 'container', 'agents');
  if (fs.existsSync(baseAgentsSrc)) {
    agentSources.push(baseAgentsSrc);
  }

  if (tenantParts) {
    const tenantAgentsSrc = path.join(
      TENANT_HOME_BASE,
      tenantParts[0],
      'nanoclaw',
      'agents',
    );
    if (fs.existsSync(tenantAgentsSrc)) {
      agentSources.push(tenantAgentsSrc);
    }
  }

  for (const src of agentSources) {
    for (const agentDir of fs.readdirSync(src)) {
      const srcDir = path.join(src, agentDir);
      if (!fs.statSync(srcDir).isDirectory()) continue;
      const dstDir = path.join(agentsDst, agentDir);
      if (shouldSkipSelfCopy(srcDir, dstDir)) continue;
      fs.rmSync(dstDir, { recursive: true, force: true });
      fs.cpSync(srcDir, dstDir, { recursive: true });
    }
  }

  mounts.push({
    hostPath: groupSessionsDir,
    containerPath: `/home/node/${runtimeHomeDir}`,
    readonly: false,
  });

  // Mount .agents/ for Codex skill discovery (.agents/skills/)
  const agentsDir = path.join(resolveGroupSessionsPath(group.folder), '.agents');
  fs.mkdirSync(agentsDir, { recursive: true });
  mounts.push({
    hostPath: agentsDir,
    containerPath: '/home/node/.agents',
    readonly: false,
  });

  // Per-group IPC namespace: each group gets its own IPC directory
  // This prevents cross-group privilege escalation via IPC.
  // Hook containers may use a separate ipcFolder to avoid interfering with
  // the main group's IPC directory (close sentinel isolation).
  const ipcDirFolder = input.ipcFolder || group.folder;
  const groupIpcDir = resolveGroupIpcPath(ipcDirFolder);
  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,
    containerPath: '/workspace/ipc',
    readonly: false,
  });

  // Shared tenant workspace for app hosting and cross-group files
  if (group.tenantId) {
    const sharedWorkspace = path.join(TENANT_HOME_BASE, group.tenantId, 'workspace');
    const tenantAppsRoot = path.join(sharedWorkspace, 'apps');
    fs.mkdirSync(sharedWorkspace, { recursive: true });
    fs.mkdirSync(tenantAppsRoot, { recursive: true });
    mounts.push({
      hostPath: sharedWorkspace,
      containerPath: '/workspace/shared',
      readonly: false,
    });
    mounts.push({
      hostPath: tenantAppsRoot,
      containerPath: '/workspace/apps',
      readonly: false,
    });

    // Keep the legacy compose mount for existing app-hosting flows.
    const composePath = path.join(TENANT_HOME_BASE, group.tenantId, 'nanoclaw', 'compose');
    if (fs.existsSync(composePath)) {
      mounts.push({
        hostPath: composePath,
        containerPath: '/workspace/group/compose',
        readonly: false,
      });
    }
  }

  // Copy agent-runner source into a per-group writable location so agents
  // can customize it (add tools, change behavior) without affecting other
  // groups. Recompiled on container startup via entrypoint.sh.
  const agentRunnerSrc = path.join(
    projectRoot,
    'container',
    'agent-runner',
    'src',
  );
  const groupAgentRunnerDir = path.join(
    resolveGroupSessionsPath(group.folder),
    'agent-runner-src',
  );
  if (fs.existsSync(agentRunnerSrc)) {
    // Always re-sync so agent-runner updates take effect immediately
    fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true, force: true });
  }
  mounts.push({
    hostPath: groupAgentRunnerDir,
    containerPath: '/app/src',
    readonly: false,
  });

  // Additional mounts validated against external allowlist (tamper-proof from containers)
  if (group.containerConfig?.additionalMounts) {
    const validatedMounts = validateAdditionalMounts(
      group.containerConfig.additionalMounts,
      group.name,
      isMain,
    );
    mounts.push(...validatedMounts);
  }


  // Admin SSH: mount key + known_hosts + passwd so agent can SSH back into the host.
  // SSH requires the running UID to have a passwd entry, so we generate one
  // that maps the host nanoclaw UID to "node" with home /home/node.
  // Admin tenants get SSH access (is_admin implies host_mount=ssh)
  if (tenant?.host_mount === 'ssh' || isAdmin) {
    const sshDir = '/var/lib/nanoclaw/.ssh';
    if (fs.existsSync(path.join(sshDir, 'admin_agent'))) {
      mounts.push({
        hostPath: path.join(sshDir, 'admin_agent'),
        containerPath: '/home/node/.ssh/id_ed25519',
        readonly: true,
      });
      mounts.push({
        hostPath: path.join(sshDir, 'known_hosts'),
        containerPath: '/home/node/.ssh/known_hosts',
        readonly: true,
      });
      // Generate a passwd file that includes the host UID so SSH works
      const uid = process.getuid?.() ?? 1000;
      const gid = process.getgid?.() ?? 1000;
      const passwdPath = path.join(sshDir, 'passwd');
      const passwdContent = [
        'root:x:0:0:root:/root:/bin/sh',
        `node:x:${uid}:${gid}:node:/home/node:/bin/bash`,
        'nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin',
      ].join('\n') + '\n';
      fs.writeFileSync(passwdPath, passwdContent);
      mounts.push({
        hostPath: passwdPath,
        containerPath: '/etc/passwd',
        readonly: true,
      });
    }

    // Mount admin AGENTS.md into the group workspace so the agent has operating instructions.
    const adminAgentsPath = '/home/admin/AGENTS.md';
    if (fs.existsSync(adminAgentsPath)) {
      mounts.push({
        hostPath: adminAgentsPath,
        containerPath: '/workspace/group/AGENTS.md',
        readonly: true,
      });
      // Also sync content into the group folder file so that send_file IPC path translation
      // resolves correctly. The bind mount shadows the group folder file inside the container
      // (agent sees read-only), but ipc.ts maps /workspace/group/ → host group folder path.
      try {
        const content = fs.readFileSync(adminAgentsPath, 'utf8');
        fs.writeFileSync(path.join(groupDir, 'AGENTS.md'), content);
      } catch {
        // Non-fatal: send_file of AGENTS.md may fail but container still has the bind-mounted version
      }
    }
  }

  return mounts;
}

/**
 * Read allowed secrets from .env for passing to the container via stdin.
 * Secrets are never written to disk or mounted as files.
 */
function readSecrets(): Record<string, string> {
  const keys = new Set<string>();
  for (const provider of ['claude', 'codex'] as RuntimeProvider[]) {
    for (const key of getRuntimeCredentialEnvKeys(provider)) {
      keys.add(key);
    }
  }
  // Pi can use any backend — collect keys for all pi backends
  for (const piBackend of ['anthropic', 'openai', 'gemini']) {
    for (const key of getRuntimeCredentialEnvKeys('pi', piBackend)) {
      keys.add(key);
    }
  }
  return readEnvFile([...keys]);
}

function shouldUseGvisorForTenant(tenant?: Tenant): boolean {
  if (!tenant?.id) return false;
  const raw = process.env.GVISOR_TENANTS || '';
  const enabled = new Set(
    raw
      .split(',')
      .map((item) => item.trim())
      .filter(Boolean),
  );
  return enabled.has(tenant.id);
}

function buildRunscResolvConfContent(): string {
  let hostResolv = '';
  try {
    hostResolv = fs.readFileSync('/etc/resolv.conf', 'utf-8');
  } catch {
    hostResolv = '';
  }

  const nameservers = hostResolv
    .split('\n')
    .map((line) => line.trim())
    .filter((line) => line.startsWith('nameserver '))
    .map((line) => line.split(/\s+/, 2)[1]?.trim() || '')
    .filter(Boolean)
    .filter((ip) => !['127.0.0.1', '::1'].includes(ip));

  const nonLoopbackNameservers = nameservers.length > 0
    ? nameservers
    : ['1.1.1.1', '1.0.0.1', '8.8.8.8', '8.8.4.4'];

  const passthroughLines = hostResolv
    .split('\n')
    .map((line) => line.trim())
    .filter((line) => line.startsWith('search ') || line.startsWith('options '));

  return [
    ...nonLoopbackNameservers.map((ip) => `nameserver ${ip}`),
    ...passthroughLines,
    '',
  ].join('\n');
}

function createRunscResolvConf(): string {
  const sharedTempDir = path.join(
    process.env.HOME || '/var/lib/nanoclaw',
    'tmp',
    'runsc-resolv',
  );
  fs.mkdirSync(sharedTempDir, { recursive: true });
  const resolvPath = path.join(
    sharedTempDir,
    `nanoclaw-runsc-resolv-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.conf`,
  );
  fs.writeFileSync(resolvPath, buildRunscResolvConfContent());
  return resolvPath;
}

function buildContainerArgs(
  mounts: VolumeMount[],
  containerName: string,
  tenant?: Tenant,
  chatJid?: string,
  groupFolder?: string,
): string[] {
  const args: string[] = ['run', '-i', '--rm', '--name', containerName];

  // Pass host timezone so container's local time matches the user's
  args.push('-e', `TZ=${TIMEZONE}`);

  // Pass tenant context for app-hosting skill
  if (tenant) {
    args.push('-e', `TENANT_ID=${tenant.id}`);
    if (tenant.name) args.push('-e', `TENANT_NAME=${tenant.name}`);
    if (tenant.discord_user_id) args.push('-e', `TENANT_DISCORD_USER_ID=${tenant.discord_user_id}`);
    if (tenant.port_range_start != null && tenant.port_range_size != null) {
      const portEnd = tenant.port_range_start + tenant.port_range_size - 1;
      args.push('-e', `TENANT_PORT_RANGE=${tenant.port_range_start}-${portEnd}`);
    }
  }

  if (fs.existsSync(getQwenTtsModelDirHost())) {
    args.push('-e', `NANOCLAW_QWEN_TTS_MODEL=${QWEN_TTS_MODEL_DIR_CONTAINER}`);
  }

  if (chatJid) args.push('-e', `NANOCLAW_CHAT_JID=${chatJid}`);
  if (groupFolder) args.push('-e', `NANOCLAW_GROUP_FOLDER=${groupFolder}`);


  // Place the container under the tenant's systemd slice so Docker creates the
  // container scope beneath the tenant-owned cgroup tree.
  const tenantSlice = getTenantSystemdSlice(tenant?.id);
  if (tenantSlice) {
    args.push('--cgroup-parent', tenantSlice);
    args.push('--cpu-shares', '8192'); // High CPU priority for agent containers vs app containers
  }
  if (shouldUseGvisorForTenant(tenant)) {
    args.push('--runtime', 'runsc');
    args.push('-e', 'GVISOR_ACTIVE=1');
  }

  if (tenant?.id) {
    args.push('--network', getTenantDockerNetworkName(tenant.id));
  }

  // Run as host user so bind-mounted files are accessible.
  // Skip when running as root (uid 0), as the container's node user (uid 1000),
  // or when getuid is unavailable (native Windows without WSL).
  const hostUid = process.getuid?.();
  const hostGid = process.getgid?.();
  if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
    args.push('--user', `${hostUid}:${hostGid}`);
    args.push('-e', 'HOME=/home/node');
  }

  for (const mount of mounts) {
    if (mount.readonly) {
      args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
    } else {
      args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
    }
  }

  // Mount @treehouse/sdk from host read-only — available as import('@treehouse/sdk') and `tc` CLI
  const __srcDir = path.dirname(fileURLToPath(import.meta.url));
  const sdkHostPath = path.join(__srcDir, '..', '..', 'sdk');
  if (fs.existsSync(sdkHostPath)) {
    args.push(...readonlyMountArgs(sdkHostPath, '/opt/treehouse/sdk'));
  }

  args.push(CONTAINER_IMAGE);

  return args;
}

export async function runContainerAgent(
  group: RegisteredGroup,
  input: ContainerInput,
  onProcess: (proc: ChildProcess, containerName: string) => void,
  onOutput?: (output: ContainerOutput) => Promise<void>,
  tenant?: Tenant,
  secretsOverride?: Record<string, string>,
): Promise<ContainerOutput> {
  const startTime = Date.now();
  const _perfRCA = perfTimer('runContainerAgent', { group: group.name });
  const tempPaths: string[] = [];
  const provider = normalizeProvider(input.provider ?? tenant?.provider);
  const preparedInput: ContainerInput = {
    ...input,
    model: input.model || tenant?.model,
    pi_backend: input.pi_backend || (provider === 'pi' ? getTenantPiBackend(tenant) : undefined),
  };

  // LCM: inject assembled context from prior turns into the prompt
  const lcmContext = assembleLcmContext(group.folder);
  _perfRCA.step('assembleLcmContext', { hasContext: !!lcmContext });
  if (lcmContext) {
    preparedInput.prompt = lcmContext + '\n\n' + preparedInput.prompt;
  }

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

  if (tenant?.id) {
    ensureTenantDockerNetwork(tenant.id);
  }
  _perfRCA.step('ensureTenantDockerNetwork');

  const attachmentPrep = prepareAttachmentsForContainer(group.folder, input.attachments);
  preparedInput.attachments = attachmentPrep.attachments;

  const mounts = buildVolumeMounts(group, preparedInput, preparedInput.isMain, tenant);
  mounts.push(...attachmentPrep.mounts);

  if (shouldUseGvisorForTenant(tenant)) {
    const resolvPath = createRunscResolvConf();
    tempPaths.push(resolvPath);
    mounts.push({
      hostPath: resolvPath,
      containerPath: '/etc/resolv.conf',
      readonly: true,
    });
  }
  const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
  const containerName = `nanoclaw-${safeName}-${Date.now()}`;
  const containerArgs = buildContainerArgs(mounts, containerName, tenant, preparedInput.chatJid, preparedInput.groupFolder);
  _perfRCA.step('buildContainerArgs', { mountCount: mounts.length });

  logger.debug(
    {
      group: group.name,
      containerName,
      mounts: mounts.map(
        (m) =>
          `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
      ),
      containerArgs: containerArgs.join(' '),
    },
    'Container mount configuration',
  );

  logger.info(
    {
      group: group.name,
      containerName,
      mountCount: mounts.length,
      isMain: preparedInput.isMain,
      attachmentCount: preparedInput.attachments?.length || 0,
      attachments: summarizeAttachments(preparedInput.attachments),
    },
    'Spawning container agent',
  );

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

  return new Promise((resolve) => {
    let cleanedUp = false;
    const cleanupTempPaths = () => {
      if (cleanedUp) return;
      cleanedUp = true;
      for (const tempPath of tempPaths) {
        try {
          fs.rmSync(tempPath, { force: true });
        } catch {
          // Ignore temp cleanup failures.
        }
      }
    };

    const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
      stdio: ['pipe', 'pipe', 'pipe'],
    });
    _perfRCA.step('containerSpawn');

    onProcess(container, containerName);

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

    // Pass secrets via stdin (never written to disk or mounted as files)
    preparedInput.secrets = secretsOverride ?? {};
    container.stdin.write(JSON.stringify(preparedInput));
    container.stdin.end();
    // Remove secrets from input so they don't appear in logs
    delete preparedInput.secrets;

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

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

      // Always accumulate for logging
      if (!stdoutTruncated) {
        const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
        if (chunk.length > remaining) {
          stdout += chunk.slice(0, remaining);
          stdoutTruncated = true;
          logger.warn(
            { group: group.name, size: stdout.length },
            'Container 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: ContainerOutput = 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',
            );
          }
        }
      }
    });

    const processStderrLine = (line: string) => {
      if (!line) return;
      logger.debug({ container: group.folder }, line);
      const typingIndicator = classifyTypingIndicatorLog(line);
      logger.info(
        {
          group: group.name,
          containerName,
          typingIndicatorMatched: typingIndicator,
          stderrLine: truncateLogLine(line),
        },
        'Processed agent-runner stderr line',
      );
      if (!typingIndicator || !onOutput) return;
      logger.info(
        {
          group: group.name,
          containerName,
          typingIndicator,
        },
        'Forwarding typing indicator from agent-runner stderr',
      );
      resetTimeout();
      outputChain = outputChain.then(() =>
        onOutput({
          status: 'success',
          result: null,
          newSessionId,
          typingIndicator,
        }),
      );
    };

    container.stderr.on('data', (data) => {
      const chunk = data.toString();
      stderrLineBuffer += chunk;
      const lines = stderrLineBuffer.split(/\r?\n/);
      stderrLineBuffer = lines.pop() ?? '';
      for (const line of lines) {
        processStderrLine(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 = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
      if (chunk.length > remaining) {
        stderr += chunk.slice(0, remaining);
        stderrTruncated = true;
        logger.warn(
          { group: group.name, size: stderr.length },
          'Container stderr truncated due to size limit',
        );
      } else {
        stderr += chunk;
      }
    });

    let timedOut = false;
    let hadStreamingOutput = false;
    const configTimeout = group.containerConfig?.timeout || CONTAINER_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, containerName },
        'Container timeout, stopping gracefully',
      );
      exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
        if (err) {
          logger.warn(
            { group: group.name, containerName, err },
            'Graceful stop failed, force killing',
          );
          container.kill('SIGKILL');
        }
      });
    };

    let timeout = setTimeout(killOnTimeout, timeoutMs);

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

    container.on('close', (code) => {
      clearTimeout(timeout);
      cleanupTempPaths();
      if (stderrLineBuffer) {
        processStderrLine(stderrLineBuffer);
        stderrLineBuffer = '';
      }
      const duration = Date.now() - startTime;
      _perfRCA.done({ exitCode: code, containerDurationMs: duration });

      if (timedOut) {
        const ts = new Date().toISOString().replace(/[:.]/g, '-');
        const timeoutLog = path.join(logsDir, `container-${ts}.log`);
        fs.writeFileSync(
          timeoutLog,
          [
            `=== Container Run Log (TIMEOUT) ===`,
            `Timestamp: ${new Date().toISOString()}`,
            `Group: ${group.name}`,
            `Container: ${containerName}`,
            `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
        // container being reaped after the idle period expired.
        if (hadStreamingOutput) {
          logger.info(
            { group: group.name, containerName, duration, code },
            'Container timed out after output (idle cleanup)',
          );
          outputChain.then(() => {
            resolve({
              status: 'success',
              result: null,
              newSessionId,
            });
          });
          return;
        }

        logger.error(
          { group: group.name, containerName, duration, code },
          'Container timed out with no output',
        );

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

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

      const logLines = [
        `=== Container 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),
          ``,
          `=== Container Args ===`,
          containerArgs.join(' '),
          ``,
          `=== Mounts ===`,
          mounts
            .map(
              (m) =>
                `${m.hostPath} -> ${m.containerPath}${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.containerPath}${m.readonly ? ' (ro)' : ''}`)
            .join('\n'),
          ``,
        );
      }

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

      if (code !== 0) {
        logger.error(
          {
            group: group.name,
            code,
            duration,
            stderr,
            stdout,
            logFile,
          },
          'Container exited with error',
        );

        // Wait for outputChain to settle before resolving so that wrappedOnOutput
        // session updates are flushed (session is saved before runAgent returns).
        const resolveError = () => resolve({
          status: 'error',
          result: null,
          error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
        });
        outputChain.then(resolveError).catch(resolveError);
        return;
      }

      // Streaming mode: wait for output chain to settle, return completion marker
      if (onOutput) {
        outputChain.then(() => {
          logger.info(
            { group: group.name, duration, newSessionId },
            'Container completed (streaming mode)',
          );
          resolve({
            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: ContainerOutput = JSON.parse(jsonLine);

        logger.info(
          {
            group: group.name,
            duration,
            status: output.status,
            hasResult: !!output.result,
          },
          'Container completed',
        );

        resolve(output);
      } catch (err) {
        logger.error(
          {
            group: group.name,
            stdout,
            stderr,
            error: err,
          },
          'Failed to parse container output',
        );

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

    container.on('error', (err) => {
      clearTimeout(timeout);
      cleanupTempPaths();
      logger.error(
        { group: group.name, containerName, error: err },
        'Container spawn error',
      );
      resolve({
        status: 'error',
        result: null,
        error: `Container spawn error: ${err.message}`,
      });
    });
  });
}

export function writeTasksSnapshot(
  groupFolder: string,
  isMain: boolean,
  tasks: Array<{
    id: string;
    groupFolder: string;
    prompt: string;
    scheduleType: string;
    scheduleValue: string;
    status: string;
    nextRun: 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 for the tenant, others only see their own group's tasks
  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 container 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>,
  tenantGroups?: AvailableGroup[],
): void {
  const groupIpcDir = resolveGroupIpcPath(groupFolder);
  fs.mkdirSync(groupIpcDir, { recursive: true });

  // Main sees all available groups; non-main groups see their own tenant's registered groups
  const visibleGroups = isMain ? groups : (tenantGroups ?? []);

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

export function writeGroupSettingsSnapshot(
  groupFolder: string,
  settings: { requiresTrigger: boolean },
): void {
  const groupIpcDir = resolveGroupIpcPath(groupFolder);
  fs.mkdirSync(groupIpcDir, { recursive: true });

  const settingsFile = path.join(groupIpcDir, 'group_settings.json');
  fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
}
