import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { logWarn } from "@/logger";
import type CopilotPlugin from "@/main";
import {
  getSettings,
  subscribeToSettingsChange,
  updateAgentModeBackendFields,
  type ClaudeBackendSettings,
  type CopilotSettings,
} from "@/settings/model";
import type { AgentSession } from "@/agentMode/session/AgentSession";
import { MethodUnsupportedError } from "@/agentMode/session/errors";
import { resolveClaudeBinary } from "./claudeBinaryResolver";
import { ClaudeSdkBackendProcess } from "@/agentMode/sdk/ClaudeSdkBackendProcess";
import { getCachedSdkCatalog, synthesizeEffortConfigOption } from "@/agentMode/sdk/effortOption";
import {
  buildPillSyntaxDirective,
  buildSkillCreationDirective,
  DEFAULT_SKILLS_FOLDER,
  SkillManager,
} from "@/agentMode/skills";
import type {
  BackendConfigOption,
  ModeMapping,
  ModelSelection,
  ModelWireCodec,
} from "@/agentMode/session/types";
import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types";
import { ClaudeInstallModal } from "./ClaudeInstallModal";
import ClaudeLogo from "./logo.svg";
import { ClaudeSettingsPanel } from "./ClaudeSettingsPanel";
import { openAskUserQuestionModal } from "./AskUserQuestionModal";

export const CLAUDE_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code";

export function updateClaudeFields(partial: Partial<ClaudeBackendSettings>): void {
  updateAgentModeBackendFields("claude", partial);
}

/**
 * Wire-format codec for Claude — bare base id only. Effort is dispatched
 * via `setSessionConfigOption`, not encoded in the model id, so `encode`
 * drops `effort` and `effortConfigFor` provides the config option spec.
 */
const claudeWire: ModelWireCodec = {
  encode: (selection: ModelSelection) => selection.baseModelId,
  decode: (wireId: string) => ({
    selection: { baseModelId: wireId, effort: null },
    provider: "anthropic",
  }),
  effortConfigFor: (baseModelId: string): BackendConfigOption | null => {
    const catalog = getCachedSdkCatalog();
    if (!catalog) return null;
    const modelInfo = catalog.find((m) => m.value === baseModelId);
    if (!modelInfo) return null;
    return synthesizeEffortConfigOption(modelInfo, undefined);
  },
};

/**
 * Build the environment input shared by both override-aware resolution and
 * fresh auto-detection. Pulls `os.homedir()`, `process.platform`, and a
 * minimal env subset that the resolver consults for Volta/NVM/npm-global.
 */
function claudeResolverEnv(): Omit<Parameters<typeof resolveClaudeBinary>[0], "override"> {
  return {
    homeDir: os.homedir(),
    platform: process.platform,
    env: {
      NVM_BIN: process.env.NVM_BIN,
      npm_config_prefix: process.env.npm_config_prefix,
      APPDATA: process.env.APPDATA,
    },
    fs: {
      existsSync: (p) => fs.existsSync(p),
      readFileSync: (p, encoding) => fs.readFileSync(p, encoding),
    },
  };
}

/**
 * Resolve the `claude` CLI path from settings + auto-detection. Mirrors the
 * `getInstallState` logic: explicit override wins, otherwise the resolver
 * walks Volta/asdf/NVM/Homebrew/npm-global.
 */
export function resolveClaudeCliPath(settings: CopilotSettings): string | null {
  return resolveClaudeBinary({
    override: settings.agentMode?.claudeCli?.path,
    ...claudeResolverEnv(),
  });
}

/**
 * Run a fresh auto-detect, ignoring any previously saved override. Used by
 * the settings panel's "Auto-detect" button so users get the resolver's full
 * candidate list (Volta/asdf/NVM/Homebrew/npm-global/`~/.local/bin`) instead
 * of the generic `which`-based fallback that only sees `PATH`.
 */
export function detectClaudeCliPath(): string | null {
  return resolveClaudeBinary({ override: undefined, ...claudeResolverEnv() });
}

