#!/usr/bin/env bun
/**
 * Enhanced package.json transform with Kilo dependency injection
 *
 * This script handles package.json conflicts by:
 * 1. Taking upstream's version (to get new dependencies)
 * 2. Transforming package names (opencode -> kilo)
 * 3. Injecting Kilo-specific dependencies
 * 4. Preserving Kilo's version number
 * 5. Preserving overrides and patchedDependencies
 * 6. Preserving Kilo's repository configuration
 * 7. Using "newest wins" strategy for dependency versions
 */

import { $ } from "bun"
import { info, success, warn, debug } from "../utils/logger"
import { getCurrentVersion } from "./preserve-versions"
import { oursHasKilocodeChanges } from "../utils/git"

/**
 * Extract clean version string from a version specifier
 * Removes ^, ~, >=, etc. prefixes
 */
function extractVersion(version: string): string | null {
  // Handle special formats that can't be compared
  if (
    version.startsWith("workspace:") ||
    version.startsWith("catalog:") ||
    version.startsWith("http://") ||
    version.startsWith("https://") ||
    version.startsWith("git://") ||
    version.startsWith("git+") ||
    version.startsWith("file:") ||
    version.startsWith("link:") ||
    version.startsWith("npm:")
  ) {
    return null
  }

  // Remove common prefixes: ^, ~, >=, >, <=, <, =
  const cleaned = version.replace(/^[\^~>=<]+/, "").trim()

  // Basic semver validation (x.y.z with optional pre-release/build)
  if (/^\d+\.\d+\.\d+/.test(cleaned)) {
    return cleaned
  }

  // Handle x.y format
  if (/^\d+\.\d+$/.test(cleaned)) {
    return cleaned + ".0"
  }

  // Handle single number
  if (/^\d+$/.test(cleaned)) {
    return cleaned + ".0.0"
  }

  return null
}

/**
 * Parse a semver string into components
 */
function parseSemver(version: string): { major: number; minor: number; patch: number; prerelease: string } | null {
  const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?/)
  if (!match) return null

  return {
    major: parseInt(match[1], 10),
    minor: parseInt(match[2], 10),
    patch: parseInt(match[3], 10),
    prerelease: match[4] || "",
  }
}

/**
 * Compare two version strings
 * Returns: 1 if a > b, -1 if a < b, 0 if equal
 * For special formats (URLs, catalog:, workspace:*), returns null (can't compare)
 */
function compareVersions(a: string, b: string): number | null {
  const cleanA = extractVersion(a)
  const cleanB = extractVersion(b)

  // If either can't be parsed, return null (can't compare)
  if (!cleanA || !cleanB) return null

  const semverA = parseSemver(cleanA)
  const semverB = parseSemver(cleanB)

  if (!semverA || !semverB) return null

  // Compare major.minor.patch
  if (semverA.major !== semverB.major) return semverA.major > semverB.major ? 1 : -1
  if (semverA.minor !== semverB.minor) return semverA.minor > semverB.minor ? 1 : -1
  if (semverA.patch !== semverB.patch) return semverA.patch > semverB.patch ? 1 : -1

  // Handle prerelease (no prerelease > prerelease)
  if (!semverA.prerelease && semverB.prerelease) return 1
  if (semverA.prerelease && !semverB.prerelease) return -1
  if (semverA.prerelease && semverB.prerelease) {
    return semverA.prerelease.localeCompare(semverB.prerelease)
  }

  return 0
}

function bun(value: unknown): { value: string; version: string } | null {
  if (typeof value !== "string") return null
  const match = value.match(/^bun@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/)
  if (!match) return null
  return { value, version: match[1] }
}

export function selectBunPackageManager(ours: unknown, theirs: unknown): string | undefined {
  const left = bun(ours)
  const right = bun(theirs)
  if (left && right) return compareVersions(left.version, right.version)! >= 0 ? left.value : right.value
  if (left) return left.value
  if (right) return right.value
  return undefined
}

export function fixPackageManager(
  pkg: Record<string, unknown>,
  path: string,
  ours: Record<string, unknown> | null,
  changes: string[],
): void {
  if (path !== "package.json") return
  const next = selectBunPackageManager(ours?.packageManager, pkg.packageManager)
  if (!next || pkg.packageManager === next) return
  const prior = typeof pkg.packageManager === "string" ? pkg.packageManager : "missing or invalid"
  changes.push(`packageManager: ${prior} -> ${next} (preserved Kilo pin)`)
  pkg.packageManager = next
}

