#!/usr/bin/env node

import fs from "node:fs/promises"
import path from "node:path"
import process from "node:process"
import { fileURLToPath } from "node:url"

const packageRoot = path.resolve(
  path.dirname(fileURLToPath(import.meta.url)),
  "..",
  ".."
)
const registryRoot = path.join(packageRoot, "registry")
const registryIndexPath = path.join(registryRoot, "index.json")

const CONFIG_FILE = "banhaten.config.json"
const FONT_IMPORTS = [
  '@import "@fontsource/inter/400.css";',
  '@import "@fontsource/inter/500.css";',
  '@import "@fontsource/inter/600.css";',
  '@import "@fontsource/inter/700.css";',
]
const TAILWIND_IMPORT = '@import "tailwindcss";'
const SETUP_DEV_DEPENDENCIES = {
  tailwindcss: "^4.0.0",
}
const VITE_DEV_DEPENDENCIES = {
  "@tailwindcss/vite": "^4.0.0",
}
const CSS_START = "/* Banhaten design system tokens:"
const CSS_END = "/* End Banhaten design system tokens. */"
const syncExistsCache = new Map()

const DEFAULT_CONFIG = {
  $schema: "https://banhaten.dev/schema.json",
  style: "default",
  tsx: true,
  tailwind: {
    css: "app/globals.css",
  },
  aliases: {
    ui: "@/components/ui",
    utils: "@/lib/utils",
    hooks: "@/hooks",
  },
}

const registry = await readJson(registryIndexPath)

main().catch((error) => {
  console.error(`banhaten: ${error.message}`)
  process.exitCode = 1
})

async function main() {
  const { command, positionals, flags } = parseArgs(process.argv.slice(2))

  if (!command || command === "help" || flags.help || flags.h) {
    printHelp()
    return
  }

  if (command === "list") {
    listComponents()
    return
  }

  if (command === "init") {
    await initProject(flags)
    return
  }

  if (command === "add") {
    await addComponents(positionals, flags)
    return
  }

  if (command === "diff") {
    await diffComponents(positionals, flags)
    return
  }

  if (command === "update") {
    await updateComponents(positionals, flags)
    return
  }

  throw new Error(`unknown command "${command}"`)
}

function parseArgs(argv) {
  const flags = {}
  const positionals = []

  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index]

    if (arg === "-h") {
      flags.h = true
      continue
    }

    if (!arg.startsWith("--")) {
      positionals.push(arg)
      continue
    }

    const [rawKey, inlineValue] = arg.slice(2).split("=")
    const key = rawKey.trim()

    if (inlineValue !== undefined) {
      flags[key] = inlineValue
      continue
    }

    const next = argv[index + 1]
    if ((key === "cwd" || key === "out") && next && !next.startsWith("-")) {
      flags[key] = next
      index += 1
      continue
    }

    flags[key] = true
  }

  const [command, ...rest] = positionals
  return { command, positionals: rest, flags }
}

function printHelp() {
  console.log(`Banhaten design system

Usage:
  banhaten init [--cwd <path>] [--force] [--dry-run]
  banhaten add <component...> [--cwd <path>] [--force] [--dry-run]
  banhaten diff [component...] [--cwd <path>] [--force]
  banhaten update [component...] [--cwd <path>] [--force] [--dry-run]
  banhaten list

Examples:
  npx banhaten init
  npx banhaten add button
  npx banhaten diff
  npx banhaten update button
`)
}

function listComponents() {
  const names = Object.keys(registry.components)

  console.log("Available Banhaten design system components:")
  for (const name of names) {
    const component = registry.components[name]
    console.log(`- ${name}: ${component.description}`)
  }
}

