import { spawn } from "node:child_process";
import crypto from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";

import { fileExists } from "./http-utils.mjs";
import { lookupAttachment } from "./attachment-utils.mjs";

const defaultModel = "gpt-5.5";
const defaultReasoningEffort = "low";
const miniThresholdPercent = Number(process.env.NBTANGINONE_MINI_THRESHOLD_PERCENT || process.env.CODEXINONE_MINI_THRESHOLD_PERCENT || 15);
const maxAttachmentBytes = 10 * 1024 * 1024;
const maxAttachmentCount = 3;
const maxPersistedSessionImages = 20;
const sessionsCacheTtlMs = Number(process.env.NBTANGINONE_SESSIONS_CACHE_TTL_MS || process.env.CODEXINONE_SESSIONS_CACHE_TTL_MS || 15000);
const hostStatusCacheTtlMs = Number(process.env.NBTANGINONE_HOST_STATUS_CACHE_TTL_MS || process.env.CODEXINONE_HOST_STATUS_CACHE_TTL_MS || 15000);
const attachmentExtensionByMime = {
  "image/png": ".png",
  "image/jpeg": ".jpg",
  "image/webp": ".webp",
  "image/gif": ".gif",
};

function buildPromptWithHandoff(prompt, handoffSummary) {
  const cleanPrompt = String(prompt || "").trim();
  const cleanSummary = String(handoffSummary || "").trim();
  if (!cleanSummary) {
    return cleanPrompt;
  }
  return "[handoff]\n请继承以下同项目上下文,继续处理用户请求。\n" + cleanSummary + "\n[/handoff]\n\n当前用户请求:\n" + cleanPrompt;
}

export class CodexHostRuntime {
  constructor(options = {}) {
    this.codexHome = options.codexHome || path.join(os.homedir(), ".codex");
    this.sessionsRoot = path.join(this.codexHome, "sessions");
    this.stateFilePath = path.join(this.codexHome, "nbtanginone-state.json");
    this.legacyStateFilePath = path.join(this.codexHome, "codexinone-state.json");
    this.usageFilePath = path.join(this.codexHome, "nbtanginone-usage.json");
    this.legacyUsageFilePath = path.join(this.codexHome, "codexinone-usage.json");
    this.attachmentsRoot = path.join(this.codexHome, "codexinone-attachments");
    this.attachmentsIndexPath = path.join(this.attachmentsRoot, "index.json");
    this.currentProjectRoot = options.currentProjectRoot || process.cwd();
    this.configPath = options.configPath || process.env.HOST_ADAPTER_CONFIG || path.join(process.cwd(), "host-adapter.config.json");
    this.hostListenLabel = options.hostListenLabel || "host-adapter";
    this.runs = new Map();
    this.attachments = new Map();
    this.sessionsCache = { value: null, expiresAt: 0, promise: null };
    this.hostStatusCache = { value: null, expiresAt: 0, promise: null };
  }

  formatCodexResetLabel(unixSeconds) {
    if (!Number.isFinite(unixSeconds)) {
      return "resets n/a";
    }
    const resetAt = new Date(unixSeconds * 1000);
    if (!Number.isFinite(resetAt.getTime())) {
      return "resets n/a";
    }
    const now = new Date();
    const sameDay =
      now.getFullYear() === resetAt.getFullYear() &&
      now.getMonth() === resetAt.getMonth() &&
      now.getDate() === resetAt.getDate();
    const timeText = new Intl.DateTimeFormat("en-GB", {
      hour: "2-digit",
      minute: "2-digit",
      hour12: false,
    }).format(resetAt);
    if (sameDay) {
      return `resets ${timeText}`;
    }
    const dateText = new Intl.DateTimeFormat("en-GB", {
      day: "numeric",
      month: "short",
    }).format(resetAt);
    return `resets ${timeText} on ${dateText}`;
  }

  formatCodexWindowSummary(label, snapshot) {
    if (!snapshot) {
      return `${label}: n/a`;
    }
    const usedPercent = Number(snapshot.usedPercent);
    const leftPercent = Number.isFinite(usedPercent)
      ? Math.max(0, Math.min(100, Math.round(100 - usedPercent)))
      : null;
    const leftText = leftPercent == null ? "n/a" : `${leftPercent}% left`;
    return `${label}: ${leftText} (${this.formatCodexResetLabel(snapshot.resetsAt)})`;
  }

  parseUsageSnapshot(payload) {
    const snapshot = payload?.rateLimitsByLimitId?.codex || payload?.rateLimits || null;
    if (!snapshot) {
      return null;
    }
    const leftPercent = (windowSnapshot) => {
      const usedPercent = Number(windowSnapshot?.usedPercent);
      return Number.isFinite(usedPercent) ? Math.max(0, Math.min(100, Math.round(100 - usedPercent))) : null;
    };
    const fiveHourRemainingPercent = leftPercent(snapshot.primary);
    const weeklyRemainingPercent = leftPercent(snapshot.secondary);
    const finiteValues = [fiveHourRemainingPercent, weeklyRemainingPercent].filter((value) => Number.isFinite(value));
    const effectiveRemainingPercent = finiteValues.length ? Math.min(...finiteValues) : null;
    const primaryLabel = snapshot?.primary?.windowDurationMins === 300 ? "5h limit" : "Primary limit";
    const secondaryLabel = snapshot?.secondary?.windowDurationMins === 10080 ? "Weekly limit" : "Secondary limit";
    const summaryParts = [this.formatCodexWindowSummary(primaryLabel, snapshot.primary)];
    if (snapshot.secondary) {
      summaryParts.push(this.formatCodexWindowSummary(secondaryLabel, snapshot.secondary));
    }
    const selectedModel = defaultModel;
    return {
      updatedAt: new Date().toISOString(),
      fiveHourRemainingPercent,
      weeklyRemainingPercent,
      fiveHourResetsAt: snapshot?.primary?.resetsAt || null,
      weeklyResetsAt: snapshot?.secondary?.resetsAt || null,
      effectiveRemainingPercent,
      selectedModel,
      thresholdPercent: miniThresholdPercent,
      summary: `cd: ${summaryParts.join(" ; ")}`,
    };
  }