/**
 * Plan mode writes its proposal to `<claude-config-dir>/plans/<slug>.md`
 * (typically `~/.claude/plans/`, but `CLAUDE_CONFIG_DIR` / `XDG_CONFIG_HOME`
 * can relocate it). We suffix-match on `.claude/plans` rather than prefix-
 * matching `os.homedir()` so the predicate stays correct under those env
 * overrides and across platforms — `path.dirname` + `path.join` produce
 * native separators on macOS/Linux/Windows.
 */
function isClaudePlanModePlanFilePath(absolutePath: string): boolean {
  if (!path.isAbsolute(absolutePath)) return false;
  if (!absolutePath.endsWith(".md")) return false;
  const dir = path.dirname(absolutePath);
  return dir.endsWith(path.join(".claude", "plans"));
}

/**
 * Claude backend backed by the official `@anthropic-ai/claude-agent-sdk`.
 * Replaces the legacy `claude-code-acp` shim. Auth is inherited from the
 * user-installed `claude` CLI's login state (or `ANTHROPIC_API_KEY` /
 * Bedrock / Vertex env if configured) — the SDK handles credential
 * resolution through the spawned CLI; we never see or pass the secret.
 */
export const ClaudeBackendDescriptor: BackendDescriptor = {
  id: "claude",
  displayName: "Claude",
  Icon: ClaudeLogo,
  skillsProjectDir: ".claude/skills",
  crossDiscoveredAgents: [],
  restartOnManagedSkillsChange: false,
  wire: claudeWire,

  getInstallState(settings: CopilotSettings): InstallState {
    const path = resolveClaudeCliPath(settings);
    if (!path) return { kind: "absent" };
    return {
      kind: "ready",
      source: settings.agentMode?.claudeCli?.path ? "custom" : "managed",
    };
  },

  subscribeInstallState(_plugin: CopilotPlugin, cb: () => void): () => void {
    return subscribeToSettingsChange((prev, next) => {
      if (prev.agentMode?.claudeCli?.path !== next.agentMode?.claudeCli?.path) {
        cb();
      }
    });
  },

  openInstallUI(plugin: CopilotPlugin): void {
    new ClaudeInstallModal(plugin.app).open();
  },

  isPlanModePlanFilePath(absolutePath: string): boolean {
    return isClaudePlanModePlanFilePath(absolutePath);
  },

  async applySelection(session: AgentSession, selection: ModelSelection): Promise<void> {
    // Claude's wire id is just the baseModelId — effort travels through
    // `setConfigOption`, not the model id. Skip the model round-trip when
    // the base hasn't changed, otherwise effort-only ticks would fire a
    // pointless `setSessionModel` on every slider drag.
    const currentBase = session.getState()?.model?.current.baseModelId;
    if (currentBase !== selection.baseModelId) {
      await session.setModel(claudeWire.encode(selection));
    }
    if (selection.effort === null) return;
    const cfgOpt = claudeWire.effortConfigFor?.(selection.baseModelId);
    if (!cfgOpt) return;
    try {
      await session.setConfigOption(cfgOpt.id, selection.effort);
    } catch (e) {
      if (!(e instanceof MethodUnsupportedError)) throw e;
    }
  },

  createBackendProcess(args): BackendProcess {
    const claudePath = resolveClaudeCliPath(getSettings());
    if (!claudePath) {
      throw new Error(
        `Claude CLI not found. Install with: ${CLAUDE_INSTALL_COMMAND}, ` +
          `or set agentMode.claudeCli.path in settings.`
      );
    }
    return new ClaudeSdkBackendProcess({
      pathToClaudeCodeExecutable: claudePath,
      app: args.app,
      clientVersion: args.clientVersion,
      descriptor: args.descriptor,
      askUserQuestion: (questions) => openAskUserQuestionModal(args.app, questions),
      getEnableThinking: () => Boolean(getSettings().agentMode?.backends?.claude?.enableThinking),
      getEnvOverrides: () => getSettings().agentMode?.backends?.claude?.envOverrides,
      isPlanModePlanFilePath: isClaudePlanModePlanFilePath,
      getDefaultModelId: () => args.getSeedSelection?.()?.baseModelId,
      // Spawn-time skill-creation directive: read the current
      // `agentMode.skills.folder` so the directive templates the live value
      // on every new session. See the Skills Management spec.
      //
      // Claude has no cross-discovery surface — it only loads
      // `.claude/skills/`, and the symlink fanout already enforces
      // visibility (no link = not seen). If the Claude Agent SDK ever
      // grows a per-skill deny hook, wire `composeDenyList(getManagedSkills(),
      // "claude")` in here.
      getSkillCreationDirective: () => {
        const folder = getSettings().agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER;
        const dirs = Object.values(SkillManager.getInstance().getAgentDirsProjectRel());
        return `${buildPillSyntaxDirective()}\n\n${buildSkillCreationDirective("claude", folder, dirs)}`;
      },
    });
  },

  SettingsPanel: ClaudeSettingsPanel,

  /**
   * Map Copilot's canonical modes onto the SDK's `PermissionMode` strings.
   * `acceptEdits` / `dontAsk` exist upstream but stay hidden — the picker is
   * a 3-mode UI (default / plan / auto). The session adapter normalizes
   * unknown ids to `default`.
   */
  getModeMapping(): ModeMapping {
    return {
      kind: "setMode",
      canonical: {
        default: "default",
        plan: "plan",
        auto: "bypassPermissions",
      },
    };
  },

  /**
   * Replay the seed effort on a freshly created session. The Claude SDK
   * adapter probes the model catalog asynchronously, so the effort
   * `SessionConfigOption` may not be present yet when this runs;
   * `replayPersistedEffort` subscribes to the session and applies once
   * the option arrives (with a timeout guard to avoid leaking listeners
   * on agents that never report effort). Mode is never persisted — the
   * Claude SDK's natural starting mode is already canonical `default`.
   *
   * The seed effort is read off `AgentSession.getDefaultEffort()`, populated
   * by the manager from its in-memory `lastSelectionByBackend` cache.
   */
  async applyInitialSessionConfig(session: AgentSession): Promise<void> {
    const seedEffort = session.getDefaultEffort();
    await replayPersistedEffort(session, seedEffort ?? undefined);
  },
};