async function initProject(flags) {
  const cwd = path.resolve(String(flags.cwd || process.cwd()))
  const dryRun = Boolean(flags["dry-run"])
  const force = Boolean(flags.force)
  await warmPathHints(cwd)
  const config = await loadConfig(cwd)
  const writes = []

  writes.push(
    ...(await writeConfig(cwd, config, { dryRun, force })),
    ...(await writeBaseFiles(cwd, config, { dryRun, force })),
    ...(await writeGlobalCss(cwd, config, { dryRun, force })),
    ...(await writeAliasConfig(cwd, config, { dryRun, force }))
  )

  const dependencyWrites = await updatePackageJson(
    cwd,
    await getPackageChanges(cwd, registry.base.dependencies || {}),
    { dryRun }
  )
  writes.push(...dependencyWrites)

  printResult("Initialized Banhaten design system", writes, dryRun)
  printInstallHint(writes, dryRun)
}

async function addComponents(names, flags) {
  if (names.length === 0) {
    throw new Error("missing component name. Try `banhaten add button`.")
  }

  await writeComponents(names, flags, {
    inferInstalled: false,
    title: `Added ${names.join(", ")}`,
  })
}

async function diffComponents(names, flags) {
  await writeComponents(names, { ...flags, "dry-run": true }, {
    inferInstalled: true,
    title: names.length
      ? `Checked ${names.join(", ")} for updates`
      : "Checked installed components for updates",
  })
}

async function updateComponents(names, flags) {
  await writeComponents(names, flags, {
    inferInstalled: true,
    title: names.length
      ? `Updated ${names.join(", ")}`
      : "Updated installed components",
  })
}

async function writeComponents(requestedNames, flags, options) {
  const cwd = path.resolve(String(flags.cwd || process.cwd()))
  const dryRun = Boolean(flags["dry-run"])
  const force = Boolean(flags.force)
  await warmPathHints(cwd)
  const config = await loadConfig(cwd)
  const selectedNames =
    requestedNames.length > 0
      ? requestedNames
      : options.inferInstalled
        ? await findInstalledComponents(cwd, config)
        : requestedNames

  if (selectedNames.length === 0) {
    throw new Error(
      "no installed components found. Run `banhaten add button` first, or pass component names."
    )
  }

  const names = resolveRegistryDependencies(selectedNames)

  const writes = []
  const deps = { ...(registry.base.dependencies || {}) }

  writes.push(
    ...(await writeConfig(cwd, config, { dryRun, force })),
    ...(await writeBaseFiles(cwd, config, { dryRun, force })),
    ...(await writeAliasConfig(cwd, config, { dryRun, force }))
  )

  for (const name of names) {
    const component = registry.components[name]
    if (!component) {
      throw new Error(`unknown component "${name}". Run \`banhaten list\`.`)
    }

    Object.assign(deps, component.dependencies || {})
    writes.push(
      ...(await writeRegistryFiles(cwd, config, component.files || [], {
        dryRun,
        force,
      }))
    )
  }

  writes.push(...(await updatePackageJson(
    cwd,
    await getPackageChanges(cwd, deps),
    { dryRun }
  )))

  printResult(options.title, writes, dryRun)
  printInstallHint(writes, dryRun)
}

async function findInstalledComponents(cwd, config) {
  const installed = []

  for (const [name, component] of Object.entries(registry.components)) {
    for (const file of component.files || []) {
      const targetPath = path.join(cwd, resolveTarget(cwd, config, file.target))
      if (await exists(targetPath)) {
        installed.push(name)
        break
      }
    }
  }

  return installed
}

function resolveRegistryDependencies(names) {
  const resolved = []
  const seen = new Set()
  const visiting = new Set()

  for (const name of names) {
    visit(name)
  }

  return resolved

  function visit(name) {
    const component = registry.components[name]
    if (!component) {
      throw new Error(`unknown component "${name}". Run \`banhaten list\`.`)
    }

    if (seen.has(name)) return
    if (visiting.has(name)) {
      throw new Error(`circular registry dependency detected at "${name}".`)
    }

    visiting.add(name)
    for (const dependency of component.registryDependencies || []) {
      visit(dependency)
    }
    visiting.delete(name)

    seen.add(name)
    resolved.push(name)
  }
}