export function assertBunPackageManager(current: unknown, base: unknown, upstream: unknown): void {
  const inputs = [bun(base), bun(upstream)].filter((item): item is NonNullable<typeof item> => item !== null)
  if (inputs.length === 0) return
  const required = inputs.reduce((max, item) => (compareVersions(item.version, max.version)! > 0 ? item : max))
  const actual = bun(current)
  if (!actual) {
    throw new Error(
      `Bun packageManager validation failed: merged value is invalid; expected at least ${required.value}`,
    )
  }
  if (compareVersions(actual.version, required.version)! >= 0) return
  throw new Error(`Bun packageManager downgrade detected: merged ${actual.value}, expected at least ${required.value}`)
}

/**
 * Merge two dependency objects using "newest wins" strategy
 * For non-comparable versions (URLs, catalog:, workspace:*), upstream (theirs) wins
 *
 * Key order preserves ours' order first (so kilo-only deps stay in their
 * original position), then appends theirs-only keys at the end. This avoids
 * relocating existing keys, which would otherwise let git's textual merge
 * produce duplicate JSON keys (ours keeps the line in place, theirs appears
 * to "add" the same key elsewhere → both survive the merge).
 */
export function mergeWithNewestVersions(
  ours: Record<string, string> | undefined,
  theirs: Record<string, string> | undefined,
  changes: string[],
  section: string,
): Record<string, string> {
  const result: Record<string, string> = {}

  // Seed with ours' keys in ours' order, applying newest-wins per key.
  if (ours) {
    for (const [name, ourVersion] of Object.entries(ours)) {
      const theirVersion = theirs?.[name]
      if (theirVersion === undefined) {
        result[name] = ourVersion
        changes.push(`${section}: preserved ${name}@${ourVersion} (kilo-only)`)
        continue
      }
      if (ourVersion === theirVersion) {
        result[name] = theirVersion
        continue
      }
      const cmp = compareVersions(ourVersion, theirVersion)
      if (cmp === null) {
        result[name] = theirVersion
        changes.push(`${section}: ${name} kept upstream ${theirVersion} (special format)`)
      } else if (cmp > 0) {
        result[name] = ourVersion
        changes.push(`${section}: ${name} ${theirVersion} -> ${ourVersion} (kilo newer)`)
      } else {
        result[name] = theirVersion
        if (cmp < 0) changes.push(`${section}: ${name} kept upstream ${theirVersion} (upstream newer)`)
      }
    }
  }

  // Append any theirs-only keys at the end, preserving theirs' relative order.
  if (theirs) {
    for (const [name, version] of Object.entries(theirs)) {
      if (result[name] === undefined) {
        result[name] = version
      }
    }
  }

  return result
}

export interface PackageJsonResult {
  file: string
  action: "transformed" | "skipped" | "failed" | "flagged"
  changes: string[]
  dryRun: boolean
}

export interface PackageJsonOptions {
  dryRun?: boolean
  verbose?: boolean
  preserveVersion?: boolean
}

export interface ReconcileOptions extends PackageJsonOptions {
  oursRef: string
  theirsRef: string
  /**
   * Files to skip (e.g. still-conflicted files where the user is going to
   * resolve manually). The reconciler would otherwise overwrite the conflict
   * markers and silently auto-resolve.
   */
  skip?: Set<string>
}

// Package name mappings
const PACKAGE_NAME_MAP: Record<string, string> = {
  "opencode-ai": "@kilocode/cli",
  "@opencode-ai/cli": "@kilocode/cli",
  "@opencode-ai/sdk": "@kilocode/sdk",
  "@opencode-ai/plugin": "@kilocode/plugin",
}

// Kilo-specific dependencies to inject into specific packages
// NOTE: When adding new Kilo-specific workspace dependencies (packages starting with @kilocode/kilo-*),
// add them here to prevent them from being removed during upstream merges
const KILO_DEPENDENCIES: Record<string, Record<string, string>> = {
  // packages/opencode/package.json needs these
  "packages/opencode/package.json": {
    "@kilocode/kilo-gateway": "workspace:*",
    "@kilocode/kilo-telemetry": "workspace:*",
  },
}

// Kilo-specific bin entries to set on specific packages
const KILO_BIN: Record<string, Record<string, string>> = {
  "packages/opencode/package.json": {
    kilo: "./bin/kilo",
    kilocode: "./bin/kilo",
  },
}

// Packages that should have their name transformed
const TRANSFORM_PACKAGE_NAMES: Record<string, string> = {
  "package.json": "@kilocode/kilo",
  "packages/opencode/package.json": "@kilocode/cli",
  "packages/plugin/package.json": "@kilocode/plugin",
  "packages/sdk/js/package.json": "@kilocode/sdk",
}

// Kilo-specific scripts to preserve from the base branch per package.json.
// Upstream's version wholesale-replaces the scripts block, so anything listed
// here gets re-applied from ours after taking theirs.
const PRESERVE_SCRIPTS: Record<string, string[]> = {
  "package.json": ["extension", "changeset", "changeset:version", "dev-setup", "postinstall"],
  "packages/opencode/package.json": ["test", "test:ci"],
}

