import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
import { spawnSync } from "node:child_process"
import { createRequire } from "node:module"
import { dirname, join, relative } from "node:path"
import { fileURLToPath } from "node:url"
import { desugarAssetMacros, hasAssetMacro } from "./assetMacro"
import type { ScanModule } from "./detectEntry"
import { hasAspectExtends, injectAspectNames } from "./aspectMacro"
import { hasImportStatement, rewriteLibraryImports } from "./libraryImports"

/*
 * The user-project bundler. Project files live in an in-memory map; the whole SDK is delivered via
 * chisel's `inject` so user code uses globals (Scene, Sprite, toast, …) with NO imports. chisel — the
 * Rust method-granular bundler (@letary/chisel) — tree-shakes the injected SDK down to what the
 * project references, including unused class *methods*. It is the only bundler (the esbuild fallback
 * is gone). Bare imports are a hard error (chisel's resolver enforces it).
 */

export type MemoryFiles = Record<string, string>

/** Locate the SDK inject entry: an explicit override (LECODES_SDK_INJECT — used by the published CLI,
 *  which vendors the SDK source), else the source in-repo. chisel needs the SOURCE tree (not a bundle). */
const resolveInject = (): string => {
  const override = process.env.LECODES_SDK_INJECT
  if (override && existsSync(override)) return override
  const here = dirname(fileURLToPath(import.meta.url)) // …/sdk/src/compile — portable (Node + Bun)
  const src = join(here, "..", "inject.ts")
  if (existsSync(src)) return src
  throw new Error("sdk: could not locate the inject source (LECODES_SDK_INJECT / src/inject.ts)")
}

const normalize = (path: string): string => {
  const parts: string[] = []
  for (const part of path.split("/")) {
    if (!part || part === ".") continue
    if (part === "..") parts.pop()
    else parts.push(part)
  }
  return "/" + parts.join("/")
}

// ---- chisel (Rust) bundler -------------------------------------------------------------------
//
// chisel is fed an in-memory file map over JSON-stdio: the SDK source under `/__sdk/…` (injected via
// `/__sdk/inject.ts`), the project files at their own paths, and a `define` map. It does
// method-granular tree-shaking + (when minify is on) compress + mangle. Assets/shaders/svg reach it
// as `asset()` macros desugared to default imports; aspect names are injected as `static aspect`.

// `EDITOR` is a boolean define: `false` (const-folded, `if (EDITOR)` branches DCE'd away) in
// every shipped bundle, `true` only in scene-editor compiles (CompileOptions.editor). Requires a
// chisel with boolean-define support — an older binary ignores the entry and leaves `EDITOR` as a
// free identifier (a runtime ReferenceError only in code that actually uses it).
const CHISEL_DEFINE: Record<string, string> = { RAD2DEG: "57.29577951308232", DEG2RAD: "0.017453292519943295" }

// Methods the native host invokes by name have no in-bundle caller, so method-DCE can't see them.
// The SDK prefixes its framework / host-facing methods with `_` (`_emitTouchStart`, `_emitClick`,
// `_onAnimationComplete`, `_emitAnim`, …), so keep every underscore-prefixed method on classes that survive.
const CHISEL_KEEP = [ "_*" ]

/** The SDK source root (parent of `inject.ts`). chisel needs the source tree, not a prebuilt bundle. */
const resolveSdkSrc = (): string => {
  const inject = resolveInject()
  if (!inject.endsWith("inject.ts")) {
    throw new Error(`chisel needs the SDK source tree (src/inject.ts); got ${inject}. Set LECODES_SDK_INJECT to the source.`)
  }
  return dirname(inject)
}

const isSdkSource = (path: string) =>
  /\.[tj]s$/.test(path) && !path.endsWith(".d.ts") &&
  !path.includes("/compile/") && !path.includes("/tests/") && !path.includes("/__tests__/")

// The SDK tree doesn't change within a process — read it once.
const sdkFilesCache = new Map<string, MemoryFiles>()
const collectSdkFiles = (sdkSrc: string): MemoryFiles => {
  const cached = sdkFilesCache.get(sdkSrc)
  if (cached) return cached
  const out: MemoryFiles = {}
  const walk = (dir: string) => {
    for (const name of readdirSync(dir)) {
      const p = join(dir, name)
      if (statSync(p).isDirectory()) walk(p)
      else if (isSdkSource(p)) out["/__sdk/" + relative(sdkSrc, p).split("\\").join("/")] = readFileSync(p, "utf8")
    }
  }
  walk(sdkSrc)
  sdkFilesCache.set(sdkSrc, out)
  return out
}