async function loadConfig(cwd) {
  const configPath = path.join(cwd, CONFIG_FILE)
  const componentsPath = path.join(cwd, "components.json")
  const config = structuredClone(DEFAULT_CONFIG)

  const componentsConfig = await readJsonIfExists(componentsPath)
  let hasExplicitCssPath = false
  if (componentsConfig) {
    if (componentsConfig.tailwind?.css) {
      config.tailwind.css = componentsConfig.tailwind.css
      hasExplicitCssPath = true
    }
    config.aliases.utils = componentsConfig.aliases?.utils || config.aliases.utils
    config.aliases.hooks = componentsConfig.aliases?.hooks || config.aliases.hooks
    config.aliases.ui =
      componentsConfig.aliases?.ui ||
      appendAliasSegment(componentsConfig.aliases?.components, "ui") ||
      config.aliases.ui
  }

  const localConfig = await readJsonIfExists(configPath)
  if (localConfig) {
    config.style = localConfig.style || config.style
    config.tsx = localConfig.tsx ?? config.tsx
    if (localConfig.tailwind?.css) hasExplicitCssPath = true
    config.tailwind = { ...config.tailwind, ...(localConfig.tailwind || {}) }
    config.aliases = { ...config.aliases, ...(localConfig.aliases || {}) }
  }

  if (!hasExplicitCssPath) {
    config.tailwind.css = await detectCssPath(cwd, config.tailwind.css)
  }

  return config
}

async function detectCssPath(cwd, fallback) {
  const candidates = [
    "app/globals.css",
    "src/app/globals.css",
    "src/index.css",
    "src/globals.css",
    "styles/globals.css",
  ]

  for (const candidate of candidates) {
    if (await exists(path.join(cwd, candidate))) return candidate
  }

  if (await exists(path.join(cwd, "src", "app"))) return "src/app/globals.css"

  return fallback
}

async function writeConfig(cwd, config, options) {
  const configPath = path.join(cwd, CONFIG_FILE)
  if ((await exists(configPath)) && !options.force) {
    return [`kept ${relative(cwd, configPath)}`]
  }

  const status = await writeFile(
    configPath,
    `${JSON.stringify(config, null, 2)}\n`,
    options
  )
  return [`${status} ${relative(cwd, configPath)}`]
}

async function writeBaseFiles(cwd, config, options) {
  return writeRegistryFiles(cwd, config, registry.base.files || [], options)
}

async function writeRegistryFiles(cwd, config, files, options) {
  const writes = []

  for (const file of files) {
    const sourcePath = path.join(registryRoot, file.source)
    const targetPath = path.join(cwd, resolveTarget(cwd, config, file.target))
    const source = await fs.readFile(sourcePath)
    const content = isTextRegistryFile(file.source)
      ? applyTemplate(source.toString("utf8"), config)
      : source

    const status = await writeFile(targetPath, content, options)
    writes.push(`${status} ${relative(cwd, targetPath)}`)
  }

  return writes
}

async function writeGlobalCss(cwd, config, options) {
  const cssPath = path.join(cwd, config.tailwind.css)
  const sourcePath = path.join(registryRoot, "styles", "globals.css")
  const tokenCss = await fs.readFile(sourcePath, "utf8")
  const current = await readTextIfExists(cssPath)

  if (current?.includes(CSS_START) && !options.force) {
    return [`kept ${relative(cwd, cssPath)}`]
  }

  const nextCss = current
    ? `${removeExistingTokenBlock(current).trimEnd()}\n\n${tokenCss}\n`
    : `${tokenCss}\n`
  const next = ensureGlobalCssImports(nextCss)

  const status = await writeFile(cssPath, next, { ...options, force: true })
  return [`${status} ${relative(cwd, cssPath)}`]
}

async function writeAliasConfig(cwd, config, options) {
  if (!usesRootAlias(config, "@")) return []

  const writes = []
  writes.push(...(await writeTypescriptAliasConfig(cwd, "@", options)))
  writes.push(...(await writeViteAliasConfig(cwd, "@", options)))
  return writes
}