  async readUsageSnapshot() {
    const usageFilePath = (await fileExists(fs, this.usageFilePath))
      ? this.usageFilePath
      : this.legacyUsageFilePath;
    if (!(await fileExists(fs, usageFilePath))) {
      return null;
    }
    try {
      const parsed = JSON.parse(await fs.readFile(usageFilePath, "utf8"));
      const usage = parsed?.codex || null;
      if (!usage || typeof usage !== "object") {
        return null;
      }
      return usage;
    } catch {
      return null;
    }
  }

  async writeUsageSnapshot(usage) {
    const tempPath = `${this.usageFilePath}.${process.pid}.tmp`;
    await fs.mkdir(path.dirname(this.usageFilePath), { recursive: true });
    await fs.writeFile(tempPath, JSON.stringify({ codex: usage }, null, 2));
    await fs.rename(tempPath, this.usageFilePath);
  }

  async fetchCodexRateLimits() {
    return await new Promise((resolve) => {
      const child = spawn("codex", ["app-server", "--listen", "stdio://"], {
        cwd: this.currentProjectRoot,
        env: process.env,
        stdio: ["pipe", "pipe", "pipe"],
      });
      let stdoutBuffer = "";
      let resolved = false;
      const request = (id, method, params) => {
        child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
      };
      const finish = (value) => {
        if (resolved) return;
        resolved = true;
        try {
          child.kill("SIGTERM");
        } catch {}
        resolve(value);
      };
      child.stdout.on("data", (chunk) => {
        stdoutBuffer += chunk.toString();
        const lines = stdoutBuffer.split(/\r?\n/);
        stdoutBuffer = lines.pop() || "";
        for (const line of lines) {
          const trimmed = line.trim();
          if (!trimmed) continue;
          try {
            const message = JSON.parse(trimmed);
            if (message?.id === 2) {
              finish(message.result || null);
              return;
            }
          } catch {}
        }
      });
      child.on("error", () => finish(null));
      child.on("close", () => finish(null));
      request(1, "initialize", {
        clientInfo: { name: "nbtangInOne", version: "1.0.0" },
        capabilities: null,
      });
      request(2, "account/rateLimits/read", null);
      setTimeout(() => finish(null), 10000).unref?.();
    });
  }

  async refreshUsageSnapshot() {
    const payload = await this.fetchCodexRateLimits();
    const usage = this.parseUsageSnapshot(payload);
    if (!usage) {
      return await this.readUsageSnapshot();
    }
    await this.writeUsageSnapshot(usage);
    return usage;
  }

  async getModelSelection() {
    let usage = await this.readUsageSnapshot();
    if (!usage) {
      usage = await this.refreshUsageSnapshot();
    }
    const effectiveRemainingPercent = Number(usage?.effectiveRemainingPercent);
    const selectedModel = defaultModel;
    return { selectedModel, usage: usage ? { ...usage, selectedModel, thresholdPercent: miniThresholdPercent } : null };
  }

  async loadAttachmentsIndex() {
    if (!(await fileExists(fs, this.attachmentsIndexPath))) {
      return { entries: [] };
    }
    try {
      const parsed = JSON.parse(await fs.readFile(this.attachmentsIndexPath, "utf8"));
      const entries = Array.isArray(parsed.entries)
        ? parsed.entries
            .filter((item) => item && typeof item === "object" && typeof item.id === "string")
            .map((item) => ({
              id: item.id,
              sessionId: typeof item.sessionId === "string" ? item.sessionId : "",
              messageKey: typeof item.messageKey === "string" ? item.messageKey : "",
              prompt: typeof item.prompt === "string" ? item.prompt : "",
              createdAt: typeof item.createdAt === "string" ? item.createdAt : "",
              fileName: typeof item.fileName === "string" ? item.fileName : "",
              mimeType: typeof item.mimeType === "string" ? item.mimeType : "",
              size: Number.isFinite(item.size) ? item.size : 0,
              filePath: typeof item.filePath === "string" ? item.filePath : "",
            }))
        : [];
      return { entries };
    } catch {
      return { entries: [] };
    }
  }

  async saveAttachmentsIndex(state) {
    await this.ensureAttachmentsRoot();
    const entries = Array.isArray(state.entries) ? state.entries : [];
    await fs.writeFile(this.attachmentsIndexPath, JSON.stringify({ entries }, null, 2));
  }

  async loadAdapterConfig() {
    if (!(await fileExists(fs, this.configPath))) {
      return {
        hostId: "",
        hostName: "",
        projects: [],
        includeSessionDiscoveredProjects: true,
      };
    }

    try {
      const parsed = JSON.parse(await fs.readFile(this.configPath, "utf8"));
      return {
        hostId: typeof parsed.hostId === "string" ? parsed.hostId : "",
        hostName: typeof parsed.hostName === "string" ? parsed.hostName : "",
        includeSessionDiscoveredProjects: parsed.includeSessionDiscoveredProjects !== false,
        projects: Array.isArray(parsed.projects)
          ? parsed.projects
              .filter((item) => item && typeof item === "object" && typeof item.cwd === "string")
              .map((item) => ({
                id: typeof item.id === "string" && item.id ? item.id : this.toProjectId(item.cwd),
                name: typeof item.name === "string" && item.name ? item.name : path.basename(item.cwd) || item.cwd,
                cwd: item.cwd,
              }))
          : [],
      };
    } catch {
      return {
        hostId: "",
        hostName: "",
        projects: [],
        includeSessionDiscoveredProjects: true,
      };
    }
  }