/** Locate the chisel binary: an explicit override (LECODES_CHISEL_BIN), else the prebuilt binary from
 *  the @letary/chisel npm package (which resolves the platform via optional @letary/chisel-<platform>). */
const resolveChiselBin = (): string => {
  const override = process.env.LECODES_CHISEL_BIN
  if (override) {
    if (existsSync(override)) return override
    throw new Error(`LECODES_CHISEL_BIN points at a missing file: ${override}`)
  }
  try {
    const bin = createRequire(import.meta.url)("@letary/chisel").binaryPath() as string | null
    if (bin && existsSync(bin)) return bin
  } catch { /* @letary/chisel (or its platform binary) not installed */ }
  throw new Error("chisel binary not found. Install @letary/chisel, or set LECODES_CHISEL_BIN.")
}

/** Parse every module in `files` via chisel scan (no bundling): resolved local imports +
 *  hasExports per file. Null when the binary is missing or predates scan mode (it then rejects
 *  the entry-less input) — detectEntry then falls back to its regex scanner. Passed into
 *  `detectEntry` as its `scanner` by every node caller (detectEntry itself stays browser-safe). */
export const scanModulesChisel = (files: MemoryFiles): Record<string, ScanModule> | null => {
  let bin: string
  try {
    bin = resolveChiselBin()
  } catch {
    return null
  }
  const input = JSON.stringify({ scan: true, files })
  const r = spawnSync(bin, [], { input, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 })
  if (r.status !== 0 || r.error) return null
  try {
    const parsed = JSON.parse(r.stdout || "{}") as { scan?: Record<string, ScanModule> }
    return parsed.scan ?? null
  } catch {
    return null
  }
}