function usesRootAlias(config, aliasRoot) {
  return Object.values(config.aliases || {}).some((alias) =>
    alias === aliasRoot || alias?.startsWith(`${aliasRoot}/`)
  )
}

async function writeTypescriptAliasConfig(cwd, aliasRoot, options) {
  const tsconfigPath = await findTypescriptConfigPath(cwd)
  if (!tsconfigPath) return []

  const config = await readJsonc(tsconfigPath)
  const compilerOptions = config.compilerOptions || {}
  const paths = compilerOptions.paths || {}
  const sourceRoot = shouldUseSrcDirectory(cwd) ? "./src" : "."
  const aliasPattern = `${aliasRoot}/*`
  const targetPattern = `${sourceRoot}/*`

  if (Array.isArray(paths[aliasPattern]) && paths[aliasPattern].includes(targetPattern)) {
    return [`kept ${relative(cwd, tsconfigPath)}`]
  }

  config.compilerOptions = {
    ...compilerOptions,
    paths: {
      ...paths,
      [aliasPattern]: [targetPattern],
    },
  }

  const status = await writeFile(tsconfigPath, `${JSON.stringify(config, null, 2)}\n`, {
    dryRun: options.dryRun,
    force: true,
  })
  return [`${status} ${relative(cwd, tsconfigPath)}`]
}

async function findTypescriptConfigPath(cwd) {
  const candidates = [
    "tsconfig.app.json",
    "tsconfig.json",
    "jsconfig.json",
  ]

  for (const candidate of candidates) {
    const target = path.join(cwd, candidate)
    if (await exists(target)) return target
  }

  return null
}

async function writeViteAliasConfig(cwd, aliasRoot, options) {
  const viteConfigPath = await findViteConfigPath(cwd)
  if (!viteConfigPath) return []

  const current = await fs.readFile(viteConfigPath, "utf8")
  const sourceRoot = shouldUseSrcDirectory(cwd) ? "./src" : "."
  const aliasLine = `"${aliasRoot}": fileURLToPath(new URL("${sourceRoot}", import.meta.url))`

  if (
    current.includes(aliasLine) &&
    current.includes("@tailwindcss/vite") &&
    current.includes("tailwindcss()")
  ) {
    return [`kept ${relative(cwd, viteConfigPath)}`]
  }

  let next = ensureNodeUrlImport(current)
  next = ensureTailwindViteImport(next)
  next = addViteResolveAlias(next, aliasRoot, sourceRoot)
  next = addVitePluginCall(next, "tailwindcss()")

  const status = await writeFile(viteConfigPath, next, {
    dryRun: options.dryRun,
    force: true,
  })
  return [`${status} ${relative(cwd, viteConfigPath)}`]
}

async function findViteConfigPath(cwd) {
  const candidates = [
    "vite.config.ts",
    "vite.config.mts",
    "vite.config.js",
    "vite.config.mjs",
  ]

  for (const candidate of candidates) {
    const target = path.join(cwd, candidate)
    if (await exists(target)) return target
  }

  return null
}

function ensureNodeUrlImport(source) {
  if (source.includes("fileURLToPath") && source.includes("node:url")) return source

  return `import { fileURLToPath, URL } from "node:url"\n${source}`
}

function ensureTailwindViteImport(source) {
  if (source.includes("@tailwindcss/vite")) return source

  return `import tailwindcss from "@tailwindcss/vite"\n${source}`
}

function addViteResolveAlias(source, aliasRoot, sourceRoot) {
  const aliasLine = `"${aliasRoot}": fileURLToPath(new URL("${sourceRoot}", import.meta.url))`
  if (source.includes(aliasLine)) return source
  if (!source.includes("defineConfig({")) return source

  const resolveBlock = [
    "  resolve: {",
    "    alias: {",
    `      "${aliasRoot}": fileURLToPath(new URL("${sourceRoot}", import.meta.url)),`,
    "    },",
    "  },",
  ].join("\n")

  return source.replace("defineConfig({", `defineConfig({\n${resolveBlock}`)
}