  async loadAppState() {
    const stateFilePath = (await fileExists(fs, this.stateFilePath))
      ? this.stateFilePath
      : this.legacyStateFilePath;
    if (!(await fileExists(fs, stateFilePath))) {
      return { hiddenSessions: [], projects: [] };
    }

    try {
      const text = await fs.readFile(stateFilePath, "utf8");
      const parsed = JSON.parse(text);
      const hiddenSessions = Array.isArray(parsed.hiddenSessions)
        ? parsed.hiddenSessions
            .filter((item) => item && typeof item === "object" && typeof item.id === "string")
            .map((item) => ({
              id: item.id,
              projectId: typeof item.projectId === "string" ? item.projectId : "",
              projectName: typeof item.projectName === "string" ? item.projectName : "",
              cwd: typeof item.cwd === "string" ? item.cwd : "",
              title: typeof item.title === "string" ? item.title : "",
              lastActivity: typeof item.lastActivity === "string" ? item.lastActivity : "",
              hiddenAt: typeof item.hiddenAt === "string" ? item.hiddenAt : "",
            }))
        : [];
      const legacyHiddenIds = Array.isArray(parsed.hiddenSessionIds)
        ? parsed.hiddenSessionIds.filter((item) => typeof item === "string")
        : [];

      return {
        hiddenSessions: [
          ...hiddenSessions,
          ...legacyHiddenIds
            .filter((id) => !hiddenSessions.some((item) => item.id === id))
            .map((id) => ({
              id,
              projectId: "",
              projectName: "",
              cwd: "",
              title: "",
              lastActivity: "",
              hiddenAt: "",
            })),
        ],
        projects: Array.isArray(parsed.projects)
          ? parsed.projects
              .filter((item) => item && typeof item === "object" && typeof item.cwd === "string")
              .map((item) => ({
                id: typeof item.id === "string" && item.id ? item.id : this.toProjectId(item.cwd),
                name: typeof item.name === "string" && item.name ? item.name : path.basename(item.cwd) || item.cwd,
                cwd: typeof item.cwd === "string" ? item.cwd : "",
                addedAt: typeof item.addedAt === "string" ? item.addedAt : "",
              }))
          : [],
      };
    } catch {
      return { hiddenSessions: [], projects: [] };
    }
  }

  async saveAppState(state) {
    const hiddenSessions = Array.isArray(state.hiddenSessions)
      ? state.hiddenSessions
          .filter((item) => item && typeof item === "object" && typeof item.id === "string")
          .map((item) => ({
            id: item.id,
            projectId: typeof item.projectId === "string" ? item.projectId : "",
            projectName: typeof item.projectName === "string" ? item.projectName : "",
            cwd: typeof item.cwd === "string" ? item.cwd : "",
            title: typeof item.title === "string" ? item.title : "",
            lastActivity: typeof item.lastActivity === "string" ? item.lastActivity : "",
            hiddenAt: typeof item.hiddenAt === "string" ? item.hiddenAt : "",
          }))
      : [];

    const normalizedState = {
      hiddenSessions: Array.from(new Map(hiddenSessions.map((item) => [item.id, item])).values()).sort((a, b) =>
        a.id.localeCompare(b.id)
      ),
      projects: Array.isArray(state.projects)
        ? Array.from(
            new Map(
              state.projects
                .filter((item) => item && typeof item === "object" && typeof item.cwd === "string")
                .map((item) => [
                  this.toProjectId(item.cwd),
                  {
                    id: typeof item.id === "string" && item.id ? item.id : this.toProjectId(item.cwd),
                    name: typeof item.name === "string" && item.name ? item.name : path.basename(item.cwd) || item.cwd,
                    cwd: item.cwd,
                    addedAt: typeof item.addedAt === "string" ? item.addedAt : "",
                  },
                ])
            ).values()
          )
        : [],
    };

    await fs.mkdir(path.dirname(this.stateFilePath), { recursive: true });
    await fs.writeFile(this.stateFilePath, JSON.stringify(normalizedState, null, 2));
  }

  async hideSession(session) {
    const [state, projects] = await Promise.all([this.loadAppState(), this.loadProjects()]);
    const project = projects.find((item) => item.cwd === session.cwd);
    const hiddenSession = {
      id: session.id,
      projectId: project?.id || this.toProjectId(session.cwd || ""),
      projectName: project?.name || path.basename(session.cwd || "") || session.cwd || "",
      cwd: session.cwd || "",
      title: session.summary || "",
      lastActivity: session.lastActivity || "",
      hiddenAt: new Date().toISOString(),
    };

    const existingIndex = state.hiddenSessions.findIndex((item) => item.id === session.id);
    if (existingIndex >= 0) {
      state.hiddenSessions[existingIndex] = {
        ...state.hiddenSessions[existingIndex],
        ...hiddenSession,
      };
    } else {
      state.hiddenSessions.push(hiddenSession);
    }
    await this.saveAppState(state);
    this.invalidateCaches();
  }

  async walkFiles(rootDir) {
    const files = [];

    async function visit(dir) {
      const entries = await fs.readdir(dir, { withFileTypes: true });
      for (const entry of entries) {
        const fullPath = path.join(dir, entry.name);
        if (entry.isDirectory()) {
          await visit(fullPath);
        } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
          files.push(fullPath);
        }
      }
    }

    if (await fileExists(fs, rootDir)) {
      await visit(rootDir);
    }

