import fs from "fs/promises"
import path from "path"
import { backupFile, copyDir, copySkillDir, ensureDir, isSafeManagedPath, pathExists, sanitizePathName, writeJson, writeText, writeTextSecure } from "../utils/files"
import type { CodexBundle } from "../types/codex"
import type { ClaudeMcpServer } from "../types/claude"
import { transformContentForCodex } from "../utils/codex-content"
import { getLegacyCodexArtifacts } from "../data/plugin-legacy-artifacts"
import { classifyCodexLegacyPromptOwnership } from "../utils/legacy-cleanup"

const MANAGED_START_MARKER = "# BEGIN Compound Engineering plugin MCP -- do not edit this block"
const MANAGED_END_MARKER = "# END Compound Engineering plugin MCP"
const PREV_START_MARKER = "# BEGIN compound-plugin Claude Code MCP"
const PREV_END_MARKER = "# END compound-plugin Claude Code MCP"
const LEGACY_MARKER = "# MCP servers synced from Claude Code"
const UNMARKED_LEGACY_MARKER = "# Generated by compound-plugin"
const MANAGED_INSTALL_MANIFEST = "install-manifest.json"

export type CodexInstallManifest = {
  version: 1
  pluginName: string
  skills: string[]
  prompts: string[]
  agents: string[]
}

export type CodexWriteOptions = {
  outputIsCodexRoot?: boolean
}