// Upstream-only scripts to delete per package.json. These reference packages
// Kilo doesn't ship (desktop-electron, console/app, app) and would otherwise
// reappear on every merge.
const DELETE_UPSTREAM_SCRIPTS: Record<string, string[]> = {
  "package.json": ["dev:desktop", "dev:web", "dev:console"],
}

// Upstream-only catalog entries to delete per package.json. These are pulled
// in by upstream features (e.g. desktop Sentry integration) that Kilo doesn't
// ship, so they add install weight with zero consumers in our tree.
const DELETE_UPSTREAM_CATALOG: Record<string, string[]> = {
  "package.json": ["@sentry/solid", "@sentry/vite-plugin"],
}

/**
 * Re-apply Kilo-specific scripts on top of the upstream-shaped scripts block,
 * and prune upstream-only scripts that target packages Kilo doesn't ship.
 */
export function fixScripts(
  pkg: Record<string, unknown>,
  path: string,
  ours: Record<string, unknown> | null,
  changes: string[],
): void {
  const theirs = (pkg.scripts as Record<string, string> | undefined) || {}
  const oursScripts = (ours?.scripts as Record<string, string> | undefined) || {}

  for (const name of PRESERVE_SCRIPTS[path] || []) {
    const val = oursScripts[name]
    if (val && theirs[name] !== val) {
      theirs[name] = val
      changes.push(`scripts.${name}: preserved from base`)
    }
  }

  for (const name of DELETE_UPSTREAM_SCRIPTS[path] || []) {
    if (theirs[name]) {
      delete theirs[name]
      changes.push(`scripts.${name}: removed (upstream-only, no Kilo target)`)
    }
  }

  if (Object.keys(theirs).length > 0) pkg.scripts = theirs
}

/**
 * Prune upstream-only catalog entries that have no consumers in Kilo.
 */
export function fixCatalog(pkg: Record<string, unknown>, path: string, changes: string[]): void {
  const ws = pkg.workspaces as { catalog?: Record<string, string> } | undefined
  const cat = ws?.catalog
  if (!cat) return
  for (const name of DELETE_UPSTREAM_CATALOG[path] || []) {
    if (cat[name]) {
      delete cat[name]
      changes.push(`workspaces.catalog.${name}: removed (upstream-only, no Kilo consumer)`)
    }
  }
}

export function fixMetadata(
  pkg: Record<string, unknown>,
  path: string,
  ours: Record<string, unknown> | null,
  changes: string[],
): void {
  if (path !== "packages/opencode/package.json") return
  if (!ours) return
  if (Array.isArray(ours.keywords) && JSON.stringify(pkg.keywords) !== JSON.stringify(ours.keywords)) {
    pkg.keywords = ours.keywords
    changes.push("keywords: preserved from base")
  }
  if (typeof ours.private === "boolean" && pkg.private !== ours.private) {
    pkg.private = ours.private
    changes.push("private: preserved from base")
  }
}

/**
 * Check if file is a package.json
 */
export function isPackageJson(file: string): boolean {
  return file.endsWith("package.json")
}

/**
 * Transform dependencies in package.json
 */
function transformDependencies(deps: Record<string, string> | undefined): {
  result: Record<string, string>
  changes: string[]
} {
  if (!deps) return { result: {}, changes: [] }

  const result: Record<string, string> = {}
  const changes: string[] = []

  for (const [name, version] of Object.entries(deps)) {
    const newName = PACKAGE_NAME_MAP[name]
    if (newName) {
      result[newName] = version
      changes.push(`${name} -> ${newName}`)
    } else {
      result[name] = version
    }
  }

  return { result, changes }
}

/**
 * Transform a package.json file
 */
