import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
import {
  resolveControlUiDistIndexHealth,
  resolveControlUiDistIndexPathForRoot,
} from "./control-ui-assets.js";
import { readPackageName, readPackageVersion } from "./package-json.js";
import { normalizePackageTagInput } from "./package-tag.js";
import { trimLogTail } from "./restart-sentinel.js";
import { resolveStableNodePath } from "./stable-node-path.js";
import {
  channelToNpmTag,
  DEFAULT_PACKAGE_CHANNEL,
  DEV_BRANCH,
  isBetaTag,
  isStableTag,
  type UpdateChannel,
} from "./update-channels.js";
import { compareSemverStrings } from "./update-check.js";
import {
  collectInstalledGlobalPackageErrors,
  cleanupGlobalRenameDirs,
  createGlobalInstallEnv,
  detectGlobalInstallManagerForRoot,
  globalInstallArgs,
  globalInstallFallbackArgs,
  resolveExpectedInstalledVersionFromSpec,
  resolveGlobalInstallTarget,
  resolveGlobalInstallSpec,
} from "./update-global.js";
import {
  managerInstallIgnoreScriptsArgs,
  managerInstallArgs,
  managerScriptArgs,
  resolveUpdateBuildManager,
  type UpdatePackageManagerFailureReason,
} from "./update-package-manager.js";

export type UpdateStepResult = {
  name: string;
  command: string;
  cwd: string;
  durationMs: number;
  exitCode: number | null;
  stdoutTail?: string | null;
  stderrTail?: string | null;
};

export type UpdateRunResult = {
  status: "ok" | "error" | "skipped";
  mode: "git" | "pnpm" | "bun" | "npm" | "unknown";
  root?: string;
  reason?: string;
  before?: { sha?: string | null; version?: string | null };
  after?: { sha?: string | null; version?: string | null };
  steps: UpdateStepResult[];
  durationMs: number;
};

type CommandRunner = (
  argv: string[],
  options: CommandOptions,
) => Promise<{ stdout: string; stderr: string; code: number | null }>;

export type UpdateStepInfo = {
  name: string;
  command: string;
  index: number;
  total: number;
};

export type UpdateStepCompletion = UpdateStepInfo & {
  durationMs: number;
  exitCode: number | null;
  stderrTail?: string | null;
};

export type UpdateStepProgress = {
  onStepStart?: (step: UpdateStepInfo) => void;
  onStepComplete?: (step: UpdateStepCompletion) => void;
};

type UpdateRunnerOptions = {
  cwd?: string;
  argv1?: string;
  tag?: string;
  channel?: UpdateChannel;
  devTargetRef?: string;
  timeoutMs?: number;
  runCommand?: CommandRunner;
  progress?: UpdateStepProgress;
};

function mapManagerResolutionFailure(
  reason: UpdatePackageManagerFailureReason,
): UpdateRunResult["reason"] {
  return reason;
}

const DEFAULT_TIMEOUT_MS = 20 * 60_000;
const MAX_LOG_CHARS = 8000;
const PREFLIGHT_MAX_COMMITS = 10;
const START_DIRS = ["cwd", "argv1", "process"];
const DEFAULT_PACKAGE_NAME = "openclaw";
const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]);
const PREFLIGHT_TEMP_PREFIX =
  process.platform === "win32" ? "ocu-pf-" : "openclaw-update-preflight-";
const PREFLIGHT_WORKTREE_DIRNAME = process.platform === "win32" ? "wt" : "worktree";
const WINDOWS_PREFLIGHT_BASE_DIR = "ocu";
const WINDOWS_BUILD_MAX_OLD_SPACE_MB = 4096;

function normalizeDir(value?: string | null) {
  if (!value) {
    return null;
  }
  const trimmed = value.trim();
  if (!trimmed) {
    return null;
  }
  return path.resolve(trimmed);
}

function resolveNodeModulesBinPackageRoot(argv1: string): string | null {
  const normalized = path.resolve(argv1);
  const parts = normalized.split(path.sep);
  const binIndex = parts.lastIndexOf(".bin");
  if (binIndex <= 0) {
    return null;
  }
  if (parts[binIndex - 1] !== "node_modules") {
    return null;
  }
  const binName = path.basename(normalized);
  const nodeModulesDir = parts.slice(0, binIndex).join(path.sep);
  return path.join(nodeModulesDir, binName);
}

function buildStartDirs(opts: UpdateRunnerOptions): string[] {
  const dirs: string[] = [];
  const cwd = normalizeDir(opts.cwd);
  if (cwd) {
    dirs.push(cwd);
  }
  const argv1 = normalizeDir(opts.argv1);
  if (argv1) {
    dirs.push(path.dirname(argv1));
    const packageRoot = resolveNodeModulesBinPackageRoot(argv1);
    if (packageRoot) {
      dirs.push(packageRoot);
    }
  }
  const proc = normalizeDir(process.cwd());
  if (proc) {
    dirs.push(proc);
  }
  return Array.from(new Set(dirs));
}

function resolvePreflightTempRootPrefix() {
  return path.join(os.tmpdir(), PREFLIGHT_TEMP_PREFIX);
}

