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 { hasAspectExtends, injectAspectNames } from "./aspectMacro"

/*
 * 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`.

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.")
}

const bundleProjectChisel = async (
  entrypoint: string,
  files: MemoryFiles,
  format: "esm" | "iife",
  sourcemap: boolean,
): Promise<{ code: string, map: string | null }> => {
  const entry = normalize(entrypoint)
  // Per-file source transforms (project files only — SDK aspects declare their own `static aspect`):
  //  - 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 (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}`)

  const sdkFiles = collectSdkFiles(resolveSdkSrc())
  const minify = process.env.LECODES_CHISEL_MINIFY !== "0" // shipped bundles minify by default
  const input = JSON.stringify({
    files: { ...sdkFiles, ...projFiles },
    entry,
    inject: [ "/__sdk/inject.ts" ],
    format,
    minify,
    fuse: process.env.LECODES_CHISEL_FUSE === "1",
    define: CHISEL_DEFINE,
    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",
  })

  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 }
  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")
  return { code: parsed.code, map: parsed.map ?? null }
}

/**
 * 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. The map's `sources` are the original
 *  module paths and its generated positions are relative to `code` (line 1 = first line of `code`). */
export const bundleProjectWithMap = async (
  entrypoint: string,
  files: MemoryFiles,
  format: "esm" | "iife" = "esm",
): Promise<{ code: string, map: string | null }> => bundleProjectChisel(entrypoint, files, format, true)