function addVitePluginCall(source, pluginCall) {
  if (source.includes(pluginCall)) return source

  const pluginsPattern = /plugins:\s*\[([\s\S]*?)\]/
  if (pluginsPattern.test(source)) {
    return source.replace(pluginsPattern, (_match, plugins) => {
      const currentPlugins = plugins.trim().replace(/,\s*$/, "")
      const nextPlugins = currentPlugins
        ? `${currentPlugins}, ${pluginCall}`
        : pluginCall
      return `plugins: [${nextPlugins}]`
    })
  }

  if (!source.includes("defineConfig({")) return source

  return source.replace("defineConfig({", `defineConfig({\n  plugins: [${pluginCall}],`)
}

async function getPackageChanges(cwd, dependencies) {
  const devDependencies = { ...SETUP_DEV_DEPENDENCIES }
  if (await findViteConfigPath(cwd)) {
    Object.assign(devDependencies, VITE_DEV_DEPENDENCIES)
  }

  return {
    dependencies,
    devDependencies,
  }
}

async function updatePackageJson(cwd, changes, options) {
  const packagePath = path.join(cwd, "package.json")
  const dependencies = changes.dependencies || {}
  const devDependencies = changes.devDependencies || {}
  const packageJson = (await readJsonIfExists(packagePath)) || {
    name: path.basename(cwd).toLowerCase(),
    version: "0.0.0",
    private: true,
  }
  const added = [
    ...addPackageSection(packageJson, "dependencies", dependencies),
    ...addPackageSection(packageJson, "devDependencies", devDependencies),
  ]

  if (added.length === 0 && (await exists(packagePath))) {
    return [`kept ${relative(cwd, packagePath)}`]
  }

  const status = await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, {
    dryRun: options.dryRun,
    force: true,
  })

  return [
    `${status} ${relative(cwd, packagePath)}`,
  ]
}

function addPackageSection(packageJson, section, dependencies) {
  const added = []
  const existing = packageJson[section] || {}

  for (const [name, version] of Object.entries(dependencies)) {
    if (hasPackageDependency(packageJson, name)) continue

    existing[name] = version
    added.push(name)
  }

  if (added.length > 0 || packageJson[section]) {
    packageJson[section] = sortObject(existing)
  }

  return added
}

function hasPackageDependency(packageJson, name) {
  return Boolean(
    packageJson.dependencies?.[name] ||
    packageJson.devDependencies?.[name] ||
    packageJson.peerDependencies?.[name] ||
    packageJson.optionalDependencies?.[name]
  )
}

function applyTemplate(source, config) {
  return source
    .replaceAll("@/lib/utils", config.aliases.utils)
    .replaceAll("@/hooks", config.aliases.hooks)
}

function isTextRegistryFile(source) {
  return !/\.(avif|eot|gif|ico|jpe?g|otf|png|ttf|webp|woff2?)$/i.test(source)
}

function resolveTarget(cwd, config, target) {
  const replacements = {
    "{{ui}}": aliasToPath(cwd, config.aliases.ui),
    "{{utils}}": stripExtension(aliasToPath(cwd, config.aliases.utils)),
    "{{hooks}}": aliasToPath(cwd, config.aliases.hooks),
  }

  let resolved = target
  for (const [placeholder, value] of Object.entries(replacements)) {
    resolved = resolved.replaceAll(placeholder, value)
  }

  return resolved
}

function aliasToPath(cwd, alias) {
  if (!alias) return ""
  if (path.isAbsolute(alias)) return alias
  if (alias.startsWith("./")) return alias.slice(2)
  if (alias.startsWith("../")) return alias

  if (alias.startsWith("@/") || alias.startsWith("~/")) {
    const rest = alias.slice(2)
    return path.join(shouldUseSrcDirectory(cwd) ? "src" : "", rest)
  }

  return alias
}