function resolvePreflightWorktreeDir(preflightRoot: string) {
  return path.join(preflightRoot, PREFLIGHT_WORKTREE_DIRNAME);
}

function shouldUseNativeWindowsTempRoot() {
  return process.platform === "win32" && path.sep === "\\";
}

async function createPreflightRoot() {
  if (shouldUseNativeWindowsTempRoot()) {
    const baseDir = path.win32.join(process.env.SystemDrive ?? "C:", WINDOWS_PREFLIGHT_BASE_DIR);
    await fs.mkdir(baseDir, { recursive: true });
    return fs.mkdtemp(path.win32.join(baseDir, PREFLIGHT_TEMP_PREFIX));
  }
  return fs.mkdtemp(resolvePreflightTempRootPrefix());
}

async function removePathRecursive(target: string) {
  await fs
    .rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
    .catch(() => {});
}

async function repairWindowsPreflightCleanup(worktreeDir: string, preflightRoot: string) {
  if (process.platform !== "win32") {
    return false;
  }
  try {
    await fs.rm(worktreeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
    await fs.rm(preflightRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
    return true;
  } catch {
    return false;
  }
}

async function readBranchName(
  runCommand: CommandRunner,
  root: string,
  timeoutMs: number,
): Promise<string | null> {
  const res = await runCommand(["git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"], {
    timeoutMs,
  }).catch(() => null);
  if (!res || res.code !== 0) {
    return null;
  }
  const branch = res.stdout.trim();
  return branch || null;
}

async function listGitTags(
  runCommand: CommandRunner,
  root: string,
  timeoutMs: number,
  pattern = "v*",
): Promise<string[]> {
  const res = await runCommand(["git", "-C", root, "tag", "--list", pattern, "--sort=-v:refname"], {
    timeoutMs,
  }).catch(() => null);
  if (!res || res.code !== 0) {
    return [];
  }
  return res.stdout
    .split("\n")
    .map((line) => line.trim())
    .filter(Boolean);
}

async function resolveChannelTag(
  runCommand: CommandRunner,
  root: string,
  timeoutMs: number,
  channel: Exclude<UpdateChannel, "dev">,
): Promise<string | null> {
  const tags = await listGitTags(runCommand, root, timeoutMs);
  if (channel === "beta") {
    const betaTag = tags.find((tag) => isBetaTag(tag)) ?? null;
    const stableTag = tags.find((tag) => isStableTag(tag)) ?? null;
    if (!betaTag) {
      return stableTag;
    }
    if (!stableTag) {
      return betaTag;
    }
    const cmp = compareSemverStrings(betaTag, stableTag);
    if (cmp != null && cmp < 0) {
      return stableTag;
    }
    return betaTag;
  }
  return tags.find((tag) => isStableTag(tag)) ?? null;
}

async function resolveGitRoot(
  runCommand: CommandRunner,
  candidates: string[],
  timeoutMs: number,
): Promise<string | null> {
  for (const dir of candidates) {
    const res = await runCommand(["git", "-C", dir, "rev-parse", "--show-toplevel"], {
      timeoutMs,
    }).catch(() => null);
    if (!res) {
      continue;
    }
    if (res.code === 0) {
      const root = res.stdout.trim();
      if (root) {
        return root;
      }
    }
  }
  return null;
}

async function findPackageRoot(candidates: string[]) {
  for (const dir of candidates) {
    let current = dir;
    for (let i = 0; i < 12; i += 1) {
      const pkgPath = path.join(current, "package.json");
      try {
        const raw = await fs.readFile(pkgPath, "utf-8");
        const parsed = JSON.parse(raw) as { name?: string };
        const name = parsed?.name?.trim();
        if (name && CORE_PACKAGE_NAMES.has(name)) {
          return current;
        }
      } catch {
        // ignore
      }
      const parent = path.dirname(current);
      if (parent === current) {
        break;
      }
      current = parent;
    }
  }
  return null;
}

type RunStepOptions = {
  runCommand: CommandRunner;
  name: string;
  argv: string[];
  cwd: string;
  timeoutMs: number;
  env?: NodeJS.ProcessEnv;
  progress?: UpdateStepProgress;
  stepIndex: number;
  totalSteps: number;
};

async function runStep(opts: RunStepOptions): Promise<UpdateStepResult> {
  const { runCommand, name, argv, cwd, timeoutMs, env, progress, stepIndex, totalSteps } = opts;
  const command = argv.join(" ");

  const stepInfo: UpdateStepInfo = {
    name,
    command,
    index: stepIndex,
    total: totalSteps,
  };

  progress?.onStepStart?.(stepInfo);

  const started = Date.now();
  const result = await runCommand(argv, { cwd, timeoutMs, env });
  const durationMs = Date.now() - started;

  const stderrTail = trimLogTail(result.stderr, MAX_LOG_CHARS);

  progress?.onStepComplete?.({
    ...stepInfo,
    durationMs,
    exitCode: result.code,
    stderrTail,
  });

  return {
    name,
    command,
    cwd,
    durationMs,
    exitCode: result.code,
    stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS),
    stderrTail: trimLogTail(result.stderr, MAX_LOG_CHARS),
  };
}

function normalizeTag(tag?: string) {
  return normalizePackageTagInput(tag, ["openclaw", DEFAULT_PACKAGE_NAME]) ?? "latest";
}

function normalizeDevTargetRef(value?: string | null): string | null {
  const trimmed = value?.trim();
  return trimmed ? trimmed : null;
}

function looksLikeFullCommitSha(value: string): boolean {
  return /^[0-9a-f]{40}$/i.test(value.trim());
}

function buildDevTargetRefResolutionCandidates(devTargetRef: string): string[] {
  const trimmed = devTargetRef.trim();
  const candidates: string[] = [];
  const addCandidate = (candidate?: string | null) => {
    if (!candidate || candidates.includes(candidate)) {
      return;
    }
    candidates.push(candidate);
  };

  if (looksLikeFullCommitSha(trimmed)) {
    addCandidate(trimmed);
    return candidates;
  }

  if (trimmed.startsWith("refs/remotes/")) {
    addCandidate(trimmed);
    return candidates;
  }

  if (trimmed.startsWith("refs/heads/")) {
    addCandidate(`refs/remotes/origin/${trimmed.slice("refs/heads/".length)}`);
    return candidates;
  }

  if (trimmed.startsWith("origin/")) {
    addCandidate(`refs/remotes/${trimmed}`);
    return candidates;
  }

  if (trimmed.startsWith("refs/tags/")) {
    addCandidate(`${trimmed}^{}`);
    addCandidate(trimmed);
    return candidates;
  }

  // Resolve plain branch names from the freshly fetched remote ref instead of
  // a possibly stale local branch checkout.
  addCandidate(`refs/remotes/origin/${trimmed}`);
  addCandidate(`refs/tags/${trimmed}^{}`);
  addCandidate(`refs/tags/${trimmed}`);
  return candidates;
}

async function resolveComparablePath(target: string): Promise<string> {
  return await fs.realpath(target).catch(() => path.resolve(target));
}

async function pathsReferToSameLocation(left: string, right: string): Promise<boolean> {
  return (await resolveComparablePath(left)) === (await resolveComparablePath(right));
}

async function looksLikeGitCheckout(root: string): Promise<boolean> {
  try {
    await fs.access(path.join(root, ".git"));
    return true;
  } catch {
    return false;
  }
}

function shouldRetryWindowsInstallIgnoringScripts(manager: "pnpm" | "bun" | "npm"): boolean {
  return process.platform === "win32" && manager === "pnpm";
}

function shouldPreferIgnoreScriptsForWindowsPreflight(manager: "pnpm" | "bun" | "npm"): boolean {
  return process.platform === "win32" && manager === "pnpm";
}

function resolveWindowsBuildNodeOptions(baseOptions: string | undefined): string {
  const current = baseOptions?.trim() ?? "";
  const desired = `--max-old-space-size=${WINDOWS_BUILD_MAX_OLD_SPACE_MB}`;
  const existingMatch = /(?:^|\s)--max-old-space-size=(\d+)(?=\s|$)/.exec(current);
  if (!existingMatch) {
    return current ? `${current} ${desired}` : desired;
  }
  const existingValue = Number(existingMatch[1]);
  if (Number.isFinite(existingValue) && existingValue >= WINDOWS_BUILD_MAX_OLD_SPACE_MB) {
    return current;
  }
  return current.replace(/(?:^|\s)--max-old-space-size=\d+(?=\s|$)/, ` ${desired}`).trim();
}

function resolveWindowsBuildEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv | undefined {
  if (process.platform !== "win32") {
    return env;
  }
  const currentNodeOptions = env?.NODE_OPTIONS ?? process.env.NODE_OPTIONS;
  const nextNodeOptions = resolveWindowsBuildNodeOptions(currentNodeOptions);
  if (nextNodeOptions === currentNodeOptions) {
    return env;
  }
  return {
    ...env,
    NODE_OPTIONS: nextNodeOptions,
  };
}

function isSupersededInstallFailure(
  step: UpdateStepResult,
  steps: readonly UpdateStepResult[],
): boolean {
  if (step.exitCode === 0) {
    return false;
  }
  if (step.name === "deps install") {
    return steps.some(
      (candidate) => candidate.name === "deps install (ignore scripts)" && candidate.exitCode === 0,
    );
  }
  const preflightMatch = /^preflight deps install \((.+)\)$/.exec(step.name);
  if (!preflightMatch) {
    return false;
  }
  const retryName = `preflight deps install (ignore scripts) (${preflightMatch[1]})`;
  return steps.some((candidate) => candidate.name === retryName && candidate.exitCode === 0);
}

function findBlockingGitFailure(steps: readonly UpdateStepResult[]): UpdateStepResult | undefined {
  return steps.find((step) => step.exitCode !== 0 && !isSupersededInstallFailure(step, steps));
}

function mergeCommandEnvironments(
  baseEnv: NodeJS.ProcessEnv | undefined,
  overrideEnv: NodeJS.ProcessEnv | undefined,
): NodeJS.ProcessEnv | undefined {
  if (!baseEnv) {
    return overrideEnv;
  }
  if (!overrideEnv) {
    return baseEnv;
  }
  return {
    ...baseEnv,
    ...overrideEnv,
  };
}

export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> {
  const startedAt = Date.now();
  const defaultCommandEnv = await createGlobalInstallEnv();
  const runCommand =
    opts.runCommand ??
    (async (argv, options) => {
      const res = await runCommandWithTimeout(argv, {
        ...options,
        env: mergeCommandEnvironments(defaultCommandEnv, options.env),
      });
      return { stdout: res.stdout, stderr: res.stderr, code: res.code };
    });
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  const progress = opts.progress;
  const steps: UpdateStepResult[] = [];
  const candidates = buildStartDirs(opts);

  let stepIndex = 0;
  let gitTotalSteps = 0;

  const step = (
    name: string,
    argv: string[],
    cwd: string,
    env?: NodeJS.ProcessEnv,
  ): RunStepOptions => {
    const currentIndex = stepIndex;
    stepIndex += 1;
    return {
      runCommand,
      name,
      argv,
      cwd,
      timeoutMs,
      env,
      progress,
      stepIndex: currentIndex,
      totalSteps: gitTotalSteps,
    };
  };

  const pkgRoot = await findPackageRoot(candidates);

  let gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs);
  if (!gitRoot && pkgRoot) {
    const cwdRoot = normalizeDir(opts.cwd);
    if (
      cwdRoot &&
      (await pathsReferToSameLocation(cwdRoot, pkgRoot)) &&
      (await looksLikeGitCheckout(cwdRoot))
    ) {
      gitRoot = await resolveComparablePath(cwdRoot);
    }
  }
  if (gitRoot && pkgRoot && !(await pathsReferToSameLocation(gitRoot, pkgRoot))) {
    gitRoot = null;
  }

  if (gitRoot && !pkgRoot) {
    return {
      status: "error",
      mode: "unknown",
      root: gitRoot,
      reason: "not-openclaw-root",
      steps: [],
      durationMs: Date.now() - startedAt,
    };
  }

  if (gitRoot && pkgRoot && (await pathsReferToSameLocation(gitRoot, pkgRoot))) {
    // Get current SHA (not a visible step, no progress)
    const beforeShaResult = await runCommand(["git", "-C", gitRoot, "rev-parse", "HEAD"], {
      cwd: gitRoot,
      timeoutMs,
    });
    const beforeSha = beforeShaResult.stdout.trim() || null;
    const beforeVersion = await readPackageVersion(gitRoot);
    const channel: UpdateChannel = opts.channel ?? "dev";
    const devTargetRef = channel === "dev" ? normalizeDevTargetRef(opts.devTargetRef) : null;
    const branch = channel === "dev" ? await readBranchName(runCommand, gitRoot, timeoutMs) : null;
    const needsCheckoutMain = channel === "dev" && !devTargetRef && branch !== DEV_BRANCH;
    gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9;
    const buildGitErrorResult = (reason: string): UpdateRunResult => ({
      status: "error",
      mode: "git",
      root: gitRoot,
      reason,
      before: { sha: beforeSha, version: beforeVersion },
      steps,
      durationMs: Date.now() - startedAt,
    });
    const runGitCheckoutOrFail = async (name: string, argv: string[]) => {
      const checkoutStep = await runStep(step(name, argv, gitRoot));
      steps.push(checkoutStep);
      if (checkoutStep.exitCode !== 0) {
        return buildGitErrorResult("checkout-failed");
      }
      return null;
    };

    const statusCheck = await runStep(
      step(
        "clean check",
        ["git", "-C", gitRoot, "status", "--porcelain", "--", ":!dist/control-ui/"],
        gitRoot,
      ),
    );
    steps.push(statusCheck);
    const hasUncommittedChanges =
      statusCheck.stdoutTail && statusCheck.stdoutTail.trim().length > 0;
    if (hasUncommittedChanges) {
      return {
        status: "skipped",
        mode: "git",
        root: gitRoot,
        reason: "dirty",
        before: { sha: beforeSha, version: beforeVersion },
        steps,
        durationMs: Date.now() - startedAt,
      };
    }

    if (channel === "dev") {
      if (needsCheckoutMain) {
        const failure = await runGitCheckoutOrFail(`git checkout ${DEV_BRANCH}`, [
          "git",
          "-C",
          gitRoot,
          "checkout",
          DEV_BRANCH,
        ]);
        if (failure) {
          return failure;
        }
      }

      const fetchStep = await runStep(
        step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--tags"], gitRoot),
      );
      steps.push(fetchStep);
      let preflightBaseSha: string | null = null;
      let candidates: string[] = [];
      if (devTargetRef) {
        let targetSha: string | null = null;
        for (const targetRefCandidate of buildDevTargetRefResolutionCandidates(devTargetRef)) {
          const targetShaStep = await runStep(
            step(
              `git rev-parse ${targetRefCandidate}`,
              ["git", "-C", gitRoot, "rev-parse", targetRefCandidate],
              gitRoot,
            ),
          );
          steps.push(targetShaStep);
          const resolvedTargetSha = targetShaStep.stdoutTail?.trim();
          if (targetShaStep.exitCode === 0 && resolvedTargetSha) {
            targetSha = resolvedTargetSha;
            break;
          }
        }
        if (!targetSha) {
          return {
            status: "error",
            mode: "git",
            root: gitRoot,
            reason: "no-target-sha",
            before: { sha: beforeSha, version: beforeVersion },
            steps,
            durationMs: Date.now() - startedAt,
          };
        }
        preflightBaseSha = targetSha;
        candidates = [targetSha];
      } else {
        const upstreamStep = await runStep(
          step(
            "upstream check",
            [
              "git",
              "-C",
              gitRoot,
              "rev-parse",
              "--abbrev-ref",
              "--symbolic-full-name",
              "@{upstream}",
            ],
            gitRoot,
          ),
        );
        steps.push(upstreamStep);
        if (upstreamStep.exitCode !== 0) {
          return {
            status: "skipped",
            mode: "git",
            root: gitRoot,
            reason: "no-upstream",
            before: { sha: beforeSha, version: beforeVersion },
            steps,
            durationMs: Date.now() - startedAt,
          };
        }

        const upstreamShaStep = await runStep(
          step(
            "git rev-parse @{upstream}",
            ["git", "-C", gitRoot, "rev-parse", "@{upstream}"],
            gitRoot,
          ),
        );
        steps.push(upstreamShaStep);
        const upstreamSha = upstreamShaStep.stdoutTail?.trim();
        if (!upstreamShaStep.stdoutTail || !upstreamSha) {
          return {
            status: "error",
            mode: "git",
            root: gitRoot,
            reason: "no-upstream-sha",
            before: { sha: beforeSha, version: beforeVersion },
            steps,
            durationMs: Date.now() - startedAt,
          };
        }

        const revListStep = await runStep(
          step(
            "git rev-list",
            ["git", "-C", gitRoot, "rev-list", `--max-count=${PREFLIGHT_MAX_COMMITS}`, upstreamSha],
            gitRoot,
          ),
        );
        steps.push(revListStep);
        if (revListStep.exitCode !== 0) {
          return {
            status: "error",
            mode: "git",
            root: gitRoot,
            reason: "preflight-revlist-failed",
            before: { sha: beforeSha, version: beforeVersion },
            steps,
            durationMs: Date.now() - startedAt,
          };
        }

        candidates = (revListStep.stdoutTail ?? "")
          .split("\n")
          .map((line) => line.trim())
          .filter(Boolean);
        if (candidates.length === 0) {
          return {
            status: "error",
            mode: "git",
            root: gitRoot,
            reason: "preflight-no-candidates",
            before: { sha: beforeSha, version: beforeVersion },
            steps,
            durationMs: Date.now() - startedAt,
          };
        }
        preflightBaseSha = upstreamSha;
      }
      if (!preflightBaseSha) {
        return {
          status: "error",
          mode: "git",
          root: gitRoot,
          reason: "preflight-base-missing",
          before: { sha: beforeSha, version: beforeVersion },
          steps,
          durationMs: Date.now() - startedAt,
        };
      }

      const manager = await resolveUpdateBuildManager(
        (argv, options) => runCommand(argv, { timeoutMs: options.timeoutMs, env: options.env }),
        gitRoot,
        timeoutMs,
        defaultCommandEnv,
        "require-preferred",
      );
      if (manager.kind === "missing-required") {
        return {
          status: "error",
          mode: "git",
          root: gitRoot,
          reason: mapManagerResolutionFailure(manager.reason),
          before: { sha: beforeSha, version: beforeVersion },
          steps,
          durationMs: Date.now() - startedAt,
        };
      }
      const preflightRoot = await createPreflightRoot();
      const worktreeDir = resolvePreflightWorktreeDir(preflightRoot);
      const worktreeStep = await runStep(
        step(
          "preflight worktree",
          ["git", "-C", gitRoot, "worktree", "add", "--detach", worktreeDir, preflightBaseSha],
          gitRoot,
        ),
      );
      steps.push(worktreeStep);
      if (worktreeStep.exitCode !== 0) {
        await removePathRecursive(preflightRoot);
        return {
          status: "error",
          mode: "git",
          root: gitRoot,
          reason: "preflight-worktree-failed",
          before: { sha: beforeSha, version: beforeVersion },
          steps,
          durationMs: Date.now() - startedAt,
        };
      }

      let selectedSha: string | null = null;
      try {
        for (const sha of candidates) {
          const shortSha = sha.slice(0, 8);
          const checkoutStep = await runStep(
            step(
              `preflight checkout (${shortSha})`,
              ["git", "-C", worktreeDir, "checkout", "--detach", sha],
              worktreeDir,
            ),
          );
          steps.push(checkoutStep);
          if (checkoutStep.exitCode !== 0) {
            continue;
          }

          const preflightIgnoreScripts = shouldPreferIgnoreScriptsForWindowsPreflight(
            manager.manager,
          );
          const preflightIgnoreScriptsArgv = managerInstallIgnoreScriptsArgs(manager.manager);
          const depsStepArgv =
            preflightIgnoreScripts && preflightIgnoreScriptsArgv
              ? preflightIgnoreScriptsArgv
              : managerInstallArgs(manager.manager, {
                  compatFallback: manager.fallback && manager.manager === "npm",
                });
          const depsStepName = preflightIgnoreScripts
            ? `preflight deps install (ignore scripts) (${shortSha})`
            : `preflight deps install (${shortSha})`;
          const depsStep = await runStep(
            step(depsStepName, depsStepArgv, worktreeDir, manager.env),
          );
          steps.push(depsStep);
          let finalDepsStep = depsStep;
          if (
            depsStep.exitCode !== 0 &&
            !preflightIgnoreScripts &&
            shouldRetryWindowsInstallIgnoringScripts(manager.manager)
          ) {
            const retryArgv = managerInstallIgnoreScriptsArgs(manager.manager);
            if (retryArgv) {
              const retryStep = await runStep(
                step(
                  `preflight deps install (ignore scripts) (${shortSha})`,
                  retryArgv,
                  worktreeDir,
                  manager.env,
                ),
              );
              steps.push(retryStep);
              finalDepsStep = retryStep;
            }
          }
          if (finalDepsStep.exitCode !== 0) {
            continue;
          }

          const buildStep = await runStep(
            step(
              `preflight build (${shortSha})`,
              managerScriptArgs(manager.manager, "build"),
              worktreeDir,
              resolveWindowsBuildEnv(manager.env),
            ),
          );
          steps.push(buildStep);
          if (buildStep.exitCode !== 0) {
            continue;
          }

          const lintStep = await runStep(
            step(
              `preflight lint (${shortSha})`,
              managerScriptArgs(manager.manager, "lint"),
              worktreeDir,
              manager.env,
            ),
          );
          steps.push(lintStep);
          if (lintStep.exitCode !== 0) {
            continue;
          }

          selectedSha = sha;
          break;
        }
      } finally {
        const removeStep = await runStep(
          step(
            "preflight cleanup",
            ["git", "-C", gitRoot, "worktree", "remove", "--force", worktreeDir],
            gitRoot,
          ),
        );
        if (
          removeStep.exitCode !== 0 &&
          (await repairWindowsPreflightCleanup(worktreeDir, preflightRoot))
        ) {
          removeStep.exitCode = 0;
          removeStep.stderrTail = trimLogTail(
            [removeStep.stderrTail, "windows fallback cleanup removed preflight tree"]
              .filter(Boolean)
              .join("\n"),
            MAX_LOG_CHARS,
          );
        }
        steps.push(removeStep);
        await runCommand(["git", "-C", gitRoot, "worktree", "prune"], {
          cwd: gitRoot,
          timeoutMs,
        }).catch(() => null);
        await removePathRecursive(preflightRoot);
        await manager.cleanup?.();
      }

      if (!selectedSha) {
        return {
          status: "error",
          mode: "git",
          root: gitRoot,
          reason: "preflight-no-good-commit",
          before: { sha: beforeSha, version: beforeVersion },
          steps,
          durationMs: Date.now() - startedAt,
        };
      }

      if (devTargetRef) {
        const failure = await runGitCheckoutOrFail(`git checkout ${selectedSha}`, [
          "git",
          "-C",
          gitRoot,
          "checkout",
          "--detach",
          selectedSha,
        ]);
        if (failure) {
          return failure;
        }
      } else {
        const rebaseStep = await runStep(
          step("git rebase", ["git", "-C", gitRoot, "rebase", selectedSha], gitRoot),
        );
        steps.push(rebaseStep);
        if (rebaseStep.exitCode !== 0) {
          const abortResult = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], {
            cwd: gitRoot,
            timeoutMs,
          });
          steps.push({
            name: "git rebase --abort",
            command: "git rebase --abort",
            cwd: gitRoot,
            durationMs: 0,
            exitCode: abortResult.code,
            stdoutTail: trimLogTail(abortResult.stdout, MAX_LOG_CHARS),
            stderrTail: trimLogTail(abortResult.stderr, MAX_LOG_CHARS),
          });
          return {
            status: "error",
            mode: "git",
            root: gitRoot,
            reason: "rebase-failed",
            before: { sha: beforeSha, version: beforeVersion },
            steps,
            durationMs: Date.now() - startedAt,
          };
        }
      }
    } else {
      const fetchStep = await runStep(
        step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--tags"], gitRoot),
      );
      steps.push(fetchStep);
      if (fetchStep.exitCode !== 0) {
        return {
          status: "error",
          mode: "git",
          root: gitRoot,
          reason: "fetch-failed",
          before: { sha: beforeSha, version: beforeVersion },
          steps,
          durationMs: Date.now() - startedAt,
        };
      }

      const tag = await resolveChannelTag(runCommand, gitRoot, timeoutMs, channel);
      if (!tag) {
        return {
          status: "error",
          mode: "git",
          root: gitRoot,
          reason: "no-release-tag",
          before: { sha: beforeSha, version: beforeVersion },
          steps,
          durationMs: Date.now() - startedAt,
        };
      }

      const failure = await runGitCheckoutOrFail(`git checkout ${tag}`, [
        "git",
        "-C",
        gitRoot,
        "checkout",
        "--detach",
        tag,
      ]);
      if (failure) {
        return failure;
      }
    }

    const manager = await resolveUpdateBuildManager(
      (argv, options) => runCommand(argv, { timeoutMs: options.timeoutMs, env: options.env }),
      gitRoot,
      timeoutMs,
      defaultCommandEnv,
      "require-preferred",
    );
    if (manager.kind === "missing-required") {
      return {
        status: "error",
        mode: "git",
        root: gitRoot,
        reason: mapManagerResolutionFailure(manager.reason),
        before: { sha: beforeSha, version: beforeVersion },
        steps,
        durationMs: Date.now() - startedAt,
      };
    }
    try {
      const depsStep = await runStep(
        step(
          "deps install",
          managerInstallArgs(manager.manager, {
            compatFallback: manager.fallback && manager.manager === "npm",
          }),
          gitRoot,
          manager.env,
        ),
      );
      steps.push(depsStep);
      let finalDepsStep = depsStep;
      if (depsStep.exitCode !== 0 && shouldRetryWindowsInstallIgnoringScripts(manager.manager)) {
        const retryArgv = managerInstallIgnoreScriptsArgs(manager.manager);
        if (retryArgv) {
          const retryStep = await runStep(
            step("deps install (ignore scripts)", retryArgv, gitRoot, manager.env),
          );
          steps.push(retryStep);
          finalDepsStep = retryStep;
        }
      }
      if (finalDepsStep.exitCode !== 0) {
        return {
          status: "error",
          mode: "git",
          root: gitRoot,
          reason: "deps-install-failed",
          before: { sha: beforeSha, version: beforeVersion },
          steps,
          durationMs: Date.now() - startedAt,
        };
      }

      const buildStep = await runStep(
        step(
          "build",
          managerScriptArgs(manager.manager, "build"),
          gitRoot,
          resolveWindowsBuildEnv(manager.env),
        ),
      );
      steps.push(buildStep);
      if (buildStep.exitCode !== 0) {
        return {
          status: "error",
          mode: "git",
          root: gitRoot,
          reason: "build-failed",
          before: { sha: beforeSha, version: beforeVersion },
          steps,
          durationMs: Date.now() - startedAt,
        };
      }

      const uiBuildStep = await runStep(
        step("ui:build", managerScriptArgs(manager.manager, "ui:build"), gitRoot, manager.env),
      );
      steps.push(uiBuildStep);
      if (uiBuildStep.exitCode !== 0) {
        return {
          status: "error",
          mode: "git",
          root: gitRoot,
          reason: "ui-build-failed",
          before: { sha: beforeSha, version: beforeVersion },
          steps,
          durationMs: Date.now() - startedAt,
        };
      }

      const doctorEntry = path.join(gitRoot, "openclaw.mjs");
      const doctorEntryExists = await fs
        .stat(doctorEntry)
        .then(() => true)
        .catch(() => false);
      if (!doctorEntryExists) {
        steps.push({
          name: "openclaw doctor entry",
          command: `verify ${doctorEntry}`,
          cwd: gitRoot,
          durationMs: 0,
          exitCode: 1,
          stderrTail: `missing ${doctorEntry}`,
        });
        return {
          status: "error",
          mode: "git",
          root: gitRoot,
          reason: "doctor-entry-missing",
          before: { sha: beforeSha, version: beforeVersion },
          steps,
          durationMs: Date.now() - startedAt,
        };
      }

      // Use --fix so that doctor auto-strips unknown config keys introduced by
      // schema changes between versions, preventing a startup validation crash.
      const doctorNodePath = await resolveStableNodePath(process.execPath);
      const doctorArgv = [doctorNodePath, doctorEntry, "doctor", "--non-interactive", "--fix"];
      const doctorStep = await runStep(
        step("openclaw doctor", doctorArgv, gitRoot, { OPENCLAW_UPDATE_IN_PROGRESS: "1" }),
      );
      steps.push(doctorStep);

      const uiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot });
      if (!uiIndexHealth.exists) {
        const repairArgv = managerScriptArgs(manager.manager, "ui:build");
        const started = Date.now();
        const repairResult = await runCommand(repairArgv, {
          cwd: gitRoot,
          timeoutMs,
          env: manager.env,
        });
        const repairStep: UpdateStepResult = {
          name: "ui:build (post-doctor repair)",
          command: repairArgv.join(" "),
          cwd: gitRoot,
          durationMs: Date.now() - started,
          exitCode: repairResult.code,
          stdoutTail: trimLogTail(repairResult.stdout, MAX_LOG_CHARS),
          stderrTail: trimLogTail(repairResult.stderr, MAX_LOG_CHARS),
        };
        steps.push(repairStep);

        if (repairResult.code !== 0) {
          return {
            status: "error",
            mode: "git",
            root: gitRoot,
            reason: repairStep.name,
            before: { sha: beforeSha, version: beforeVersion },
            steps,
            durationMs: Date.now() - startedAt,
          };
        }

        const repairedUiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot });
        if (!repairedUiIndexHealth.exists) {
          const uiIndexPath =
            repairedUiIndexHealth.indexPath ?? resolveControlUiDistIndexPathForRoot(gitRoot);
          steps.push({
            name: "ui assets verify",
            command: `verify ${uiIndexPath}`,
            cwd: gitRoot,
            durationMs: 0,
            exitCode: 1,
            stderrTail: `missing ${uiIndexPath}`,
          });
          return {
            status: "error",
            mode: "git",
            root: gitRoot,
            reason: "ui-assets-missing",
            before: { sha: beforeSha, version: beforeVersion },
            steps,
            durationMs: Date.now() - startedAt,
          };
        }
      }

      const failedStep = findBlockingGitFailure(steps);
      const afterShaStep = await runStep(
        step("git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot),
      );
      steps.push(afterShaStep);
      const afterVersion = await readPackageVersion(gitRoot);

      return {
        status: failedStep ? "error" : "ok",
        mode: "git",
        root: gitRoot,
        reason: failedStep ? failedStep.name : undefined,
        before: { sha: beforeSha, version: beforeVersion },
        after: {
          sha: afterShaStep.stdoutTail?.trim() ?? null,
          version: afterVersion,
        },
        steps,
        durationMs: Date.now() - startedAt,
      };
    } finally {
      await manager.cleanup?.();
    }
  }

  if (!pkgRoot) {
    return {
      status: "error",
      mode: "unknown",
      reason: `no root (${START_DIRS.join(",")})`,
      steps: [],
      durationMs: Date.now() - startedAt,
    };
  }

  const beforeVersion = await readPackageVersion(pkgRoot);
  const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs);
  if (globalManager) {
    const installTarget = await resolveGlobalInstallTarget({
      manager: globalManager,
      runCommand,
      timeoutMs,
      pkgRoot,
    });
    const packageName = (await readPackageName(pkgRoot)) ?? DEFAULT_PACKAGE_NAME;
    await cleanupGlobalRenameDirs({
      globalRoot: path.dirname(pkgRoot),
      packageName,
    });
    const channel = opts.channel ?? DEFAULT_PACKAGE_CHANNEL;
    const tag = normalizeTag(opts.tag ?? channelToNpmTag(channel));
    const steps: UpdateStepResult[] = [];
    const globalInstallEnv = await createGlobalInstallEnv();
    const spec = resolveGlobalInstallSpec({
      packageName,
      tag,
      env: globalInstallEnv,
    });
    const updateStep = await runStep({
      runCommand,
      name: "global update",
      argv: globalInstallArgs(installTarget, spec),
      cwd: pkgRoot,
      timeoutMs,
      env: globalInstallEnv,
      progress,
      stepIndex: 0,
      totalSteps: 1,
    });
    steps.push(updateStep);

    let finalStep = updateStep;
    if (updateStep.exitCode !== 0) {
      const fallbackArgv = globalInstallFallbackArgs(installTarget, spec);
      if (fallbackArgv) {
        const fallbackStep = await runStep({
          runCommand,
          name: "global update (omit optional)",
          argv: fallbackArgv,
          cwd: pkgRoot,
          timeoutMs,
          env: globalInstallEnv,
          progress,
          stepIndex: 0,
          totalSteps: 1,
        });
        steps.push(fallbackStep);
        finalStep = fallbackStep;
      }
    }

    const verifiedPackageRoot =
      (
        await resolveGlobalInstallTarget({
          manager: installTarget,
          runCommand,
          timeoutMs,
        })
      ).packageRoot ?? pkgRoot;
    const expectedVersion = resolveExpectedInstalledVersionFromSpec(packageName, spec);
    const verificationErrors = await collectInstalledGlobalPackageErrors({
      packageRoot: verifiedPackageRoot,
      expectedVersion,
    });
    if (verificationErrors.length > 0) {
      steps.push({
        name: "global install verify",
        command: `verify ${verifiedPackageRoot}`,
        cwd: verifiedPackageRoot,
        durationMs: 0,
        exitCode: 1,
        stderrTail: verificationErrors.join("\n"),
      });
    }
    const afterVersion = await readPackageVersion(verifiedPackageRoot);
    const failedStep =
      finalStep.exitCode !== 0
        ? finalStep
        : (steps.find((step) => step.name === "global install verify" && step.exitCode !== 0) ??
          null);
    return {
      status: failedStep ? "error" : "ok",
      mode: globalManager,
      root: verifiedPackageRoot,
      reason: failedStep ? failedStep.name : undefined,
      before: { version: beforeVersion },
      after: { version: afterVersion },
      steps,
      durationMs: Date.now() - startedAt,
    };
  }

  return {
    status: "skipped",
    mode: "unknown",
    root: pkgRoot,
    reason: "not-git-install",
    before: { version: beforeVersion },
    steps: [],
    durationMs: Date.now() - startedAt,
  };
}