export async function writeCodexBundle(
  outputRoot: string,
  bundle: CodexBundle,
  options: CodexWriteOptions = {},
): Promise<void> {
  const codexRoot = resolveCodexRoot(outputRoot, options)
  await ensureDir(codexRoot)

  const pluginName = bundle.pluginName ? sanitizeCodexPathComponent(bundle.pluginName) : undefined
  const manifest = pluginName ? await readInstallManifest(codexRoot, pluginName) : null
  const currentPrompts = bundle.prompts.map((prompt) => `${sanitizePathName(prompt.name)}.md`)
  const agents = bundle.agents ?? []
  const agentsRoot = pluginName
    ? path.join(codexRoot, "agents", pluginName)
    : path.join(codexRoot, "agents")
  const currentAgents = agents.map((agent) => `${sanitizePathName(agent.name)}.toml`)
  assertNoCodexAgentFilenameCollisions(agents)

  if (bundle.prompts.length > 0) {
    const promptsDir = path.join(codexRoot, "prompts")
    await cleanupRemovedPrompts(promptsDir, manifest, currentPrompts)
    for (const prompt of bundle.prompts) {
      await writeText(path.join(promptsDir, `${sanitizePathName(prompt.name)}.md`), prompt.content + "\n")
    }
  } else if (pluginName) {
    await cleanupRemovedPrompts(path.join(codexRoot, "prompts"), manifest, [])
  }

  const skillsRoot = pluginName
    ? path.join(codexRoot, "skills", pluginName)
    : path.join(codexRoot, "skills")
  // Include `externallyManagedSkillNames` so agents-only installs (default
  // `--to codex`) treat skills installed via Codex's native plugin flow as
  // "current" for cleanup purposes. Without this, `cleanupLegacyAgentSkillDirs`
  // would see an empty `currentSkills` set and sweep allow-listed names such
  // as `ce-plan` out of `.codex/skills/<plugin>/` into legacy-backup on every
  // re-run of `install --to codex`.
  const currentSkills = Array.from(new Set([
    ...bundle.skillDirs.map((skill) => sanitizePathName(skill.name)),
    ...bundle.generatedSkills.map((skill) => sanitizePathName(skill.name)),
    ...(bundle.externallyManagedSkillNames ?? []).map((name) => sanitizePathName(name)),
  ]))
  await cleanupRemovedSkills(skillsRoot, manifest, currentSkills)

  if (bundle.skillDirs.length > 0) {
    for (const skill of bundle.skillDirs) {
      const targetDir = path.join(skillsRoot, sanitizePathName(skill.name))
      await cleanupCurrentManagedSkillDir(targetDir, manifest, sanitizePathName(skill.name))
      await copySkillDir(
        skill.sourceDir,
        targetDir,
        (content) => transformContentForCodex(content, bundle.invocationTargets, {
          unknownSlashBehavior: "preserve",
        }),
      )
    }
  }

  if (bundle.generatedSkills.length > 0) {
    for (const skill of bundle.generatedSkills) {
      const skillDir = path.join(skillsRoot, sanitizePathName(skill.name))
      await cleanupCurrentManagedSkillDir(skillDir, manifest, sanitizePathName(skill.name))
      await writeText(path.join(skillDir, "SKILL.md"), skill.content + "\n")
      for (const sidecar of skill.sidecarDirs ?? []) {
        await copyDir(sidecar.sourceDir, path.join(skillDir, sidecar.targetName))
      }
    }
  }

  await cleanupRemovedAgents(agentsRoot, manifest, currentAgents)
  if (agents.length > 0) {
    for (const agent of agents) {
      const agentBaseName = sanitizePathName(agent.name)
      const agentFile = `${agentBaseName}.toml`
      // If the agent declares no sidecars, remove any same-basename sibling
      // directory left behind by a prior install that did. The manifest-driven
      // cleanupRemovedAgents sweep above only removes the sibling dir when the
      // TOML itself is being removed; a same-name agent that loses its sidecar
      // would otherwise leave an orphan directory.
      if ((agent.sidecarDirs ?? []).length === 0 && isSafeManagedPath(agentsRoot, agentBaseName)) {
        await fs.rm(path.join(agentsRoot, agentBaseName), { recursive: true, force: true })
      }
      await writeText(path.join(agentsRoot, agentFile), renderCodexAgentToml(agent) + "\n")
      for (const sidecar of agent.sidecarDirs ?? []) {
        await copyDir(sidecar.sourceDir, path.join(agentsRoot, agentBaseName, sidecar.targetName))
      }
    }
  }

  if (pluginName) {
    await ensureDir(skillsRoot)
    await writeInstallManifest(codexRoot, {
      version: 1,
      pluginName,
      skills: currentSkills,
      prompts: currentPrompts,
      agents: currentAgents,
    })
    await cleanupKnownLegacyCodexArtifacts(codexRoot, bundle)
    await cleanupLegacyAgentSkillDirs(codexRoot, pluginName, currentSkills, bundle)
    await cleanupLegacyAgentsSkillSymlinks(codexRoot, pluginName, currentSkills, manifest)
    await cleanupPreviousManagedCodexSkillStore(codexRoot, pluginName)
  }

  const configPath = path.join(codexRoot, "config.toml")
  const existingConfig = await readFileSafe(configPath)
  const mcpToml = renderCodexConfig(bundle.mcpServers)
  const merged = mergeCodexConfig(existingConfig, mcpToml)
  if (merged !== null) {
    const backupPath = await backupFile(configPath)
    if (backupPath) {
      console.log(`Backed up existing config to ${backupPath}`)
    }
    await writeTextSecure(configPath, merged)
  }

  // Write hooks to .codex/hooks.json — Codex uses the same hooks format
  // as Claude Code. Hooks are merged with any existing hooks file to avoid
  // clobbering hooks from other plugins or manual configuration.
  // Always run the merge (even with empty hooks) so previously installed
  // managed entries are cleaned up on upgrade.
  if (pluginName) {
    const hooksPath = path.join(codexRoot, "hooks.json")
    const existingHooks = await readJsonSafe(hooksPath)
    const pluginHooks = bundle.hooks?.hooks ?? {}
    const mergedHooks = mergeCodexHooks(existingHooks, pluginHooks, pluginName)
    const mergedContent = JSON.stringify(mergedHooks, null, 2) + "\n"
    const existingContent = existingHooks !== null
      ? JSON.stringify(existingHooks, null, 2) + "\n"
      : null
    const hasHooks = Object.keys((mergedHooks.hooks as Record<string, unknown>) ?? {}).length > 0
    // Only write when content actually changes (avoids backup churn on idempotent re-installs)
    if ((hasHooks || existingHooks !== null) && mergedContent !== existingContent) {
      if (existingHooks !== null) {
        const backupPath = await backupFile(hooksPath)
        if (backupPath) {
          console.log(`Backed up existing hooks to ${backupPath}`)
        }
      }
      await writeTextSecure(hooksPath, mergedContent)
    }
  }
}

function resolveCodexRoot(outputRoot: string, options: CodexWriteOptions): string {
  if (options.outputIsCodexRoot) return outputRoot
  return path.basename(outputRoot) === ".codex" ? outputRoot : path.join(outputRoot, ".codex")
}