const bundleProjectChisel = async (
  entrypoint: string,
  files: MemoryFiles,
  format: "esm" | "iife",
  sourcemap: boolean,
  minifyOverride?: boolean,
  editor?: boolean,
  libraries?: Record<string, MemoryFiles>,
): Promise<{ code: string, map: string | null, diagnostics: string[] }> => {
  const entry = normalize(entrypoint)
  const slugs = new Set(Object.keys(libraries ?? {}))
  // Per-file source transforms (project files only — SDK aspects declare their own `static aspect`):
  //  - rewrite bare library imports (`from "acme-ui"`) → the injected `/__lib/<slug>/…` tree
  //  - desugar `asset('./x')` → a default import that resolves to the synthetic asset module
  //  - inject `static aspect = '<name>'` from `class … extends Aspect<'<name>', …>`
  const projFiles: MemoryFiles = {}
  for (const [ k, v ] of Object.entries(files)) {
    const path = normalize(k)
    let src = v
    if (/\.[tj]sx?$/.test(path)) {
      if (hasImportStatement(src)) src = rewriteLibraryImports(src, slugs)
      if (hasAssetMacro(src)) src = desugarAssetMacros(src, path)
      if (hasAspectExtends(src)) src = injectAspectNames(src)
    }
    projFiles[path] = src
  }
  if (!projFiles[entry]) throw new Error(`Entrypoint not found: ${entry}`)

  // Library dependencies: merge each library's files under /__lib/<slug>/… (like /__sdk/…). They
  // get the same asset/aspect desugaring as project code, but NOT the bare-import rewrite — a
  // library can't declare dependencies (flat graph v1), so any bare import in a library is an
  // error chisel surfaces. Library-internal relative imports resolve within the prefix untouched.
  for (const [ slug, libFiles ] of Object.entries(libraries ?? {})) {
    for (const [ k, v ] of Object.entries(libFiles)) {
      const path = normalize(`/__lib/${slug}/${k}`)
      let src = v
      if (/\.[tj]sx?$/.test(path)) {
        if (hasAssetMacro(src)) src = desugarAssetMacros(src, path)
        if (hasAspectExtends(src)) src = injectAspectNames(src)
      }
      projFiles[path] = src
    }
  }

  const sdkFiles = collectSdkFiles(resolveSdkSrc())
  // Shipped bundles minify by default; edit-mode bundles pass `false` — mangling erases class
  // names, which the scene editor matches aspect schemas by (`describeAspect().className`).
  const minify = minifyOverride ?? (process.env.LECODES_CHISEL_MINIFY !== "0")
  const input = JSON.stringify({
    files: { ...sdkFiles, ...projFiles },
    entry,
    inject: [ "/__sdk/inject.ts" ],
    format,
    minify,
    // Chain fusion, ON by default (`LECODES_CHISEL_FUSE=0` opts out): Vec3/date math chains lower to
    // scalar arithmetic (bit-exact — JS numbers are f64 and the lowering never reassociates) and
    // particle `curve()`/`colorCurve()` chains constant-fold into the exact native Float32Array.
    fuse: process.env.LECODES_CHISEL_FUSE !== "0",
    define: { ...CHISEL_DEFINE, EDITOR: editor ? "true" : "false" },
    keep: CHISEL_KEEP,
    sourcemap,
    // Reactive-UI desugaring (memoized children .map → __uiMap + auto-wrapped signal reads in
    // UIText/style args). Only fires on free references to the injected globals, i.e. user code.
    // An older chisel binary ignores the unknown field — bundles still work, children just
    // rebuild per change instead of reusing nodes.
    reactive_ui: process.env.LECODES_CHISEL_REACTIVE_UI !== "0",
    // Flatten literal array args of UI factory calls into variadic args (`UIColumn([a, b])` →
    // `UIColumn(a, b)`) — the SDK's buildUI dispatch flattens array args one level anyway, so
    // this only drops an array allocation per call site. Safe to always request: the sibling SDK
    // source above has the variadic dispatch, and an older binary ignores the unknown field.
    flatten_ui: process.env.LECODES_CHISEL_FLATTEN_UI !== "0",
  })

  const bin = resolveChiselBin()
  const r = spawnSync(bin, [], { input, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 })
  if (r.status === 2 || r.error) {
    throw new Error(`chisel failed: ${r.error?.message ?? r.stderr ?? "unknown error"}`)
  }
  let parsed: { code?: string, map?: string, error?: string, diagnostics?: string[] }
  try {
    parsed = JSON.parse(r.stdout || "{}")
  } catch {
    throw new Error(`chisel produced invalid output: ${(r.stdout || r.stderr || "").slice(0, 200)}`)
  }
  if (parsed.error) throw new Error(`chisel: ${parsed.error}`)
  if (parsed.code == null) throw new Error("chisel produced no code")
  // Non-fatal build-time findings (`path:line:col: message`) — today: particle curve chains the
  // engine would reject at set time. Warn so they're visible in the CLI / server log; the bundle
  // itself is unaffected (chisel leaves an invalid chain as real calls).
  const diagnostics = parsed.diagnostics ?? []
  for (const d of diagnostics) console.warn(`warning: ${d}`)
  return { code: parsed.code, map: parsed.map ?? null, diagnostics }
}

/**
 * Bundle the project `files` (path → source) starting at `entrypoint`, with the SDK injected.
 * `format` is "esm" by default (what the platform ships); "iife" produces a self-running script.
 */
export const bundleProject = async (
  entrypoint: string,
  files: MemoryFiles,
  format: "esm" | "iife" = "esm",
): Promise<string> => (await bundleProjectChisel(entrypoint, files, format, false)).code

/** Like `bundleProject`, but also returns chisel's source map (and any build-time `diagnostics`).
 *  The map's `sources` are the original module paths and its generated positions are relative to
 *  `code` (line 1 = first line of `code`). Pass `sourcemap: false` to skip map generation entirely —
 *  `map` then comes back null. */
export const bundleProjectWithMap = async (
  entrypoint: string,
  files: MemoryFiles,
  format: "esm" | "iife" = "esm",
  minify?: boolean,
  editor?: boolean,
  libraries?: Record<string, MemoryFiles>,
  sourcemap = true,
): Promise<{ code: string, map: string | null, diagnostics: string[] }> => bundleProjectChisel(entrypoint, files, format, sourcemap, minify, editor, libraries)