function shouldUseSrcDirectory(cwd) {
  return pathExistsSyncHint(cwd, "src/app") || pathExistsSyncHint(cwd, "src")
}

function pathExistsSyncHint(cwd, segment) {
  return Boolean(syncExistsCache.get(path.join(cwd, segment)))
}

async function warmPathHints(cwd) {
  for (const segment of ["src", "src/app"]) {
    const target = path.join(cwd, segment)
    syncExistsCache.set(target, await exists(target))
  }
}

async function writeFile(targetPath, content, options) {
  const alreadyExists = await exists(targetPath)
  const isBinary = Buffer.isBuffer(content)

  if (options.dryRun) {
    if (!alreadyExists) return "would write"

    const current = await fs.readFile(targetPath)
    const isEqual = isBinary
      ? current.equals(content)
      : current.toString("utf8") === content
    if (isEqual) return "would keep"

    return "would update"
  }

  if (alreadyExists && !options.force) {
    const current = await fs.readFile(targetPath)
    const isEqual = isBinary
      ? current.equals(content)
      : current.toString("utf8") === content
    if (isEqual) return "kept"
  }

  await fs.mkdir(path.dirname(targetPath), { recursive: true })
  await fs.writeFile(targetPath, content, isBinary ? undefined : "utf8")
  return alreadyExists ? "updated" : "wrote"
}

async function readJson(filePath) {
  return JSON.parse(await fs.readFile(filePath, "utf8"))
}

async function readJsonc(filePath) {
  const content = await fs.readFile(filePath, "utf8")
  return JSON.parse(stripJsonComments(content))
}

function stripJsonComments(content) {
  return content
    .replace(/\/\*[\s\S]*?\*\//g, "")
    .replace(/(^|[^:])\/\/.*$/gm, "$1")
}

async function readJsonIfExists(filePath) {
  try {
    return await readJson(filePath)
  } catch (error) {
    if (error.code === "ENOENT") return null
    throw error
  }
}

async function readTextIfExists(filePath) {
  try {
    return await fs.readFile(filePath, "utf8")
  } catch (error) {
    if (error.code === "ENOENT") return null
    throw error
  }
}

async function exists(filePath) {
  try {
    await fs.access(filePath)
    return true
  } catch {
    return false
  }
}

function removeExistingTokenBlock(content) {
  const start = content.indexOf(CSS_START)
  if (start === -1) return content

  const end = content.indexOf(CSS_END, start)
  if (end === -1) return content

  return `${content.slice(0, start)}${content.slice(end + CSS_END.length)}`
}

function ensureGlobalCssImports(content) {
  const fontImportPattern =
    /^\s*@import\s+["']@fontsource(?:-variable)?\/inter(?:\/(?:400|500|600|700)\.css)?["'];\s*/gm
  const tailwindImportPattern = /^\s*@import\s+["']tailwindcss["'];\s*/gm
  const cleaned = content
    .replace(fontImportPattern, "")
    .replace(tailwindImportPattern, "")
    .trimStart()
  return `${TAILWIND_IMPORT}\n${FONT_IMPORTS.join("\n")}\n${cleaned}`
}

function appendAliasSegment(alias, segment) {
  if (!alias) return null
  return `${alias.replace(/\/$/, "")}/${segment}`
}

function stripExtension(filePath) {
  return filePath.replace(/\.(t|j)sx?$/, "")
}

function sortObject(value) {
  return Object.fromEntries(
    Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
  )
}

function relative(cwd, targetPath) {
  return path.relative(cwd, targetPath).replaceAll("\\", "/")
}

function printResult(title, writes, dryRun) {
  console.log(`${dryRun ? "Dry run: " : ""}${title}`)
  for (const write of writes) {
    console.log(`- ${write}`)
  }
}

function printInstallHint(writes, dryRun) {
  if (dryRun) return

  const packageJsonChanged = writes.some((write) =>
    /^(wrote|updated) package\.json$/.test(write)
  )

  if (packageJsonChanged) {
    console.log("\nNext: run npm install to install the added dependencies.")
  }
}