function sanitizeCodexPathComponent(name: string): string {
  return sanitizePathName(name).replace(/[\\/]/g, "-")
}

export async function readCodexInstallManifest(codexRoot: string, pluginName: string): Promise<CodexInstallManifest | null> {
  return readInstallManifest(codexRoot, pluginName)
}

async function readInstallManifest(codexRoot: string, pluginName: string): Promise<CodexInstallManifest | null> {
  const manifestPath = path.join(codexRoot, pluginName, MANAGED_INSTALL_MANIFEST)
  try {
    const raw = await fs.readFile(manifestPath, "utf8")
    const parsed = JSON.parse(raw) as Partial<CodexInstallManifest>
    if (
      parsed.version === 1 &&
      parsed.pluginName === pluginName &&
      Array.isArray(parsed.skills) &&
      Array.isArray(parsed.prompts)
    ) {
      // Filter manifest entries at read time. Cleanup functions join these
      // strings into `fs.rm` paths, so a tampered or corrupted
      // `install-manifest.json` could otherwise delete outside the Codex
      // managed tree. Codex entries are bare leaf names joined against
      // `skills/<plugin>`, `prompts/`, or `agents/<plugin>` — but the
      // absolute-path and `..`-segment checks are root-independent, and we
      // use `codexRoot` for the containment check as the outermost root
      // that contains every possible destination.
      const agents = Array.isArray(parsed.agents) ? parsed.agents : []
      return {
        version: 1,
        pluginName,
        skills: filterSafeCodexManifestEntries(parsed.skills, codexRoot, manifestPath, "skills"),
        prompts: filterSafeCodexManifestEntries(parsed.prompts, codexRoot, manifestPath, "prompts"),
        agents: filterSafeCodexManifestEntries(agents, codexRoot, manifestPath, "agents"),
      }
    }
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
      console.warn(`Ignoring unreadable Codex install manifest at ${manifestPath}.`)
    }
  }
  return null
}

function filterSafeCodexManifestEntries(
  entries: unknown[],
  codexRoot: string,
  manifestPath: string,
  group: string,
): string[] {
  const safe: string[] = []
  for (const entry of entries) {
    if (isSafeManagedPath(codexRoot, entry)) {
      safe.push(entry)
    } else {
      console.warn(
        `Dropping unsafe Codex install-manifest entry in ${manifestPath} (group "${group}"): ${JSON.stringify(entry)}`,
      )
    }
  }
  return safe
}

async function writeInstallManifest(codexRoot: string, manifest: CodexInstallManifest): Promise<void> {
  await writeJson(path.join(codexRoot, manifest.pluginName, MANAGED_INSTALL_MANIFEST), manifest)
}

async function cleanupRemovedSkills(
  skillsRoot: string,
  manifest: CodexInstallManifest | null,
  currentSkills: string[],
): Promise<void> {
  if (!manifest) return
  const current = new Set(currentSkills)
  for (const skillName of manifest.skills) {
    if (current.has(skillName)) continue
    // Defense in depth: `readInstallManifest` already drops unsafe entries,
    // but re-check before any out-of-tree fs.rm can be issued from a future
    // caller that bypasses the read layer.
    if (!isSafeManagedPath(skillsRoot, skillName)) continue
    await fs.rm(path.join(skillsRoot, skillName), { recursive: true, force: true })
  }
}

async function cleanupRemovedPrompts(
  promptsDir: string,
  manifest: CodexInstallManifest | null,
  currentPrompts: string[],
): Promise<void> {
  if (!manifest) return
  const current = new Set(currentPrompts)
  for (const promptFile of manifest.prompts) {
    if (current.has(promptFile)) continue
    if (!isSafeManagedPath(promptsDir, promptFile)) continue
    await fs.rm(path.join(promptsDir, promptFile), { force: true })
  }
}

async function cleanupRemovedAgents(
  agentsRoot: string,
  manifest: CodexInstallManifest | null,
  currentAgents: string[],
): Promise<void> {
  if (!manifest) return
  const current = new Set(currentAgents)
  for (const agentFile of manifest.agents) {
    if (current.has(agentFile)) continue
    if (!isSafeManagedPath(agentsRoot, agentFile)) continue
    await fs.rm(path.join(agentsRoot, agentFile), { force: true })
    await fs.rm(path.join(agentsRoot, path.basename(agentFile, ".toml")), { recursive: true, force: true })
  }
}

