import { execFile, execFileSync } from "node:child_process"
import fs from "node:fs/promises"
import path from "node:path"
import { promisify } from "node:util"

import type * as TF from "type-fest"

import { dedupeSingletons } from "./link-packages"

interface Config {
  dedupeSingletons?: boolean | string[]
}

interface ManifestSibling {
  repo: string
  path: string // relative to the consumer, e.g. "../common"
  ref: string | null
  sha: string | null
  version: string | null // nearest git tag, if any
}

interface Manifest {
  schema: 1
  consumer: { repo: string; ref: string | null; sha: string | null }
  createdAt: string
  siblings: ManifestSibling[]
}

interface LinkRemoteOptions {
  /** Write the resolved transitive closure ({repo, ref, sha, version}) to this path. */
  manifestOut?: string
  /** Pin-clone each sibling at the SHA recorded in this manifest (revert), instead of branch-first. */
  fromManifest?: string
}

/** A discovered sibling repo root and the ref it resolved to (branch name, or null if detached/pinned). */
type Sibling = { abs: string; ref: string | null }

// execFile (not a shell) so branch names / refs / SHAs can't inject shell syntax.
const git = (args: string[], cwd?: string): string =>
  execFileSync(`git`, args, { cwd, encoding: `utf8` }).trim()

const execFileAsync = promisify(execFile)

// Async git so independent clones (a BFS level's siblings, or all pinned --from clones) run
// concurrently: the sync `git` helper's execFileSync would serialize them by blocking the event
// loop until each finishes. Output is captured (not inherited) so concurrent progress can't
// interleave into unreadable logs; callers log one line per repo.
const gitAsync = (args: string[], cwd?: string): Promise<unknown> =>
  execFileAsync(`git`, args, { cwd })

const tryGit = (args: string[], cwd?: string): string | null => {
  try {
    return git(args, cwd)
  } catch {
    return null
  }
}

const bunInstall = (cwd: string): void =>
  void execFileSync(`bun`, [`install`], { cwd, stdio: `inherit` })

const exists = (p: string): Promise<boolean> =>
  fs
    .stat(p)
    .then(() => true)
    .catch(() => false)

const readPkg = async (dir: string): Promise<TF.PackageJson | null> => {
  try {
    return JSON.parse(await fs.readFile(path.join(dir, `package.json`), `utf8`))
  } catch {
    return null
  }
}

const getConfig = async (cwd: string): Promise<Config | null> =>
  import(path.join(cwd, `10stars.config`)).catch(() => null)

const getOrg = (): string => {
  const org = process.env.GITHUB_ORG ?? process.env.GITHUB_REPOSITORY?.split(`/`)[0]
  if (!org) {
    throw new Error(
      `Cannot determine org: set GITHUB_ORG, or run inside GitHub Actions (GITHUB_REPOSITORY)`,
    )
  }
  return org
}

const repoUrl = (repo: string) => `https://github.com/${getOrg()}/${repo}.git`

/**
 * Absolute repo roots referenced by one dir's `file:` deps.
 * `file:../common/env` → `<dir>/../common`; whole-repo `file:../frontend-toolkit` → itself.
 * Self-refs (`file:.`) and non-parent paths are skipped, so registry deps (incl.
 * `@anime.club/translations`) are never included.
 */
const fileSiblingRoots = async (dir: string): Promise<string[]> => {
  const pkg = await readPkg(dir)
  if (!pkg) return []
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
  const roots = new Set<string>()
  for (const spec of Object.values(allDeps)) {
    if (typeof spec !== `string` || !spec.startsWith(`file:`)) continue
    const rel = spec.slice(`file:`.length)
    if (!rel.startsWith(`..`)) continue
    const segments = rel.split(`/`)
    const firstNamed = segments.findIndex((s) => s !== `..`)
    if (firstNamed < 0) continue
    roots.add(path.resolve(dir, segments.slice(0, firstNamed + 1).join(`/`)))
  }
  return [...roots]
}

/**
 * BFS the transitive `file:` sibling graph from `cwd` in dependents-first order, invoking `visit`
 * on each level's newly discovered siblings concurrently (`Promise.all`). Cloning a sibling in
 * `visit` makes its own `file:` deps discoverable when the next level expands from it. Levels stay
 * sequential (a child is only found after its parent is cloned) but siblings within a level are
 * independent and clone in parallel. The `visited` set dedupes repos reached by more than one path,
 * both across and within a level. Returns the deduped siblings in discovery (dependents-first) order.
 */