async function replayPersistedEffort(
  session: AgentSession,
  persistedEffort: string | undefined
): Promise<void> {
  if (!persistedEffort) return;
  const tryApply = async (): Promise<boolean> => {
    const state = session.getState();
    const current = state?.model?.current;
    if (!current) return false;
    if (current.effort === persistedEffort) return true;
    const entry = state?.model?.availableModels.find((e) => e.baseModelId === current.baseModelId);
    if (!entry?.effortOptions.some((o) => o.value === persistedEffort)) return true;
    const cfgOpt = ClaudeBackendDescriptor.wire.effortConfigFor?.(current.baseModelId);
    if (!cfgOpt) return true;
    try {
      await session.setConfigOption(cfgOpt.id, persistedEffort);
    } catch (e) {
      if (e instanceof MethodUnsupportedError) return true;
      logWarn(`[AgentMode] could not apply default effort ${persistedEffort}`, e);
    }
    return true;
  };

  if (await tryApply()) return;

  // Effort hasn't been advertised yet — wait for the first
  // config_option_update. Bound the wait so we don't keep a listener alive
  // on agents that never emit an effort option.
  await new Promise<void>((resolve) => {
    let settled = false;
    const finish = (): void => {
      if (settled) return;
      settled = true;
      unsub();
      window.clearTimeout(timer);
      resolve();
    };
    const unsub = session.subscribe({
      onMessagesChanged: () => {},
      onStatusChanged: () => {},
      onModelChanged: () => {
        void tryApply().then((applied) => {
          if (applied) finish();
        });
      },
    });
    const timer = window.setTimeout(finish, 10_000);
  });
}
