import { mkdir, readFile, rm } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";

import { OMP_RUNTIME_VERSION, PRODUCT_NAME, PRODUCT_VERSION } from "./constants.ts";
import { createPlatformExtension } from "./platform-extension.ts";
import { devflowAgentDir, devflowHome, pluginRoots, projectSessionDir, sharedOmpAgentDir } from "./paths.ts";
import type { ProjectContext } from "./project-context.ts";
import { injectOmpExtensionCliRoots } from "@oh-my-pi/pi-coding-agent/discovery/omp-extension-roots";

export interface RuntimeOptions {
  initialPrompt?: string;
  originalOmpAgentDir?: string;
  resume?: boolean | string;
  hasUI?: boolean;
  enableMCP?: boolean;
  enableLsp?: boolean;
  approvalMode?: "write" | "yolo";
}

const TOOL_CALL_SAFETY_PROMPT = `Devflow tool-call rules:
- Read always requires a non-empty string path; never call Read with {} or omit path.
- skill:// URLs identify one exact packaged resource. Never use Glob or Grep with a skill:// URL or wildcard; choose an exact resource and use Read.
- A generated project path such as .devflow/context/spec/frontend/index.md is not automatically a packaged skill template path.
- The project-context validator is a final gate. Do not run project-context-validate while files are still being planned or written.`;

const MANAGED_SETTINGS: Record<string, unknown> = {
  modelRoleStorage: "user",
  disabledProviders: ["claude", "codex", "cursor", "gemini", "github", "opencode"],
  "skills.enabled": true,
  "skills.enableSkillCommands": true,
  "skills.enableClaudeUser": false,
  "skills.enableClaudeProject": false,
  "skills.enableCodexUser": false,
  "skills.enablePiUser": false,
  "skills.enablePiProject": false,
  "skills.enableAgentsUser": false,
  "skills.enableAgentsProject": false,
  // The interactive session is the high-capability Planner. Low-cost agents
  // are dispatched only through explicit Devflow steps, one at a time by
  // default, so background work cannot silently widen a user's request.  The
  // parallel workflow window is explicitly capped at two workers, matching
  // its two declared tracks; serial workflows are unaffected.
  // Keep ordinary interactive turns on the user's DEFAULT role. In particular,
  // do not inherit SLOW just because a thinking model is configured.
  "defaultThinkingLevel": "low",
  // Parallel Devflow orchestration relies on OMP's AsyncJobManager and
  // subagent lifecycle channel. Keep this explicit instead of inheriting the
  // SDK default so a global OMP config cannot silently disable it.
  "async.enabled": true,
  "task.batch": false,
  "task.maxConcurrency": 2,
  "task.maxRecursionDepth": 1,
  "task.enableLsp": true,
  // Keep cheap workers bounded by role prompts. OMP's loop guards remain
  // enabled, while these switches prevent accidental high-effort keywords from
  // widening a normal Devflow turn into an unbounded orchestration request.
  "magicKeywords.ultrathink": false,
  "magicKeywords.orchestrate": false,
  "magicKeywords.workflow": false,
  "task.agentModelOverrides": {
    "project-scout": "@smol",
    "requirements-auditor": "@slow",
    "contract-auditor": "@slow",
    "implementation-worker": "@task",
    "devflow-code-reviewer": "@slow",
    "verification-runner": "@task",
  },
  "task.isolation.mode": "auto",
  "task.isolation.apply": false,
  "lsp.enabled": true,
  "lsp.lazy": true,
  "lsp.diagnosticsOnWrite": true,
  // Devflow owns cross-session handoff through STATE.rollover. OMP's LLM-made
  // handoff would create a second, unauditable source of continuation facts.
  "compaction.strategy": "context-full",
  "compaction.handoffSaveToDisk": false,
};