export async function transformPackageJson(file: string, options: PackageJsonOptions = {}): Promise<PackageJsonResult> {
  const changes: string[] = []

  if (options.dryRun) {
    info(`[DRY-RUN] Would transform package.json: ${file}`)
    return { file, action: "transformed", changes: [], dryRun: true }
  }

  // If our version has kilocode_change markers, flag for manual resolution
  if (await oursHasKilocodeChanges(file)) {
    warn(`${file} has kilocode_change markers — skipping auto-transform, needs manual resolution`)
    return { file, action: "flagged", changes: [], dryRun: false }
  }

  try {
    // Save Kilo's version BEFORE taking theirs
    let ourPkg: Record<string, unknown> | null = null
    try {
      const ourContent = await $`git show :2:${file}`.text() // :2: is "ours" in merge
      ourPkg = JSON.parse(ourContent)
    } catch {
      // File might not exist in ours (new file from upstream)
      // or we're not in a merge conflict - try reading current file
      try {
        const currentContent = await Bun.file(file).text()
        if (!currentContent.includes("<<<<<<<")) {
          // Not a conflict, read as-is
          ourPkg = JSON.parse(currentContent)
        }
      } catch {
        // File doesn't exist yet
      }
    }

    // Take upstream's version
    await $`git checkout --theirs ${file}`.quiet().nothrow()
    await $`git add ${file}`.quiet().nothrow()

    // Read and parse upstream's version
    const content = await Bun.file(file).text()
    const pkg = JSON.parse(content)

    // 1. Transform package name if needed
    const relativePath = file.replace(process.cwd() + "/", "")
    const newName = TRANSFORM_PACKAGE_NAMES[relativePath]
    if (newName && pkg.name !== newName) {
      changes.push(`name: ${pkg.name} -> ${newName}`)
      pkg.name = newName
    }

    fixPackageManager(pkg, relativePath, ourPkg, changes)

    // 2. Preserve Kilo version if requested
    if (options.preserveVersion !== false) {
      const kiloVersion = await getCurrentVersion()
      if (pkg.version !== kiloVersion) {
        changes.push(`version: ${pkg.version} -> ${kiloVersion}`)
        pkg.version = kiloVersion
      }
    }

    // 3. Merge dependencies with "newest wins" strategy
    if (ourPkg) {
      pkg.dependencies = mergeWithNewestVersions(
        ourPkg.dependencies as Record<string, string> | undefined,
        pkg.dependencies,
        changes,
        "dependencies",
      )

      pkg.devDependencies = mergeWithNewestVersions(
        ourPkg.devDependencies as Record<string, string> | undefined,
        pkg.devDependencies,
        changes,
        "devDependencies",
      )

      pkg.peerDependencies = mergeWithNewestVersions(
        ourPkg.peerDependencies as Record<string, string> | undefined,
        pkg.peerDependencies,
        changes,
        "peerDependencies",
      )

      // 4. Preserve/merge overrides
      const ourOverrides = ourPkg.overrides as Record<string, string> | undefined
      if (ourOverrides || pkg.overrides) {
        pkg.overrides = mergeWithNewestVersions(ourOverrides, pkg.overrides, changes, "overrides")
      }

      // 5. Preserve patchedDependencies (Kilo-specific, upstream won't have these)
      const ourPatchedDeps = ourPkg.patchedDependencies as Record<string, string> | undefined
      if (ourPatchedDeps) {
        pkg.patchedDependencies = pkg.patchedDependencies || {}
        for (const [name, patch] of Object.entries(ourPatchedDeps)) {
          if (!pkg.patchedDependencies[name]) {
            pkg.patchedDependencies[name] = patch
            changes.push(`patchedDependencies: preserved ${name}`)
          }
        }
      }

      // 6. Preserve repository (Kilo-specific, upstream doesn't have this)
      const ourRepo = ourPkg.repository
      if (ourRepo && JSON.stringify(pkg.repository) !== JSON.stringify(ourRepo)) {
        pkg.repository = ourRepo
        changes.push(`repository: preserved Kilo's repository configuration`)
      }

      fixMetadata(pkg, relativePath, ourPkg, changes)

      // 7. Handle workspaces for root package.json
      // Kilo has removed hosted platform packages (console/*, slack, etc.)
      // so we need to preserve Kilo's workspace configuration instead of taking upstream's
      const ourWorkspaces = ourPkg.workspaces as { packages?: string[]; catalog?: Record<string, string> } | undefined
      const theirWorkspaces = pkg.workspaces as { packages?: string[]; catalog?: Record<string, string> } | undefined

      if (relativePath === "package.json" && ourWorkspaces?.packages) {
        pkg.workspaces = pkg.workspaces || {}
        pkg.workspaces.packages = ourWorkspaces.packages
        changes.push(`workspaces.packages: preserved Kilo's workspace configuration`)
      }

      fixScripts(pkg, relativePath, ourPkg, changes)

      // Merge catalog with "newest wins" strategy
      if (ourWorkspaces?.catalog || theirWorkspaces?.catalog) {
        pkg.workspaces = pkg.workspaces || {}
        pkg.workspaces.catalog = mergeWithNewestVersions(
          ourWorkspaces?.catalog,
          theirWorkspaces?.catalog,
          changes,
          "workspaces.catalog",
        )
      }

      fixCatalog(pkg, relativePath, changes)
    }

    // 7. Transform dependency names (opencode -> kilo)
    if (pkg.dependencies) {
      const { result, changes: depChanges } = transformDependencies(pkg.dependencies)
      pkg.dependencies = result
      changes.push(...depChanges.map((c) => `dependencies: ${c}`))
    }

    if (pkg.devDependencies) {
      const { result, changes: devChanges } = transformDependencies(pkg.devDependencies)
      if (devChanges.length > 0) {
        pkg.devDependencies = result
        changes.push(...devChanges.map((c) => `devDependencies: ${c}`))
      }
    }

    if (pkg.peerDependencies) {
      const { result, changes: peerChanges } = transformDependencies(pkg.peerDependencies)
      if (peerChanges.length > 0) {
        pkg.peerDependencies = result
        changes.push(...peerChanges.map((c) => `peerDependencies: ${c}`))
      }
    }

    // 8. Inject Kilo-specific dependencies
    const kiloDeps = KILO_DEPENDENCIES[relativePath]
    if (kiloDeps) {
      pkg.dependencies = pkg.dependencies || {}
      for (const [name, version] of Object.entries(kiloDeps)) {
        if (!pkg.dependencies[name]) {
          pkg.dependencies[name] = version
          changes.push(`injected: ${name}`)
        }
      }
    }

    // 9. Set Kilo-specific bin entries
    const kiloBin = KILO_BIN[relativePath]
    if (kiloBin) {
      pkg.bin = kiloBin
      changes.push(`bin: set Kilo bin entries`)
    }

    // Write back with proper formatting
    const newContent = JSON.stringify(pkg, null, 2) + "\n"
    await Bun.write(file, newContent)
    await $`git add ${file}`.quiet().nothrow()

    if (changes.length > 0) {
      success(`Transformed ${file}: ${changes.length} changes`)
      if (options.verbose) {
        for (const change of changes) {
          debug(`  - ${change}`)
        }
      }
    }

    return { file, action: "transformed", changes, dryRun: false }
  } catch (err) {
    warn(`Failed to transform ${file}: ${err}`)
    return { file, action: "failed", changes: [], dryRun: false }
  }
}