const bfsSiblings = async (
  cwd: string,
  visit: (sib: string) => Promise<void>,
): Promise<string[]> => {
  const visited = new Set<string>([path.resolve(cwd)])
  const order: string[] = []
  let frontier = [path.resolve(cwd)]
  while (frontier.length) {
    const level: string[] = []
    for (const dir of frontier) {
      for (const sib of await fileSiblingRoots(dir)) {
        if (visited.has(sib)) continue // dedupe repos reached via multiple file: paths
        visited.add(sib)
        level.push(sib)
      }
    }
    await Promise.all(level.map(visit)) // clone this level's siblings in parallel
    order.push(...level)
    frontier = level
  }
  return order
}

/**
 * The first of `candidates` (priority order) that exists as a branch on the remote, else null.
 * Queries only those specific refs in one `ls-remote` — no full head listing. A non-zero exit means
 * the repo is unreadable (private + missing/unauthorized clone token → "Repository not found") —
 * surfaced immediately rather than masquerading as a missing branch.
 */
const matchRemoteBranch = (url: string, candidates: string[]): string | null => {
  let out: string
  try {
    out = git([`ls-remote`, url, ...candidates.map((c) => `refs/heads/${c}`)])
  } catch (err) {
    const stderr = (err as { stderr?: string | Buffer })?.stderr
    throw new Error(
      `Cannot read ${url}: repo is unreachable — if private, the clone token is missing or ` +
        `unauthorized. Pass app-key to setup-siblings and install the GitHub App on this repo ` +
        `with contents:read.\n${(stderr ?? (err as Error)?.message ?? ``).toString().trim()}`,
    )
  }
  // Lines are "<sha>\trefs/heads/<branch>"; a branch may itself contain slashes (e.g. alice/x).
  const present = new Set(
    out
      .split(`\n`)
      .map((line) => line.split(`refs/heads/`)[1])
      .filter(Boolean),
  )
  return candidates.find((c) => present.has(c)) ?? null
}

/**
 * Clone a missing sibling as a flat peer, returning the branch it landed on. Prefers a branch
 * matching the consumer's coordinated branch (PR head, then push branch) when the sibling has one;
 * otherwise clones the sibling's own default branch directly. A default-branch clone needs no
 * `--branch`, so the `ls-remote` probe is skipped entirely when no coordinated branch is set — one
 * fewer round-trip for local/manual runs (in GitHub Actions GITHUB_REF_NAME is always set).
 */
const cloneSibling = async (
  url: string,
  repo: string,
  sib: string,
  dest: string,
): Promise<string | null> => {
  const coordinated = [
    process.env.GITHUB_HEAD_REF, // pull_request events
    process.env.GITHUB_REF_NAME, // push events
  ].filter((v): v is string => Boolean(v))
  const ref = coordinated.length ? matchRemoteBranch(url, coordinated) : null

  console.info(`linkRemote: cloning ${repo}@${ref ?? `(default)`} -> ${dest}`)
  await gitAsync([
    `clone`,
    `--depth`,
    `1`,
    `--single-branch`,
    ...(ref ? [`--branch`, ref] : []),
    url,
    sib,
  ])
  return ref ?? currentRef(sib)
}

const headSha = (dir: string): string | null => tryGit([`rev-parse`, `HEAD`], dir)

const nearestTag = (dir: string): string | null => tryGit([`describe`, `--tags`, `--abbrev=0`], dir)

const currentRef = (dir: string): string | null => {
  const branch = tryGit([`rev-parse`, `--abbrev-ref`, `HEAD`], dir)
  return branch === `HEAD` ? null : branch
}

const clonePinned = async (url: string, sha: string, dir: string): Promise<void> => {
  await gitAsync([`init`, dir])
  await gitAsync([`remote`, `add`, `origin`, url], dir)
  await gitAsync([`fetch`, `--depth`, `1`, `origin`, sha], dir) // GitHub allows fetching a reachable SHA
  await gitAsync([`checkout`, `FETCH_HEAD`], dir)
}

/**
 * Walk the transitive `file:` graph from `cwd`, cloning each missing sibling as a flat peer.
 * Returns siblings in discovery (BFS) order — dependents before their dependencies.
 */
const cloneClosureBranchFirst = async (cwd: string): Promise<Sibling[]> => {
  // Parallel visits resolve out of order; key refs by abs path and reassemble in discovery order.
  const refs = new Map<string, string | null>()
  const discovered = await bfsSiblings(cwd, async (sib) => {
    if (await exists(sib)) {
      console.info(`linkRemote: ${path.relative(cwd, sib)} already present; not cloning`)
      refs.set(sib, currentRef(sib))
      return
    }
    const repo = path.basename(sib)
    refs.set(sib, await cloneSibling(repoUrl(repo), repo, sib, path.relative(cwd, sib)))
  })
  return discovered.map((abs) => ({ abs, ref: refs.get(abs) ?? null }))
}