export async function createEmbeddedRuntime(project: ProjectContext, options: RuntimeOptions = {}) {
  const productAgentDir = devflowAgentDir();
  const productHome = devflowHome();
  const sharedAgentDir = sharedOmpAgentDir(options.originalOmpAgentDir);
  const sessionDir = projectSessionDir(project.manifest.projectId);
  // OMP's logger uses its state root rather than PI_CODING_AGENT_DIR. Keep
  // embedded Devflow logs private as well, including in sandboxed test runs.
  await Promise.all([
    mkdir(productAgentDir, { recursive: true }),
    mkdir(sessionDir, { recursive: true }),
    mkdir(path.join(productHome, "omp"), { recursive: true }),
    mkdir(path.join(productHome, "logs"), { recursive: true }),
  ]);

  process.env.PI_CODING_AGENT_DIR = productAgentDir;
  process.env.XDG_STATE_HOME = productHome;
  process.env.DEVFLOW_RUNTIME_MODE = "embedded";
  process.env.DEVFLOW_PROJECT_ROOT = project.root;
  process.env.DEVFLOW_PLUGIN_ROOT = path.join(path.dirname(pluginRoots()[0]), "");
  process.env.DEVFLOW_SESSION_ROOT = sessionDir;

  // Configure logging before importing the OMP SDK. The SDK's default is
  // ~/.omp/logs, which would break the product/session isolation boundary.
  const utils = await import("@oh-my-pi/pi-utils");
  utils.logger.setTransports({ file: path.join(productHome, "logs") });
  const omp = await import("@oh-my-pi/pi-coding-agent");
  const roots = pluginRoots();
  // Task workers perform a fresh agent discovery after session creation. The
  // SDK's temporary extension-root scope does not cover that later lookup,
  // so register Devflow package roots as durable CLI roots for this process.
  // Without this, packaged agents such as implementation-worker appear during
  // startup but fail with Unknown agent when a parallel task is dispatched.
  injectOmpExtensionCliRoots(roots, homedir(), project.root, { mode: "explicit-only", replace: true });
  // Reuse credentials and model discovery from the user's OMP agent, but not
  // its config.yml: a global modelRoles block would override choices made in
  // Devflow's own Roles screen after every restart.
  const settings = await omp.Settings.init({
    cwd: project.root,
    agentDir: productAgentDir,
    overrides: {
      ...MANAGED_SETTINGS,
      "tools.approvalMode": options.approvalMode ?? "write",
      // OMP 17.2.7 classifies extension-package skills behind the generic
      // third-party toggle. Explicit custom roots keep ambient providers off
      // while guaranteeing the packaged Devflow skills are present.
      "skills.customDirectories": roots.map(root => path.join(root, "skills")),
    } as never,
  });
  const authStorage = await omp.discoverAuthStorage(sharedAgentDir);
  const modelRegistry = new omp.ModelRegistry(authStorage, path.join(sharedAgentDir, "models.yml"));
  modelRegistry.refreshInBackground();
  let sessionManager;
  if (typeof options.resume === "string") {
    const sessions = await omp.SessionManager.list(project.root, sessionDir);
    const exact = sessions.find(session => session.id === options.resume);
    const candidates = exact ? [exact] : sessions.filter(session => session.id.startsWith(options.resume as string));
    if (candidates.length === 0) throw new Error(`Devflow session not found in this project: ${options.resume}`);
    if (candidates.length > 1) throw new Error(`Devflow session id is ambiguous in this project: ${options.resume}`);
    const matched = candidates[0];
    sessionManager = await omp.SessionManager.open(matched.path, sessionDir);
  } else if (options.resume) {
    sessionManager = await omp.SessionManager.continueRecent(project.root, sessionDir);
  } else {
    sessionManager = omp.SessionManager.create(project.root, sessionDir);
  }
  const previousCliResumeSignal = process.env.DEVFLOW_CLI_SESSION_RESUME;
  if (options.resume) process.env.DEVFLOW_CLI_SESSION_RESUME = "1";
  else delete process.env.DEVFLOW_CLI_SESSION_RESUME;
  let runtime;
  try {
    runtime = await omp.createAgentSession({
      cwd: project.root,
      agentDir: productAgentDir,
      settings,
      authStorage,
      modelRegistry,
      sessionManager,
      additionalExtensionPaths: roots,
      disableExtensionDiscovery: true,
      extensions: [
        createPlatformExtension({
          sessionRoot: sessionDir,
          ompSessionRoot: path.join(sharedAgentDir, "sessions"),
          projectId: project.manifest.projectId,
          getConfiguredRole: role => settings.getModelRole(role),
        }),
      ],
      hasUI: options.hasUI ?? true,
      enableMCP: options.enableMCP ?? false,
      enableLsp: options.enableLsp ?? true,
      appendSystemPrompt: TOOL_CALL_SAFETY_PROMPT,
    });
  } finally {
    if (previousCliResumeSignal === undefined) delete process.env.DEVFLOW_CLI_SESSION_RESUME;
    else process.env.DEVFLOW_CLI_SESSION_RESUME = previousCliResumeSignal;
  }
  return { omp, runtime, sessionDir };
}