async function cleanupCurrentManagedSkillDir(
  targetDir: string,
  manifest: CodexInstallManifest | null,
  skillName: string,
): Promise<void> {
  if (!manifest?.skills.includes(skillName)) return
  await fs.rm(targetDir, { recursive: true, force: true })
}

async function cleanupKnownLegacyCodexArtifacts(codexRoot: string, bundle: CodexBundle): Promise<void> {
  const pluginName = bundle.pluginName
  if (!pluginName) return

  const legacyArtifacts = getLegacyCodexArtifacts(bundle)
  for (const skillName of legacyArtifacts.skills) {
    const legacySkillPath = path.join(codexRoot, "skills", skillName)
    await moveLegacyArtifactToBackup(codexRoot, pluginName, "skills", legacySkillPath)
  }

  for (const promptFile of legacyArtifacts.prompts) {
    const legacyPromptPath = path.join(codexRoot, "prompts", promptFile)
    // Ownership gate: `~/.codex/prompts/` is a shared directory across plugins
    // and user-authored prompts. A filename match against the legacy allow-list
    // is not a strong enough signal to move a file — a user who creates
    // `~/.codex/prompts/ce-plan.md` for their own workflow would otherwise see
    // it swept into `compound-engineering/legacy-backup/` on every install.
    // Mirror the body + frontmatter check used by the standalone
    // `cleanupStalePrompts` helper. "unknown" (no fingerprint on record, e.g.
    // fully-retired wrappers like `reproduce-bug.md`) falls through to the
    // historical allow-list behavior — user collisions at those names are
    // unlikely and a strict gate would strand genuinely-owned orphans.
    const ownership = await classifyCodexLegacyPromptOwnership(legacyPromptPath)
    if (ownership === "foreign") continue
    await moveLegacyArtifactToBackup(codexRoot, pluginName, "prompts", legacyPromptPath)
  }
}

async function cleanupLegacyAgentSkillDirs(
  codexRoot: string,
  pluginName: string,
  currentSkills: string[],
  bundle: CodexBundle,
): Promise<void> {
  const currentSkillSet = new Set(currentSkills)
  const legacySkillNames = new Set<string>()
  for (const agent of bundle.agents ?? []) {
    legacySkillNames.add(sanitizePathName(agent.name))
    // Cross-layout cleanup for plugins with nested agent directories: if the
    // Codex-built agent name contains an embedded `-ce-` (e.g. `review-ce-foo`
    // for `agents/review/ce-foo.md`), the same logical agent may have
    // previously been installed under the flat-alias name (`ce-foo`) by an
    // earlier plugin layout. Seeding the flat alias as a cleanup candidate
    // sweeps that orphan skill dir on upgrade. For flat-only layouts (such as
    // compound-engineering itself) `agent.name` has no embedded `-ce-` and
    // this produces harmless non-matching candidates like `ce-ce-<name>`.
    if (agent.name.includes("-ce-")) {
      const finalSegment = agent.name.split("-ce-").pop()
      if (finalSegment) legacySkillNames.add(`ce-${sanitizePathName(finalSegment)}`)
    }
  }
  for (const name of getLegacyCodexArtifacts({
    pluginName,
    prompts: [],
    skillDirs: [],
    generatedSkills: [],
    agents: [],
  }).skills) {
    legacySkillNames.add(name)
  }

  const skillsRoot = path.join(codexRoot, "skills", pluginName)
  for (const skillName of legacySkillNames) {
    if (currentSkillSet.has(skillName)) continue
    await moveLegacyArtifactToBackup(codexRoot, pluginName, "skills", path.join(skillsRoot, skillName))
  }
}