/**
 * Transform conflicted package.json files
 */
export async function transformConflictedPackageJson(
  files: string[],
  options: PackageJsonOptions = {},
): Promise<PackageJsonResult[]> {
  const results: PackageJsonResult[] = []

  for (const file of files) {
    if (!isPackageJson(file)) {
      results.push({ file, action: "skipped", changes: [], dryRun: options.dryRun ?? false })
      continue
    }

    const result = await transformPackageJson(file, options)
    results.push(result)
  }

  return results
}

/**
 * Get Kilo's package.json from the base branch (main) for comparison
 * Used during pre-merge to compare upstream versions against Kilo's versions
 */
async function getKiloPackageJson(path: string, baseBranch = "main"): Promise<Record<string, unknown> | null> {
  try {
    // Try to get the file from origin/main (or whatever base branch)
    const content = await $`git show origin/${baseBranch}:${path}`.text()
    return JSON.parse(content)
  } catch {
    // File might not exist in Kilo
    return null
  }
}

/**
 * Transform all package.json files (pre-merge, on opencode branch)
 * This function merges Kilo's versions with upstream, using "newest wins" strategy
 */
export async function transformAllPackageJson(options: PackageJsonOptions = {}): Promise<PackageJsonResult[]> {
  const { Glob } = await import("bun")
  const results: PackageJsonResult[] = []

  // Find all package.json files
  const glob = new Glob("**/package.json")

  for await (const path of glob.scan({ absolute: false })) {
    // Skip node_modules
    if (path.includes("node_modules")) continue

    const file = Bun.file(path)
    if (!(await file.exists())) continue

    try {
      const content = await file.text()
      const pkg = JSON.parse(content) // This is upstream's version
      const changes: string[] = []

      // Get Kilo's version from base branch for comparison
      const kiloPkg = await getKiloPackageJson(path)

      // 1. Transform package name if needed
      const newName = TRANSFORM_PACKAGE_NAMES[path]
      if (newName && pkg.name !== newName) {
        changes.push(`name: ${pkg.name} -> ${newName}`)
        pkg.name = newName
      }

      fixPackageManager(pkg, path, kiloPkg, changes)

      // 2. Preserve Kilo version if requested
      if (options.preserveVersion !== false) {
        const kiloVersion = await getCurrentVersion()
        if (pkg.version !== kiloVersion) {
          changes.push(`version: ${pkg.version} -> ${kiloVersion}`)
          pkg.version = kiloVersion
        }
      }

      // 3. Merge dependencies with "newest wins" strategy (if Kilo has this file)
      if (kiloPkg) {
        pkg.dependencies = mergeWithNewestVersions(
          kiloPkg.dependencies as Record<string, string> | undefined,
          pkg.dependencies,
          changes,
          "dependencies",
        )

        pkg.devDependencies = mergeWithNewestVersions(
          kiloPkg.devDependencies as Record<string, string> | undefined,
          pkg.devDependencies,
          changes,
          "devDependencies",
        )

        pkg.peerDependencies = mergeWithNewestVersions(
          kiloPkg.peerDependencies as Record<string, string> | undefined,
          pkg.peerDependencies,
          changes,
          "peerDependencies",
        )

        // 4. Preserve/merge overrides
        const kiloOverrides = kiloPkg.overrides as Record<string, string> | undefined
        if (kiloOverrides || pkg.overrides) {
          pkg.overrides = mergeWithNewestVersions(kiloOverrides, pkg.overrides, changes, "overrides")
        }

        // 5. Preserve patchedDependencies (Kilo-specific, upstream won't have these)
        const kiloPatchedDeps = kiloPkg.patchedDependencies as Record<string, string> | undefined
        if (kiloPatchedDeps) {
          pkg.patchedDependencies = pkg.patchedDependencies || {}
          for (const [name, patch] of Object.entries(kiloPatchedDeps)) {
            if (!pkg.patchedDependencies[name]) {
              pkg.patchedDependencies[name] = patch
              changes.push(`patchedDependencies: preserved ${name}`)
            }
          }
        }

        // 6. Preserve repository (Kilo-specific, upstream doesn't have this)
        const kiloRepo = kiloPkg.repository
        if (kiloRepo && JSON.stringify(pkg.repository) !== JSON.stringify(kiloRepo)) {
          pkg.repository = kiloRepo
          changes.push(`repository: preserved Kilo's repository configuration`)
        }

        fixMetadata(pkg, path, kiloPkg, changes)

        // 7. Handle workspaces for root package.json
        // Kilo has removed hosted platform packages (console/*, slack, etc.)
        // so we need to preserve Kilo's workspace configuration instead of taking upstream's
        const kiloWorkspaces = kiloPkg.workspaces as
          | { packages?: string[]; catalog?: Record<string, string> }
          | undefined
        const upstreamWorkspaces = pkg.workspaces as
          | { packages?: string[]; catalog?: Record<string, string> }
          | undefined

        if (path === "package.json" && kiloWorkspaces?.packages) {
          pkg.workspaces = pkg.workspaces || {}
          pkg.workspaces.packages = kiloWorkspaces.packages
          changes.push(`workspaces.packages: preserved Kilo's workspace configuration`)
        }

        fixScripts(pkg, path, kiloPkg, changes)

        // Merge catalog with "newest wins" strategy
        if (kiloWorkspaces?.catalog || upstreamWorkspaces?.catalog) {
          pkg.workspaces = pkg.workspaces || {}
          pkg.workspaces.catalog = mergeWithNewestVersions(
            kiloWorkspaces?.catalog,
            upstreamWorkspaces?.catalog,
            changes,
            "workspaces.catalog",
          )
        }

        fixCatalog(pkg, path, changes)
      }

      // 7. Transform dependency names (opencode -> kilo)
      if (pkg.dependencies) {
        const { result, changes: depChanges } = transformDependencies(pkg.dependencies)
        if (depChanges.length > 0) {
          pkg.dependencies = result
          changes.push(...depChanges.map((c) => `dependencies: ${c}`))
        }
      }

      if (pkg.devDependencies) {
        const { result, changes: devChanges } = transformDependencies(pkg.devDependencies)
        if (devChanges.length > 0) {
          pkg.devDependencies = result
          changes.push(...devChanges.map((c) => `devDependencies: ${c}`))
        }
      }

      if (pkg.peerDependencies) {
        const { result, changes: peerChanges } = transformDependencies(pkg.peerDependencies)
        if (peerChanges.length > 0) {
          pkg.peerDependencies = result
          changes.push(...peerChanges.map((c) => `peerDependencies: ${c}`))
        }
      }

      // 8. Inject Kilo-specific dependencies
      const kiloDeps = KILO_DEPENDENCIES[path]
      if (kiloDeps) {
        pkg.dependencies = pkg.dependencies || {}
        for (const [name, version] of Object.entries(kiloDeps)) {
          if (!pkg.dependencies[name]) {
            pkg.dependencies[name] = version
            changes.push(`injected: ${name}`)
          }
        }
      }

      // 9. Set Kilo-specific bin entries
      const kiloBin = KILO_BIN[path]
      if (kiloBin) {
        pkg.bin = kiloBin
        changes.push(`bin: set Kilo bin entries`)
      }

      if (changes.length > 0) {
        if (!options.dryRun) {
          const newContent = JSON.stringify(pkg, null, 2) + "\n"
          await Bun.write(path, newContent)
          success(`Transformed ${path}: ${changes.length} changes`)
        } else {
          info(`[DRY-RUN] Would transform ${path}: ${changes.length} changes`)
        }
      }

      results.push({ file: path, action: "transformed", changes, dryRun: options.dryRun ?? false })
    } catch (err) {
      warn(`Failed to transform ${path}: ${err}`)
      results.push({ file: path, action: "failed", changes: [], dryRun: options.dryRun ?? false })
    }
  }

  return results
}