export async function runInteractiveRuntime(project: ProjectContext, options: RuntimeOptions = {}): Promise<void> {
  const { omp, runtime } = await createEmbeddedRuntime(project, {
    ...options,
    hasUI: true,
  });
  const { initTheme } = await import("@oh-my-pi/pi-coding-agent/modes/theme/theme");
  await initTheme(
    true,
    runtime.session.settings.get("symbolPreset"),
    runtime.session.settings.get("colorBlindMode"),
    runtime.session.settings.get("theme.dark"),
    runtime.session.settings.get("theme.light"),
  );
  const mode = new omp.InteractiveMode(
    runtime.session,
    `${OMP_RUNTIME_VERSION} · ${PRODUCT_NAME} ${PRODUCT_VERSION}`,
    undefined,
    runtime.setToolUIContext,
    runtime.lspServers,
    runtime.mcpManager,
    runtime.eventBus,
  );
  await mode.init({ clearInitialTerminalHistory: true });
  mode.renderInitialMessages({ preserveExistingChat: true, clearTerminalHistory: true });
  mode.showStatus(`Devflow project ${project.manifest.name} · ${project.manifest.projectId.slice(0, 8)}`);
  if (runtime.modelFallbackMessage) mode.showWarning(runtime.modelFallbackMessage);
  if (options.initialPrompt) await runtime.session.prompt(options.initialPrompt);
  // Extensions cannot create a session from lifecycle hooks.  The interactive
  // loop, however, owns AgentSession and reaches this point only after the
  // submitted turn has settled.  A request is merely a wake-up signal: its
  // authority remains STATE.rollover, which the extension verifies and claims
  // during session_switch before it starts the next workflow turn.
  const consumeControlledRollover = async (): Promise<void> => {
    if (rolloverConsumptionInFlight || interactiveSubmissionInFlight || runtime.session.isStreaming) return;
    rolloverConsumptionInFlight = true;
    try {
    const requestPath = path.join(project.root, ".devflow/planning", "rollover-request.json");
    let request: { schema_version?: number; rollover_id?: string; source_session_id?: string };
    let originalRequest: string;
    try {
      originalRequest = await readFile(requestPath, "utf8");
      request = JSON.parse(originalRequest) as typeof request;
    } catch {
      rolloverConsumptionInFlight = false;
      return;
    }
    if (
      request.schema_version !== 1 ||
      typeof request.rollover_id !== "string" ||
      !/^ROL-[0-9a-f-]+$/i.test(request.rollover_id) ||
      request.source_session_id !== runtime.session.sessionManager.getSessionId()
    ) {
      await rm(requestPath, { force: true });
      mode.showWarning("已忽略无效或陈旧的 Devflow 会话轮换请求；STATE 未被修改。");
      rolloverConsumptionInFlight = false;
      return;
    }
    // Session creation can briefly be unavailable while the interactive mode
    // finishes flushing the previous turn. Retry here because the handoff is
    // already durable and STATE remains frozen until the target consumes it.
    let switched = false;
    for (let attempt = 0; attempt < 3 && !switched; attempt += 1) {
      // newSession aborts a running turn; leave the durable request for the poller.
      if (interactiveSubmissionInFlight || runtime.session.isStreaming) return;
      switched = await runtime.session.newSession();
      if (!switched && attempt < 2) await new Promise(resolve => setTimeout(resolve, 100));
    }
    if (!switched) {
      mode.showWarning("Devflow 会话轮换被运行时取消；当前会话保持不变。请检查控制面状态后重试。");
      scheduleControlledRolloverRetry();
      rolloverConsumptionInFlight = false;
      return;
    }
    let requestAfterSwitch = "";
    try { requestAfterSwitch = await readFile(requestPath, "utf8"); } catch { /* success path normally has no replacement request */ }
    if (!requestAfterSwitch || requestAfterSwitch === originalRequest) {
      await rm(requestPath, { force: true });
    } else {
      scheduleControlledRolloverRetry();
    }
    rolloverConsumptionInFlight = false;
    } catch (error) {
      rolloverConsumptionInFlight = false;
      mode.showWarning(`Devflow 会话轮换运行时异常，将自动重试：${String(error)}`);
      scheduleControlledRolloverRetry();
    } finally {
      rolloverConsumptionInFlight = false;
    }
  };
  let interactiveSubmissionInFlight = false;
  let rolloverConsumptionInFlight = false;
  let rolloverRetryTimer: ReturnType<typeof setTimeout> | undefined;
  let rolloverRetryAttempt = 0;
  const scheduleControlledRolloverRetry = () => {
    if (rolloverRetryTimer) return;
    const delay = Math.min(30_000, 1_000 * 2 ** Math.min(rolloverRetryAttempt, 5));
    rolloverRetryAttempt += 1;
    rolloverRetryTimer = setTimeout(() => {
      rolloverRetryTimer = undefined;
      void consumeControlledRollover();
    }, delay);
  };
  const waitForControlledRollover = async (): Promise<void> => {
    const requestPath = path.join(project.root, ".devflow/planning", "rollover-request.json");
    let requestObserved = false;
    // The input hook may finish its async state transition just after
    // submitInteractiveInput resolves. Wait briefly for that durable request
    // so approval starts the fresh session without requiring another input.
    for (let attempt = 0; attempt < 50; attempt += 1) {
      try {
        await readFile(requestPath, "utf8");
        requestObserved = true;
      } catch {
        if (requestObserved) return;
      }
      await consumeControlledRollover();
      try {
        await readFile(requestPath, "utf8");
      } catch {
        if (requestObserved) return;
      }
      await new Promise(resolve => setTimeout(resolve, 100));
    }
    scheduleControlledRolloverRetry();
  };
  // A control-plane request may be created by an extension input handler that
  // marks the message handled, so it is not guaranteed to pass through the
  // normal submitInteractiveInput return path. Keep a small runtime-owned
  // poller alive for the lifetime of the interactive loop; it only touches the
  // filesystem when a durable rollover request exists.
  const rolloverPoller = setInterval(() => {
    void consumeControlledRollover();
  }, 500);
  rolloverPoller.unref?.();
  // A prepared handoff is a durable runtime request, not something that
  // should wait for the user to type another command after a restart.
  await consumeControlledRollover();
  while (true) {
    const input = await mode.getUserInput();
    interactiveSubmissionInFlight = true;
    try {
      await omp.submitInteractiveInput(mode, runtime.session, input);
    } finally {
      interactiveSubmissionInFlight = false;
    }
    // Only an explicit approval can create a user-driven rollover after this
    // input. Ordinary chat messages must return to the prompt immediately.
    if (/^(?:批准|确认|同意|通过)(?:\s*(?:(?:full|完整|全量)?\s*初始化|执行|完成|继续|推进|当前(?:方案|步骤|计划)))?[。！!]?$/i.test(input.trim())) {
      await waitForControlledRollover();
    } else {
      await consumeControlledRollover();
      scheduleControlledRolloverRetry();
    }
    // The submitted turn may create a durable rollover request after the
    // input hook settles; the bounded wait above handles the normal path and
    // the poller remains as a recovery path for a slow or interrupted hook.
  }
}