async function cleanupLegacyAgentsSkillSymlinks(
  codexRoot: string,
  pluginName: string,
  currentSkills: string[],
  manifest: CodexInstallManifest | null,
): Promise<void> {
  // Symlink cleanup is safe for a broad candidate set because
  // `removeAgentsSkillSymlinkIfManaged` only removes a symlink whose resolved
  // target is inside a managed root. We probe:
  //   - current and manifest-tracked skills (in case stale symlinks point at
  //     still-current skill directories under a previous layout)
  //   - the explicit historical legacy allow-list (renamed/removed CE skills)
  // Bundle-derived names that might collide with unrelated user skills are
  // safe here because the managed-root check rejects symlinks pointing
  // anywhere outside CE's own install tree.
  const legacyArtifacts = getLegacyCodexArtifacts({
    pluginName,
    prompts: [],
    skillDirs: [],
    generatedSkills: [],
  })
  const candidateSkillNames = new Set<string>([
    ...currentSkills,
    ...(manifest?.skills ?? []),
    ...legacyArtifacts.skills,
  ])
  const agentsSkillsDir = path.join(path.dirname(codexRoot), ".agents", "skills")
  const managedRoots = await resolveCodexManagedRoots(codexRoot, pluginName)

  await removeAgentsSkillSymlinkIfManaged(path.join(agentsSkillsDir, pluginName), managedRoots)
  for (const skillName of candidateSkillNames) {
    await removeAgentsSkillSymlinkIfManaged(path.join(agentsSkillsDir, skillName), managedRoots)
  }
}

async function cleanupPreviousManagedCodexSkillStore(codexRoot: string, pluginName: string): Promise<void> {
  await fs.rm(path.join(codexRoot, pluginName, "skills"), { recursive: true, force: true })
}

async function removeAgentsSkillSymlinkIfManaged(symlinkPath: string, managedRoots: string[]): Promise<void> {
  if (!(await isManagedCodexAgentsSymlink(symlinkPath, managedRoots))) return
  try {
    await fs.unlink(symlinkPath)
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err
  }
}

/**
 * Ownership check for entries under the shared `~/.agents/skills/` store.
 *
 * Returns true only when `entryPath` is a symlink whose resolved target lives
 * inside one of the supplied CE-managed Codex roots. Plain files, directories,
 * and symlinks pointing elsewhere (user-created skills that happen to share a
 * name with a CE skill) return false so callers can leave them alone.
 *
 * The shared `.agents` store is cross-plugin, so name-only matches are
 * ambiguous. Only a symlink pointing into CE's own install tree is a strong
 * signal that CE emitted it — use this guard before any mutation there.
 */
export async function isManagedCodexAgentsSymlink(
  entryPath: string,
  managedRoots: string[],
): Promise<boolean> {
  let stats
  try {
    stats = await fs.lstat(entryPath)
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code === "ENOENT") return false
    throw err
  }
  if (!stats.isSymbolicLink()) return false

  const resolvedTarget = await readResolvedSymlinkTarget(entryPath)
  if (!resolvedTarget) return false
  return managedRoots.some((root) => isPathInside(resolvedTarget, root))
}

/**
 * Build the set of CE-managed Codex roots used as the ownership signal for
 * entries under `~/.agents/skills/`. Returns both the raw and realpath-resolved
 * forms so symlink-bearing paths on macOS (`/var/folders/...` -> `/private/...`)
 * match regardless of which form the resolved symlink target takes.
 */
export async function resolveCodexManagedRoots(
  codexRoot: string,
  pluginName: string,
): Promise<string[]> {
  const rawManagedRoots = [
    path.join(codexRoot, pluginName),
    path.join(codexRoot, "skills", pluginName),
  ]
  return [
    ...rawManagedRoots,
    ...(await Promise.all(rawManagedRoots.map((root) => canonicalizePath(root)))),
  ]
}

async function readResolvedSymlinkTarget(symlinkPath: string): Promise<string | null> {
  try {
    return await fs.realpath(symlinkPath)
  } catch {
    try {
      const linkTarget = await fs.readlink(symlinkPath)
      return path.resolve(path.dirname(symlinkPath), linkTarget)
    } catch {
      return null
    }
  }
}

async function canonicalizePath(filePath: string): Promise<string> {
  try {
    return await fs.realpath(filePath)
  } catch {
    return path.resolve(filePath)
  }
}

function isPathInside(candidatePath: string, rootPath: string): boolean {
  const relative = path.relative(path.resolve(rootPath), path.resolve(candidatePath))
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}

async function moveLegacyArtifactToBackup(
  codexRoot: string,
  pluginName: string,
  kind: "skills" | "prompts",
  artifactPath: string,
): Promise<void> {
  if (!(await pathExists(artifactPath))) return
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
  const backupDir = path.join(codexRoot, pluginName, "legacy-backup", timestamp, kind)
  const backupPath = path.join(backupDir, path.basename(artifactPath))
  await ensureDir(backupDir)
  await fs.rename(artifactPath, backupPath)
  console.warn(`Moved legacy Codex ${kind.slice(0, -1)} artifact to ${backupPath}`)
}