const writeManifest = async (cwd: string, siblings: Sibling[], out: string): Promise<void> => {
  const manifest: Manifest = {
    schema: 1,
    consumer: {
      repo: path.basename(path.resolve(cwd)),
      ref: process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? currentRef(cwd),
      sha: headSha(cwd),
    },
    createdAt: new Date().toISOString(),
    siblings: siblings.map(({ abs, ref }) => ({
      repo: path.basename(abs),
      path: path.relative(cwd, abs),
      ref,
      sha: headSha(abs),
      version: nearestTag(abs),
    })),
  }
  await fs.writeFile(out, JSON.stringify(manifest, null, 2) + `\n`)
  console.info(`linkRemote: wrote manifest -> ${out}`)
}

const linkRemoteFromManifest = async (cwd: string, manifestPath: string): Promise<void> => {
  const manifest: Manifest = JSON.parse(await fs.readFile(manifestPath, `utf8`))
  if (!manifest.siblings?.length) {
    console.info(`linkRemote --from: manifest has no siblings; nothing to do`)
    return
  }

  // Pinned clones are mutually independent (each at a recorded SHA, no discovery), so materialize
  // them all in parallel; leaves-first install order is still enforced below.
  await Promise.all(
    manifest.siblings.map(async (sib) => {
      const abs = path.resolve(cwd, sib.path)
      if (await exists(abs)) {
        console.info(`linkRemote --from: ${sib.path} already present; leaving as-is`)
        return
      }
      if (!sib.sha) throw new Error(`Manifest entry "${sib.repo}" has no sha to pin`)
      console.info(`linkRemote --from: pinning ${sib.repo}@${sib.sha} -> ${sib.path}`)
      await clonePinned(repoUrl(sib.repo), sib.sha, abs)
    }),
  )

  installLeavesFirst(
    cwd,
    manifest.siblings.map((s) => path.resolve(cwd, s.path)),
  )
}

/**
 * Install siblings leaves-first (reverse of dependents-first discovery order) so a hoisted
 * sibling (e.g. `common`) is installed before a repo that links into it.
 * Assumes an acyclic, mostly-linear `file:` graph; a diamond could need a real topological sort.
 */
const installLeavesFirst = (cwd: string, siblingsInDiscoveryOrder: string[]): void => {
  for (const abs of [...siblingsInDiscoveryOrder].reverse()) {
    console.info(`linkRemote: bun install in ${path.relative(cwd, abs)}`)
    bunInstall(abs)
  }
}

/**
 * CI: materialize the transitive `file:` sibling graph that a standalone checkout lacks.
 *
 * Default (branch-first): BFS the graph, shallow single-branch clone each missing sibling at
 * its resolved ref, then `bun install` leaves-first. `--emit-manifest` records the exact
 * {repo, ref, sha, version} set (a cross-repo lockfile snapshot for revert). `--from` skips
 * branch-first and pin-clones each sibling at the manifest's recorded SHA.
 *
 * Linking is done by the consumer's own `bun install` resolving the `file:` specs afterwards;
 * run `dedupe` after that. No-op for repos with no `file:` siblings, and skips siblings already
 * present (local dev).
 */
export const linkRemote = async (cwd: string, opts: LinkRemoteOptions = {}): Promise<void> => {
  if (opts.fromManifest) return linkRemoteFromManifest(cwd, opts.fromManifest)

  const siblings = await cloneClosureBranchFirst(cwd)
  if (!siblings.length) {
    console.info(`linkRemote: no file: siblings; nothing to do`)
    return
  }

  installLeavesFirst(
    cwd,
    siblings.map((s) => s.abs),
  )

  if (opts.manifestOut) await writeManifest(cwd, siblings, opts.manifestOut)
}

/** Present transitive `file:` siblings (no cloning) — used for post-install dedupe. */
const collectPresentSiblings = async (cwd: string): Promise<string[]> => {
  const found: string[] = []
  await bfsSiblings(cwd, async (sib) => {
    if (await exists(sib)) found.push(sib)
  })
  return found
}

/**
 * CI, run AFTER the consumer's `bun install`: collapse type singletons (`@types/react`, …)
 * across the transitive linked graph onto the consumer's own copy. A standalone CI checkout
 * has no hoisted workspace root, so the canonical is the consumer (`cwd`).
 */
export const dedupeRemote = async (cwd: string): Promise<void> => {
  const config = await getConfig(cwd)
  const siblings = await collectPresentSiblings(cwd)
  if (!siblings.length) return
  await dedupeSingletons(cwd, [], siblings, config?.dedupeSingletons, cwd)
}