    return files.sort();
  }

  safeJsonParse(line) {
    try {
      return JSON.parse(line);
    } catch {
      return null;
    }
  }

  async findSessionFile(sessionId) {
    if (!sessionId) return null;
    try {
      const today = new Date();
      for (let offset = 0; offset <= 3; offset++) {
        const d = new Date(today);
        d.setDate(d.getDate() - offset);
        const yyyy = d.getFullYear();
        const mm = String(d.getMonth() + 1).padStart(2, "0");
        const dd = String(d.getDate()).padStart(2, "0");
        const dateDir = path.join(this.sessionsRoot, String(yyyy), mm, dd);
        if (!(await fileExists(fs, dateDir))) continue;
        const entries = await fs.readdir(dateDir, { withFileTypes: true });
        for (const entry of entries) {
          if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
          const filePath = path.join(dateDir, entry.name);
          try {
            const raw = await fs.readFile(filePath, "utf8");
            const firstLine = raw.split("\n")[0];
            const meta = this.safeJsonParse(firstLine);
            if (meta?.type === "session_meta" && meta?.payload?.id === sessionId) {
              return filePath;
            }
          } catch {}
        }
      }
    } catch {}
    return null;
  }

  async stripHandoffFromSession(sessionId) {
    if (!sessionId) return;
    let sessionFile;
    try {
      sessionFile = await this.findSessionFile(sessionId);
      if (!sessionFile) return;
      const content = await fs.readFile(sessionFile, "utf8");
      const lines = content.split("\n");
      let modified = false;
      const newLines = lines.map((line) => {
        if (!line.trim()) return line;
        const entry = this.safeJsonParse(line);
        if (!entry) return line;
        const contentArr = entry?.payload?.content;
        if (!Array.isArray(contentArr)) return line;
        let entryModified = false;
        const newContent = contentArr.map((part) => {
          if (part?.type === "input_text" && typeof part.text === "string" && /\[handoff\]/i.test(part.text)) {
            const newText = part.text
              .replace(/\[handoff\][\s\S]*?\[\/handoff\]\s*\n*\s*当前用户请求:\s*/gi, "")
              .trimStart();
            if (newText !== part.text) {
              entryModified = true;
              return { ...part, text: newText };
            }
          }
          return part;
        });
        if (entryModified) {
          modified = true;
          return JSON.stringify({ ...entry, payload: { ...entry.payload, content: newContent } });
        }
        return line;
      });
      if (modified) {
        await fs.writeFile(sessionFile, newLines.join("\n"));
        console.log(`[runtime] stripped handoff from session file sessionId=${sessionId} file=${sessionFile}`);
      }
    } catch (err) {
      console.error(`[runtime] stripHandoffFromSession error sessionId=${sessionId}:`, err.message);
    }
  }

  extractTextContent(content) {
    if (!Array.isArray(content)) {
      return "";
    }
    return content
      .map((part) => {
        if (!part || typeof part !== "object") return "";
        return typeof part.text === "string" ? part.text : "";
      })
      .filter(Boolean)
      .join("\n")
      .trim();
  }

  isMeaningfulText(value) {
    if (!value) return false;
    const trimmed = value.trim();
    if (!trimmed) return false;
    if (trimmed.includes("<environment_context>") && trimmed.includes("</environment_context>")) {
      const stripped = trimmed.replace(/<environment_context>[\s\S]*?<\/environment_context>/g, "").trim();
      return Boolean(stripped);
    }
    return true;
  }

  summarizeText(value) {
    const trimmed = value.trim().replace(/\s+/g, " ");
    return trimmed.length > 140 ? `${trimmed.slice(0, 137)}...` : trimmed;
  }

  normalizeUserMessageText(value) {
    return String(value || "")
      .trimStart()
      .replace(/^\[handoff\][\s\S]*?\[\/handoff\]\s*/i, "")
      .replace(/^当前用户请求:\s*/i, "")
      .replace(/^Current user request:\s*/i, "")
      .replace(/<image[\s\S]*?<\/image>\s*/g, "")
      .trim();
  }

  isInternalUserBootstrapText(value) {
    const normalized = this.normalizeUserMessageText(value);
    if (!normalized) return true;
    if (/^# AGENTS\.md instructions for\b/.test(normalized)) {
      return true;
    }
    if (/^<turn_aborted>[\s\S]*<\/turn_aborted>$/.test(normalized)) {
      return true;
    }
    return false;
  }

  async parseSessionFile(filePath) {
    const text = await fs.readFile(filePath, "utf8");
    const lines = text.split("\n").filter(Boolean);

    let meta = null;
    let lastTimestamp = "";
    let summary = "";
    const messages = [];

    for (const line of lines) {
      const entry = this.safeJsonParse(line);
      if (!entry) continue;
      lastTimestamp = entry.timestamp || lastTimestamp;

      if (entry.type === "session_meta" && entry.payload) {
        meta = entry.payload;
        continue;
      }

      if (entry.type === "response_item" && entry.payload?.type === "message") {
        const role = entry.payload.role;
        const textValue = this.extractTextContent(entry.payload.content);
        if ((role === "user" || role === "assistant") && this.isMeaningfulText(textValue)) {
          const item = {
            role,
            text: textValue,
            timestamp: entry.timestamp || "",
          };
          messages.push(item);
          if (!summary && role === "user" && !this.isInternalUserBootstrapText(textValue)) {
            summary = this.summarizeText(this.normalizeUserMessageText(textValue));
          }
        }
      }
    }

    if (!meta?.id) {
      return null;
    }

    let cwd = meta.cwd || "";
    if (cwd) {
      try {
        cwd = await fs.realpath(cwd);
      } catch {
        cwd = meta.cwd || "";
      }
    }

    if (!summary) {
      const assistantMessage = messages.find((message) => message.role === "assistant");
      if (assistantMessage) {
        summary = this.summarizeText(assistantMessage.text);
      }
    }

    return {
      id: meta.id,
      cwd,
      model: meta.model_slug || meta.model || "default",
      source: meta.source || "unknown",
      startedAt: meta.timestamp || "",
      lastActivity: lastTimestamp || meta.timestamp || "",
      summary,
      filePath,
      messages,
      messageCount: messages.length,
      isEmpty: messages.length === 0 || !summary,
    };
  }

  async loadAllSessions() {
    const files = await this.walkFiles(this.sessionsRoot);
    const sessions = [];
    for (const filePath of files) {
      const parsed = await this.parseSessionFile(filePath);
      if (parsed && !parsed.isEmpty) {
        sessions.push(parsed);
      }
    }
    sessions.sort((a, b) => String(b.lastActivity).localeCompare(String(a.lastActivity)));
    return sessions;
  }

  async loadSessions(options = {}) {
    const useCache = options.useCache !== false;
    const now = Date.now();
    if (useCache && this.sessionsCache.value && this.sessionsCache.expiresAt > now) {
      return this.sessionsCache.value;
    }
    if (useCache && this.sessionsCache.promise) {
      return this.sessionsCache.promise;
    }

    const loadPromise = Promise.all([this.loadAllSessions(), this.loadAppState()])
      .then(([sessions, state]) => {
        const hiddenIds = new Set(state.hiddenSessions.map((item) => item.id));
        const visibleSessions = sessions.filter((session) => !hiddenIds.has(session.id));
        this.sessionsCache.value = visibleSessions;
        this.sessionsCache.expiresAt = Date.now() + sessionsCacheTtlMs;
        return visibleSessions;
      })
      .finally(() => {
        this.sessionsCache.promise = null;
      });

    this.sessionsCache.promise = loadPromise;
    const sessions = await loadPromise;
    if (!useCache) {
      this.sessionsCache.expiresAt = 0;
    }
    return sessions;
  }

  invalidateCaches() {
    this.sessionsCache.expiresAt = 0;
    this.hostStatusCache.expiresAt = 0;
  }

  async readCodexVersion() {
    return new Promise((resolve) => {
      const child = spawn("codex", ["-V"], { stdio: ["ignore", "pipe", "ignore"] });
      let data = "";
      child.stdout.on("data", (chunk) => {
        data += chunk.toString();
      });
      child.on("close", () => resolve(data.trim() || "unknown"));
      child.on("error", () => resolve("unavailable"));
    });
  }

  async loadHealthStatus(options = {}) {
    const config = await this.loadAdapterConfig();
    const activeRuns = Array.from(this.runs.values()).filter((run) => run.status === "running").length;
    const cachedStatus = this.hostStatusCache.value;
    let usage = options.forceRefresh === true ? await this.refreshUsageSnapshot() : await this.readUsageSnapshot();
    if (usage && (usage.fiveHourResetsAt == null || usage.weeklyResetsAt == null)) {
      usage = await this.refreshUsageSnapshot();
    }
    return {
      ok: true,
      id: config.hostId || os.hostname(),
      hostname: config.hostName || os.hostname(),
      sessionCount: cachedStatus?.sessionCount ?? null,
      activeRuns,
      adapterLabel: this.hostListenLabel,
      currentProjectRoot: this.currentProjectRoot,
      usage,
      checkedAt: new Date().toISOString(),
    };
  }

  toProjectId(cwd) {
    return Buffer.from(cwd).toString("base64url");
  }

  sessionToListItem(session) {
    return {
      id: session.id,
      cwd: session.cwd,
      model: session.model,
      source: session.source,
      startedAt: session.startedAt,
      lastActivity: session.lastActivity,
      summary: session.summary,
      filePath: session.filePath,
      messageCount: session.messageCount,
      recentMessages: session.messages.slice(-6),
    };
  }

  async loadProjects() {
    const [sessions, config, state] = await Promise.all([this.loadSessions(), this.loadAdapterConfig(), this.loadAppState()]);
    const projectMap = new Map();

    for (const configuredProject of config.projects) {
      let cwd = configuredProject.cwd;
      try {
        cwd = await fs.realpath(configuredProject.cwd);
      } catch {
        cwd = configuredProject.cwd;
      }
      projectMap.set(configuredProject.id, {
        id: configuredProject.id,
        name: configuredProject.name,
        cwd,
        sessionCount: 0,
        lastActivity: "",
      });
    }

    for (const savedProject of state.projects || []) {
      let cwd = savedProject.cwd;
      try {
        cwd = await fs.realpath(savedProject.cwd);
      } catch {}
      const projectId = typeof savedProject.id === "string" && savedProject.id ? savedProject.id : this.toProjectId(cwd);
      if (!projectMap.has(projectId) && !Array.from(projectMap.values()).some((item) => item.cwd === cwd)) {
        projectMap.set(projectId, {
          id: projectId,
          name: savedProject.name || path.basename(cwd) || cwd,
          cwd,
          sessionCount: 0,
          lastActivity: "",
        });
      }
    }

    if (config.includeSessionDiscoveredProjects !== false) {
      for (const session of sessions) {
        if (!session.cwd) continue;
        const existing = Array.from(projectMap.values()).find((item) => item.cwd === session.cwd);
        if (!existing) {
          const projectId = this.toProjectId(session.cwd);
          projectMap.set(projectId, {
            id: projectId,
            name: path.basename(session.cwd) || session.cwd,
            cwd: session.cwd,
            sessionCount: 0,
            lastActivity: session.lastActivity,
          });
        }
      }
    }

    const currentProjectId = this.toProjectId(this.currentProjectRoot);
    if (!projectMap.has(currentProjectId) && !Array.from(projectMap.values()).some((item) => item.cwd === this.currentProjectRoot)) {
      projectMap.set(currentProjectId, {
        id: currentProjectId,
        name: path.basename(this.currentProjectRoot) || this.currentProjectRoot,
        cwd: this.currentProjectRoot,
        sessionCount: 0,
        lastActivity: "",
      });
    }

    for (const session of sessions) {
      const project = Array.from(projectMap.values()).find((item) => item.cwd === session.cwd);
      if (!project) continue;
      project.sessionCount += 1;
      if (String(session.lastActivity) > String(project.lastActivity)) {
        project.lastActivity = session.lastActivity;
      }
    }

    return Array.from(projectMap.values()).sort((a, b) => String(b.lastActivity).localeCompare(String(a.lastActivity)));
  }

  getAllowedProjectRoots() {
    return Array.from(new Set([os.homedir(), "/mnt"].filter(Boolean).map((item) => path.resolve(item))));
  }

  isAllowedProjectPath(targetPath) {
    const resolved = path.resolve(targetPath);
    return this.getAllowedProjectRoots().some((root) => resolved === root || resolved.startsWith(root + path.sep));
  }

  async registerProject({ cwd, createIfMissing = false, name = "" } = {}) {
    const rawCwd = typeof cwd === "string" ? cwd.trim() : "";
    if (!rawCwd) {
      const error = new Error("Project path is required");
      error.statusCode = 400;
      throw error;
    }
    if (!path.isAbsolute(rawCwd)) {
      const error = new Error("Project path must be absolute");
      error.statusCode = 400;
      throw error;
    }
    const normalizedPath = path.resolve(rawCwd);
    if (!this.isAllowedProjectPath(normalizedPath)) {
      const error = new Error("Project path is outside allowed roots");
      error.statusCode = 403;
      throw error;
    }
    if (createIfMissing) {
      await fs.mkdir(normalizedPath, { recursive: true });
    }
    let stats;
    try {
      stats = await fs.stat(normalizedPath);
    } catch {
      const error = new Error("Project path does not exist");
      error.statusCode = 404;
      throw error;
    }
    if (!stats.isDirectory()) {
      const error = new Error("Project path must be a directory");
      error.statusCode = 400;
      throw error;
    }

    let resolvedCwd = normalizedPath;
    try {
      resolvedCwd = await fs.realpath(normalizedPath);
    } catch {}

    const project = {
      id: this.toProjectId(resolvedCwd),
      name: typeof name === "string" && name.trim() ? name.trim() : path.basename(resolvedCwd) || resolvedCwd,
      cwd: resolvedCwd,
      addedAt: new Date().toISOString(),
    };
    const state = await this.loadAppState();
    state.projects = [
      project,
      ...(Array.isArray(state.projects) ? state.projects.filter((item) => item.id !== project.id && item.cwd !== project.cwd) : []),
    ];
    await this.saveAppState(state);
    this.invalidateCaches();
    return {
      id: project.id,
      name: project.name,
      cwd: project.cwd,
      sessionCount: 0,
      lastActivity: "",
    };
  }

  async loadHostStatus(options = {}) {
    const useCache = options.useCache !== false;
    const now = Date.now();
    if (useCache && this.hostStatusCache.value && this.hostStatusCache.expiresAt > now) {
      return {
        ...this.hostStatusCache.value,
        activeRuns: Array.from(this.runs.values()).filter((run) => run.status === "running").length,
      };
    }
    if (useCache && this.hostStatusCache.promise) {
      const cached = await this.hostStatusCache.promise;
      return {
        ...cached,
        activeRuns: Array.from(this.runs.values()).filter((run) => run.status === "running").length,
      };
    }

    const loadPromise = Promise.all([this.loadAdapterConfig(), this.readCodexVersion(), this.loadSessions({ useCache })])
      .then(([config, version, sessions]) => {
        const status = {
          id: config.hostId || os.hostname(),
          hostname: config.hostName || os.hostname(),
          codexVersion: version,
          codexHome: this.codexHome,
          sessionsRoot: this.sessionsRoot,
          sessionCount: sessions.length,
          activeRuns: Array.from(this.runs.values()).filter((run) => run.status === "running").length,
          currentProjectRoot: this.currentProjectRoot,
          adapterConfigPath: this.configPath,
          adapterLabel: this.hostListenLabel,
        };
        this.hostStatusCache.value = status;
        this.hostStatusCache.expiresAt = Date.now() + hostStatusCacheTtlMs;
        return status;
      })
      .finally(() => {
        this.hostStatusCache.promise = null;
      });

    this.hostStatusCache.promise = loadPromise;
    const status = await loadPromise;
    if (!useCache) {
      this.hostStatusCache.expiresAt = 0;
    }
    return status;
  }

  createRun(command, args, cwd, sessionId = null) {
    const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
    const child = spawn(command, args, {
      cwd,
      env: process.env,
      stdio: ["ignore", "pipe", "pipe"],
    });

    const run = {
      id: runId,
      status: "running",
      startedAt: new Date().toISOString(),
      cwd,
      sessionId,
      command,
      args,
      events: [],
      nextEventSeq: 1,
      clients: new Set(),
      child,
      threadId: null,
      finishedAt: null,
    };
    this.runs.set(runId, run);

    const publish = (event) => {
      const eventWithSeq = { seq: run.nextEventSeq++, ...event };
      run.events.push(eventWithSeq);
      if (run.events.length > 500) {
        run.events.shift();
      }
      const payload = `data: ${JSON.stringify(eventWithSeq)}\n\n`;
      for (const client of run.clients) {
        client.write(payload);
      }
    };

    const parseStdoutLine = (line) => {
      const trimmed = line.trim();
      if (!trimmed) return;
      const parsed = this.safeJsonParse(trimmed);
      if (parsed) {
        if (parsed.type === "thread.started" && parsed.thread_id) {
          run.threadId = parsed.thread_id;
        }
        publish({ stream: "stdout", ...parsed });
        return;
      }
      publish({ stream: "stdout", type: "raw", text: trimmed });
    };

    let stdoutBuffer = "";
    child.stdout.on("data", (chunk) => {
      stdoutBuffer += chunk.toString();
      const parts = stdoutBuffer.split("\n");
      stdoutBuffer = parts.pop() || "";
      for (const line of parts) parseStdoutLine(line);
    });

    let stderrBuffer = "";
    child.stderr.on("data", (chunk) => {
      stderrBuffer += chunk.toString();
      const parts = stderrBuffer.split("\n");
      stderrBuffer = parts.pop() || "";
      for (const line of parts) {
        const trimmed = line.trim();
        if (trimmed) publish({ stream: "stderr", type: "raw", text: trimmed });
      }
    });

    child.on("close", (code, signal) => {
      run.status = code === 0 ? "completed" : "failed";
      run.finishedAt = new Date().toISOString();
      this.invalidateCaches();
      publish({
        type: "run.completed",
        code,
        signal,
        status: run.status,
        threadId: run.threadId || null,
      });
    });

    child.on("error", (error) => {
      run.status = "failed";
      run.finishedAt = new Date().toISOString();
      this.invalidateCaches();
      publish({
        type: "run.error",
        message: error.message,
      });
    });

    return run;
  }

  getRun(runId) {
    return this.runs.get(runId) || null;
  }

  getRunSummary(runId) {
    const run = this.getRun(runId);
    if (!run) return null;
    return {
      id: run.id,
      status: run.status,
      startedAt: run.startedAt,
      finishedAt: run.finishedAt,
      cwd: run.cwd,
      sessionId: run.sessionId,
      threadId: run.threadId,
      command: run.command,
      args: run.args,
    };
  }

  async ensureAttachmentsRoot() {
    await fs.mkdir(this.attachmentsRoot, { recursive: true });
  }

  async ensureSessionAttachmentDir(sessionId) {
    const dir = path.join(this.attachmentsRoot, sessionId);
    await fs.mkdir(dir, { recursive: true });
    return dir;
  }

  normalizeAttachmentName(fileName = "", mimeType = "") {
    const baseName = path.basename(String(fileName || "image")).replace(/[^a-zA-Z0-9._-]/g, "_") || "image";
    const extension = attachmentExtensionByMime[mimeType] || path.extname(baseName) || ".bin";
    const stem = baseName.replace(/\.[^.]+$/, "") || "image";
    return `${stem}${extension}`;
  }

  async createAttachment({ fileName, mimeType, contentBase64 }) {
    if (!mimeType || !attachmentExtensionByMime[mimeType]) {
      const error = new Error("Unsupported image type");
      error.statusCode = 400;
      throw error;
    }
    if (!contentBase64 || typeof contentBase64 !== "string") {
      const error = new Error("Attachment content is required");
      error.statusCode = 400;
      throw error;
    }

    const buffer = Buffer.from(contentBase64, "base64");
    if (!buffer.length) {
      const error = new Error("Attachment content is empty");
      error.statusCode = 400;
      throw error;
    }
    if (buffer.length > maxAttachmentBytes) {
      const error = new Error("Attachment exceeds 10MB limit");
      error.statusCode = 400;
      throw error;
    }

    await this.ensureAttachmentsRoot();
    const id = crypto.randomUUID();
    const normalizedName = this.normalizeAttachmentName(fileName, mimeType);
    const filePath = path.join(this.attachmentsRoot, `${id}-${normalizedName}`);
    await fs.writeFile(filePath, buffer);

    const attachment = {
      id,
      fileName: normalizedName,
      mimeType,
      size: buffer.length,
      filePath,
      createdAt: new Date().toISOString(),
    };
    this.attachments.set(id, attachment);
    console.log(
      `[runtime] attachment stored attachmentId=${attachment.id} filePath=${attachment.filePath} mimeType=${attachment.mimeType} size=${attachment.size}`
    );
    return {
      id: attachment.id,
      fileName: attachment.fileName,
      mimeType: attachment.mimeType,
      size: attachment.size,
      createdAt: attachment.createdAt,
    };
  }

  async removeAttachment(id) {
    const attachment = this.attachments.get(id);
    if (attachment) {
      this.attachments.delete(id);
      await fs.rm(attachment.filePath, { force: true });
      console.log(`[runtime] staged attachment removed attachmentId=${id} filePath=${attachment.filePath}`);
      return true;
    }

    const index = await this.loadAttachmentsIndex();
    const entry = index.entries.find((item) => item.id === id);
    if (!entry) return false;
    index.entries = index.entries.filter((item) => item.id !== id);
    await fs.rm(entry.filePath, { force: true });
    await this.saveAttachmentsIndex(index);
    console.log(`[runtime] persisted attachment removed attachmentId=${id} filePath=${entry.filePath}`);
    return true;
  }

  async resolveAttachments(attachmentIds) {
    const ids = Array.isArray(attachmentIds) ? attachmentIds.filter((item) => typeof item === "string" && item) : [];
    if (ids.length > maxAttachmentCount) {
      const error = new Error("Too many attachments");
      error.statusCode = 400;
      throw error;
    }

    const resolved = [];
    for (const id of ids) {
      const staged = this.attachments.get(id);
      if (staged) { resolved.push(staged); continue; }
      const persisted = await this.getPersistedAttachment(id);
      if (!persisted) {
        const error = new Error("Attachment not found or expired");
        error.statusCode = 404;
        throw error;
      }
      resolved.push(persisted);
    }
    return resolved;
  }

  async cleanupFiles(filePaths) {
    await Promise.all(
      (Array.isArray(filePaths) ? filePaths : []).map(async (filePath) => {
        try {
          await fs.rm(filePath, { force: true });
          console.log(`[runtime] attachment cleanup removed filePath=${filePath}`);
        } catch (error) {
          console.warn(`[runtime] attachment cleanup failed filePath=${filePath} error=${error instanceof Error ? error.message : String(error)}`);
        }
      })
    );
  }

  async archiveRunAttachments({ sessionId, prompt, createdAt, attachments }) {
    if (!sessionId || !attachments.length) return;
    const index = await this.loadAttachmentsIndex();
    const sessionDir = await this.ensureSessionAttachmentDir(sessionId);
    const messageKey = crypto.randomUUID();
    const archivedEntries = [];

    for (const attachment of attachments) {
      const normalizedName = this.normalizeAttachmentName(attachment.fileName, attachment.mimeType);
      const targetPath = path.join(sessionDir, `${attachment.id}-${normalizedName}`);
      if (attachment.filePath !== targetPath) {
        await fs.rename(attachment.filePath, targetPath);
      }
      archivedEntries.push({
        id: attachment.id,
        sessionId,
        messageKey,
        prompt,
        createdAt,
        fileName: normalizedName,
        mimeType: attachment.mimeType,
        size: attachment.size,
        filePath: targetPath,
      });
      console.log(
        `[runtime] attachment archived attachmentId=${attachment.id} sessionId=${sessionId} messageKey=${messageKey} filePath=${targetPath}`
      );
    }

    index.entries.push(...archivedEntries);

    const sessionEntries = index.entries
      .filter((item) => item.sessionId === sessionId)
      .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));

    if (sessionEntries.length > maxPersistedSessionImages) {
      const overflow = sessionEntries.slice(0, sessionEntries.length - maxPersistedSessionImages);
      const overflowIds = new Set(overflow.map((item) => item.id));
      for (const item of overflow) {
        await fs.rm(item.filePath, { force: true });
        console.log(`[runtime] attachment evicted attachmentId=${item.id} sessionId=${sessionId} filePath=${item.filePath}`);
      }
      index.entries = index.entries.filter((item) => !overflowIds.has(item.id));
    }

    await this.saveAttachmentsIndex(index);
  }

  async getSessionAttachmentGroups(sessionId, messages) {
    const index = await this.loadAttachmentsIndex();
    const sessionEntries = index.entries
      .filter((item) => item.sessionId === sessionId)
      .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));

    const groups = new Map();
    for (const entry of sessionEntries) {
      const current = groups.get(entry.messageKey) || {
        messageKey: entry.messageKey,
        prompt: entry.prompt,
        createdAt: entry.createdAt,
        attachments: [],
      };
      current.attachments.push({
        id: entry.id,
        fileName: entry.fileName,
        mimeType: entry.mimeType,
        size: entry.size,
        createdAt: entry.createdAt,
      });
      groups.set(entry.messageKey, current);
    }

    const orderedGroups = Array.from(groups.values()).sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
    let groupIndex = 0;
    const enrichedMessages = (messages || []).map((message) => {
      if (message.role !== "user") {
        return message;
      }
      const clone = { ...message, attachments: [] };
      const group = orderedGroups[groupIndex];
      if (group && group.prompt === this.normalizeUserMessageText(message.text)) {
        clone.attachments = group.attachments;
        clone.messageKey = group.messageKey;
        groupIndex += 1;
      }
      return clone;
    });
    return enrichedMessages;
  }

  async getPersistedAttachment(id) {
    return lookupAttachment(id, this.attachments, this.runs.values(), () => this.loadAttachmentsIndex());
  }

  async startRun({ mode, projectId, sessionId, prompt, attachmentIds = [], cwd: cwdParam, handoffSummary = "" }) {
    const projects = await this.loadProjects();
    let project = projects.find((item) => item.id === projectId);
    if (!project && cwdParam) {
      project = projects.find((item) => item.cwd === cwdParam)
        || { id: null, cwd: cwdParam, name: path.basename(cwdParam) || cwdParam };
    }
    if (!project) {
      const error = new Error("Project not found");
      error.statusCode = 404;
      throw error;
    }
    if (!prompt || typeof prompt !== "string") {
      const error = new Error("Prompt is required");
      error.statusCode = 400;
      throw error;
    }

    const attachments = await this.resolveAttachments(attachmentIds);
    const imageArgs = attachments.flatMap((attachment) => ["--image", attachment.filePath]);
    const { selectedModel, usage } = await this.getModelSelection();
    const promptWithHandoff = buildPromptWithHandoff(prompt, handoffSummary);
    console.log(
      `[runtime] preparing run mode=${mode} projectId=${projectId} sessionId=${sessionId || ""} model=${selectedModel} usageRemaining=${usage?.effectiveRemainingPercent ?? "n/a"} attachmentCount=${attachments.length} attachmentIds=${attachments.map((item) => item.id).join(",")}`
    );

    let args;
    if (mode === "resume") {
      if (!sessionId) {
        const error = new Error("Session is required for resume");
        error.statusCode = 400;
        throw error;
      }
      args = [
        "-C",
        project.cwd,
        "exec",
        "resume",
        "-m",
        selectedModel,
        "-c",
        `model_reasoning_effort="${defaultReasoningEffort}"`,
        "--all",
        "--json",
        "--skip-git-repo-check",
        "--dangerously-bypass-approvals-and-sandbox",
        ...imageArgs,
        "--",
        sessionId,
        promptWithHandoff,
      ];
    } else {
      args = [
        "-C",
        project.cwd,
        "exec",
        "-m",
        selectedModel,
        "-c",
        `model_reasoning_effort="${defaultReasoningEffort}"`,
        "--json",
        "--skip-git-repo-check",
        "--dangerously-bypass-approvals-and-sandbox",
        ...imageArgs,
        "--",
        promptWithHandoff,
      ];
    }

    const run = this.createRun("codex", args, project.cwd, sessionId || null);
    for (const attachment of attachments) {
      this.attachments.delete(attachment.id);
    }
    console.log(
      `[runtime] run created runId=${run.id} command=${["codex", ...args].join(" ")}`
    );
    run.pendingAttachments = attachments.map((attachment) => ({ ...attachment }));
    run.prompt = prompt;
    run.handoffSummary = handoffSummary || "";
    const cleanup = async () => {
      const targetSessionId = run.threadId || sessionId || null;
      const pending = run.pendingAttachments || [];
      if (run.status === "completed" && targetSessionId && pending.length) {
        await this.archiveRunAttachments({
          sessionId: targetSessionId,
          prompt,
          createdAt: run.startedAt,
          attachments: pending,
        });
      } else {
        await this.cleanupFiles(pending.map((attachment) => attachment.filePath));
      }
      run.pendingAttachments = null;
      if (run.status === "completed" && targetSessionId && run.handoffSummary) {
        await this.stripHandoffFromSession(targetSessionId);
      }
    };
    run.child.once("close", () => {
      this.invalidateCaches();
      void cleanup();
      void this.refreshUsageSnapshot();
    });
    run.child.once("error", () => {
      this.invalidateCaches();
      void cleanup();
      void this.refreshUsageSnapshot();
    });

    return {
      runId: run.id,
      cwd: project.cwd,
      command: ["codex", ...args].join(" "),
      model: selectedModel,
      usage,
    };
  }
}