/**
 * Reconcile a single package.json after a merge has finished, regardless of
 * whether it was conflicted or auto-resolved (by rerere or git's textual
 * merge). Reads ours from `oursRef` and theirs from `theirsRef`, then applies
 * the same merge logic used for conflict resolution and writes the result to
 * the working tree. Stages the file.
 *
 * This is needed because rerere can replay stale resolutions for files like
 * `package.json` that include cosmetic reordering — those resolutions bypass
 * `transformConflictedPackageJson` entirely. Running this reconciler after
 * the merge guarantees our merge logic always wins.
 *
 * Returns "skipped" if neither side touched the file (or both sides match) so
 * callers can avoid unnecessary churn. Returns "flagged" if ours has
 * kilocode_change markers (manual review needed).
 */
export async function reconcilePackageJsonFromRefs(
  file: string,
  options: ReconcileOptions,
): Promise<PackageJsonResult> {
  const changes: string[] = []
  const dryRun = options.dryRun ?? false

  if (await oursHasKilocodeChanges(file)) {
    warn(`${file} has kilocode_change markers — skipping reconcile, needs manual resolution`)
    return { file, action: "flagged", changes: [], dryRun }
  }

  let ourPkg: Record<string, unknown> | null = null
  try {
    const ourContent = await $`git show ${options.oursRef}:${file}`.text()
    ourPkg = JSON.parse(ourContent)
  } catch {
    // file didn't exist in ours - that's fine
  }

  let pkg: Record<string, unknown> | null = null
  try {
    const theirContent = await $`git show ${options.theirsRef}:${file}`.text()
    pkg = JSON.parse(theirContent)
  } catch {
    // file didn't exist in theirs either - nothing to reconcile
  }

  if (!pkg) {
    if (!ourPkg) return { file, action: "skipped", changes: [], dryRun }
    pkg = JSON.parse(JSON.stringify(ourPkg))
  }
  if (!pkg) return { file, action: "skipped", changes: [], dryRun }

  const relativePath = file.replace(process.cwd() + "/", "")
  const newName = TRANSFORM_PACKAGE_NAMES[relativePath]
  if (newName && pkg.name !== newName) {
    changes.push(`name: ${pkg.name} -> ${newName}`)
    pkg.name = newName
  }

  fixPackageManager(pkg, relativePath, ourPkg, changes)

  if (options.preserveVersion !== false) {
    const kiloVersion = await getCurrentVersion()
    if (pkg.version !== kiloVersion) {
      changes.push(`version: ${pkg.version} -> ${kiloVersion}`)
      pkg.version = kiloVersion
    }
  }

  if (ourPkg) {
    pkg.dependencies = mergeWithNewestVersions(
      ourPkg.dependencies as Record<string, string> | undefined,
      pkg.dependencies as Record<string, string> | undefined,
      changes,
      "dependencies",
    )
    pkg.devDependencies = mergeWithNewestVersions(
      ourPkg.devDependencies as Record<string, string> | undefined,
      pkg.devDependencies as Record<string, string> | undefined,
      changes,
      "devDependencies",
    )
    pkg.peerDependencies = mergeWithNewestVersions(
      ourPkg.peerDependencies as Record<string, string> | undefined,
      pkg.peerDependencies as Record<string, string> | undefined,
      changes,
      "peerDependencies",
    )

    const ourOverrides = ourPkg.overrides as Record<string, string> | undefined
    if (ourOverrides || pkg.overrides) {
      pkg.overrides = mergeWithNewestVersions(
        ourOverrides,
        pkg.overrides as Record<string, string> | undefined,
        changes,
        "overrides",
      )
    }

    const ourPatched = ourPkg.patchedDependencies as Record<string, string> | undefined
    if (ourPatched) {
      pkg.patchedDependencies = (pkg.patchedDependencies as Record<string, string>) || {}
      const patched = pkg.patchedDependencies as Record<string, string>
      for (const [name, patch] of Object.entries(ourPatched)) {
        if (!patched[name]) {
          patched[name] = patch
          changes.push(`patchedDependencies: preserved ${name}`)
        }
      }
    }

    const ourRepo = ourPkg.repository
    if (ourRepo && JSON.stringify(pkg.repository) !== JSON.stringify(ourRepo)) {
      pkg.repository = ourRepo
      changes.push(`repository: preserved Kilo's repository configuration`)
    }

    fixMetadata(pkg, relativePath, ourPkg, changes)

    const ourWs = ourPkg.workspaces as { packages?: string[]; catalog?: Record<string, string> } | undefined
    const theirWs = pkg.workspaces as { packages?: string[]; catalog?: Record<string, string> } | undefined

    if (relativePath === "package.json" && ourWs?.packages) {
      pkg.workspaces = (pkg.workspaces as Record<string, unknown>) || {}
      ;(pkg.workspaces as { packages: string[] }).packages = ourWs.packages
      changes.push(`workspaces.packages: preserved Kilo's workspace configuration`)
    }

    fixScripts(pkg, relativePath, ourPkg, changes)

    if (ourWs?.catalog || theirWs?.catalog) {
      pkg.workspaces = (pkg.workspaces as Record<string, unknown>) || {}
      ;(pkg.workspaces as { catalog: Record<string, string> }).catalog = mergeWithNewestVersions(
        ourWs?.catalog,
        theirWs?.catalog,
        changes,
        "workspaces.catalog",
      )
    }

    fixCatalog(pkg, relativePath, changes)
  }

  if (pkg.dependencies) {
    const { result, changes: depChanges } = transformDependencies(pkg.dependencies as Record<string, string>)
    pkg.dependencies = result
    changes.push(...depChanges.map((c) => `dependencies: ${c}`))
  }
  if (pkg.devDependencies) {
    const { result, changes: devChanges } = transformDependencies(pkg.devDependencies as Record<string, string>)
    if (devChanges.length > 0) {
      pkg.devDependencies = result
      changes.push(...devChanges.map((c) => `devDependencies: ${c}`))
    }
  }
  if (pkg.peerDependencies) {
    const { result, changes: peerChanges } = transformDependencies(pkg.peerDependencies as Record<string, string>)
    if (peerChanges.length > 0) {
      pkg.peerDependencies = result
      changes.push(...peerChanges.map((c) => `peerDependencies: ${c}`))
    }
  }

  const kiloDeps = KILO_DEPENDENCIES[relativePath]
  if (kiloDeps) {
    pkg.dependencies = (pkg.dependencies as Record<string, string>) || {}
    const deps = pkg.dependencies as Record<string, string>
    for (const [name, version] of Object.entries(kiloDeps)) {
      if (!deps[name]) {
        deps[name] = version
        changes.push(`injected: ${name}`)
      }
    }
  }

  const kiloBin = KILO_BIN[relativePath]
  if (kiloBin) {
    pkg.bin = kiloBin
    changes.push(`bin: set Kilo bin entries`)
  }

  if (dryRun) {
    info(`[DRY-RUN] Would reconcile ${file}: ${changes.length} changes`)
    return { file, action: "transformed", changes, dryRun: true }
  }

  const newContent = JSON.stringify(pkg, null, 2) + "\n"
  await Bun.write(file, newContent)
  await $`git add ${file}`.quiet().nothrow()

  if (changes.length > 0) {
    success(`Reconciled ${file}: ${changes.length} changes`)
    if (options.verbose) {
      for (const change of changes) debug(`  - ${change}`)
    }
  }

  return { file, action: "transformed", changes, dryRun: false }
}