export function renderCodexConfig(mcpServers?: Record<string, ClaudeMcpServer>): string | null {
  if (!mcpServers || Object.keys(mcpServers).length === 0) return null

  const lines: string[] = []

  for (const [name, server] of Object.entries(mcpServers)) {
    if (!server.command && !server.url) continue

    const key = formatTomlKey(name)
    lines.push(`[mcp_servers.${key}]`)

    if (server.command) {
      lines.push(`command = ${formatTomlString(server.command)}`)
      if (server.args && server.args.length > 0) {
        const args = server.args.map((arg) => formatTomlString(arg)).join(", ")
        lines.push(`args = [${args}]`)
      }

      if (server.env && Object.keys(server.env).length > 0) {
        lines.push("")
        lines.push(`[mcp_servers.${key}.env]`)
        for (const [envKey, value] of Object.entries(server.env)) {
          lines.push(`${formatTomlKey(envKey)} = ${formatTomlString(value)}`)
        }
      }
    } else if (server.url) {
      lines.push(`url = ${formatTomlString(server.url)}`)
      if (server.headers && Object.keys(server.headers).length > 0) {
        lines.push(`http_headers = ${formatTomlInlineTable(server.headers)}`)
      }
    }

    lines.push("")
  }

  return lines.length > 0 ? lines.join("\n") : null
}

async function readFileSafe(filePath: string): Promise<string> {
  try {
    return await fs.readFile(filePath, "utf-8")
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
      throw err
    }
    return ""
  }
}

export function mergeCodexConfig(existingContent: string, mcpToml: string | null): string | null {
  // Strip current and previous managed blocks
  let stripped = existingContent
  let removedManagedBlock = false
  for (const [start, end] of [[MANAGED_START_MARKER, MANAGED_END_MARKER], [PREV_START_MARKER, PREV_END_MARKER]]) {
    const next = stripped.replace(
      new RegExp(`${escapeForRegex(start)}[\\s\\S]*?${escapeForRegex(end)}\\n?`, "g"),
      "",
    )
    if (next !== stripped) removedManagedBlock = true
    stripped = next
  }

  // No MCP servers to write — only remove bounded managed blocks. Do not strip
  // unmarked legacy markers here: old Codex config files may contain user
  // settings after "# Generated by compound-plugin", and there is no safe
  // boundary for deleting only plugin-owned TOML.
  if (!mcpToml) {
    if (!existingContent) return null
    const legacyMarkerIndex = stripped.indexOf(LEGACY_MARKER)
    if (legacyMarkerIndex !== -1) {
      return stripped.slice(0, legacyMarkerIndex).trimEnd()
    }
    return removedManagedBlock ? stripped.trimEnd() : existingContent
  }

  stripped = stripped.trimEnd()

  // Strip from legacy markers to end of content (old formats wrote everything after the marker)
  let cleaned = stripped
  for (const marker of [LEGACY_MARKER, UNMARKED_LEGACY_MARKER]) {
    const idx = cleaned.indexOf(marker)
    if (idx !== -1) {
      cleaned = cleaned.slice(0, idx).trimEnd()
    }
  }

  const managedBlock = [
    MANAGED_START_MARKER,
    mcpToml.trim(),
    MANAGED_END_MARKER,
    "",
  ].join("\n")

  return cleaned
    ? `${cleaned}\n\n${managedBlock}`
    : `${managedBlock}`
}

function escapeForRegex(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}

function formatTomlString(value: string): string {
  return JSON.stringify(value)
}

function assertNoCodexAgentFilenameCollisions(
  agents: NonNullable<CodexBundle["agents"]>,
): void {
  const seen = new Map<string, string>()
  for (const agent of agents) {
    const filename = `${sanitizePathName(agent.name)}.toml`
    const prior = seen.get(filename)
    if (prior !== undefined && prior !== agent.name) {
      throw new Error(
        `Codex agent filename collision: "${prior}" and "${agent.name}" both normalize to ` +
          `"${filename}". Rename one of the source agents so their sanitized filenames differ. ` +
          `A numeric suffix cannot be used here because the TOML filename must match the ` +
          `agent name used for Task(subagent_type: ...) invocations.`,
      )
    }
    seen.set(filename, agent.name)
  }
}