/**
 * Reconcile every package.json that differs between `oursRef` and `theirsRef`
 * after a merge. This is meant to run after `git merge` (whether the merge
 * was clean, conflict-resolved, or rerere-replayed) to ensure our merge logic
 * is the source of truth for package.json content.
 */
export async function reconcileAllPackageJson(options: ReconcileOptions): Promise<PackageJsonResult[]> {
  // Collect every package.json that differs in either direction so we cover
  // upstream-only and kilo-only files alike.
  const diffOurs = await $`git diff --name-only ${options.oursRef} -- '*package.json'`.text()
  const diffTheirs = await $`git diff --name-only ${options.theirsRef} -- '*package.json'`.text()
  const candidates = new Set<string>()
  for (const line of [...diffOurs.split("\n"), ...diffTheirs.split("\n")]) {
    const path = line.trim()
    if (!path) continue
    if (path.includes("node_modules")) continue
    if (!path.endsWith("package.json")) continue
    candidates.add(path)
  }

  const results: PackageJsonResult[] = []
  for (const file of candidates) {
    if (options.skip?.has(file)) {
      results.push({ file, action: "skipped", changes: [], dryRun: options.dryRun ?? false })
      continue
    }
    const f = Bun.file(file)
    if (!(await f.exists())) {
      // file was removed by the merge - nothing to reconcile
      continue
    }
    results.push(await reconcilePackageJsonFromRefs(file, options))
  }
  return results
}

// CLI entry point
if (import.meta.main) {
  const args = process.argv.slice(2)
  const dryRun = args.includes("--dry-run")
  const verbose = args.includes("--verbose")

  const files = args.filter((a) => !a.startsWith("--"))

  if (files.length === 0) {
    info("Usage: transform-package-json.ts [--dry-run] [--verbose] <file1> <file2> ...")
    process.exit(1)
  }

  if (dryRun) {
    info("Running in dry-run mode")
  }

  const results = await transformConflictedPackageJson(files, { dryRun, verbose })

  const transformed = results.filter((r) => r.action === "transformed")
  const totalChanges = results.reduce((sum, r) => sum + r.changes.length, 0)

  console.log()
  success(`Transformed ${transformed.length} package.json files with ${totalChanges} changes`)

  if (dryRun) {
    info("Run without --dry-run to apply changes")
  }
}