function renderCodexAgentToml(agent: NonNullable<CodexBundle["agents"]>[number]): string {
  const lines = [
    `name = ${formatTomlString(agent.name)}`,
    `description = ${formatTomlString(agent.description)}`,
    `developer_instructions = ${formatTomlString(agent.instructions)}`,
  ]
  return lines.join("\n")
}

function formatTomlKey(value: string): string {
  if (/^[A-Za-z0-9_-]+$/.test(value)) return value
  return JSON.stringify(value)
}

function formatTomlInlineTable(entries: Record<string, string>): string {
  const parts = Object.entries(entries).map(
    ([key, value]) => `${formatTomlKey(key)} = ${formatTomlString(value)}`,
  )
  return `{ ${parts.join(", ")} }`
}

// ── Hooks ──────────────────────────────────────────────────

async function readJsonSafe(filePath: string): Promise<Record<string, unknown> | null> {
  try {
    const content = await fs.readFile(filePath, "utf8")
    return JSON.parse(content)
  } catch {
    return null
  }
}

type HookEntry = { matcher?: string; hooks: Array<{ type: string; command?: string; prompt?: string; agent?: string; timeout?: number }> }

/**
 * Index tracking which hook entries are managed by which plugin.
 * Stored as a sibling `_managed` block in hooks.json so hook entry
 * objects stay schema-clean (no `_source` field injected into runtime data).
 *
 * Shape: `{ "<pluginName>": { "<event>": [<index>, ...] } }`
 */
type ManagedIndex = Record<string, Record<string, number[]>>

/**
 * Merge plugin hooks into an existing .codex/hooks.json, preserving hooks
 * from other sources. Uses a `_managed` index block to track which entries
 * belong to which plugin, so re-installs can replace them cleanly without
 * injecting bookkeeping fields into hook entry objects.
 */
export function mergeCodexHooks(
  existing: Record<string, unknown> | null,
  pluginHooks: Record<string, HookEntry[]>,
  pluginName: string,
): Record<string, unknown> {
  const result: Record<string, unknown[]> = {}
  const managed: ManagedIndex = (existing?._managed as ManagedIndex) ?? {}

  // Collect indices of entries managed by this plugin (to be removed)
  const ownedIndices: Record<string, Set<number>> = {}
  if (managed[pluginName]) {
    for (const [event, indices] of Object.entries(managed[pluginName])) {
      ownedIndices[event] = new Set(indices)
    }
  }

  // Preserve existing hooks, filtering out this plugin's managed entries
  const existingHooks = (existing?.hooks ?? {}) as Record<string, unknown[]>
  for (const [event, matchers] of Object.entries(existingHooks)) {
    if (!Array.isArray(matchers)) continue
    const owned = ownedIndices[event]
    result[event] = owned
      ? matchers.filter((_, idx) => !owned.has(idx))
      : [...matchers]
  }

  // Also filter out entries with legacy `_source` tag from this plugin
  // (migration path from the previous `_source`-in-entry format)
  for (const [event, matchers] of Object.entries(result)) {
    result[event] = (matchers as Array<Record<string, unknown>>).filter((m) => {
      if (typeof m === "object" && m !== null && "_source" in m) {
        return m._source !== pluginName
      }
      return true
    })
  }

  // Build new managed index for this plugin
  const newManagedForPlugin: Record<string, number[]> = {}

  // Add this plugin's hooks (clean entries, no _source field)
  for (const [event, matchers] of Object.entries(pluginHooks)) {
    if (!result[event]) result[event] = []
    const indices: number[] = []
    for (const matcher of matchers) {
      indices.push(result[event].length)
      result[event].push({ ...matcher })
    }
    if (indices.length > 0) {
      newManagedForPlugin[event] = indices
    }
  }

  // Remove empty event arrays
  for (const event of Object.keys(result)) {
    if (result[event].length === 0) delete result[event]
  }

  // Update managed index
  if (Object.keys(newManagedForPlugin).length > 0) {
    managed[pluginName] = newManagedForPlugin
  } else {
    delete managed[pluginName]
  }

  const output: Record<string, unknown> = { hooks: result }
  if (Object.keys(managed).length > 0) {
    output._managed = managed
  }
  return output
}
