/*
 * The `assetIcon('pack:name')` compile macro — scan/parse/rewrite core, shared by every compiler
 * (CLI design + app compiles, backend design board + platform app compile). At compile time each
 * call is resolved to an SVG body and rewritten to an inline `SvgSource(\`<svg>\`)` — the same image
 * source `SvgSource(...)` returns and that a `.svg` asset compiles to — so icons render offline and
 * deterministically on every host with no runtime support and no network at render time.
 *
 *   UIImage(assetIcon('lucide:bell', { color: colors.accent }))                  // recolor via a token
 *   UIImage(assetIcon('lucide:bell', { color: '#5b8cff' }))                      // bake a hex literal
 *   UIImage(assetIcon('lucide:bell')).style({ width: 24, tintColor: '#5b8cff' }) // or the style prop
 *
 * The SVG uses `currentColor`. The `{ color }` option recolors it: a string LITERAL is baked into the
 * SVG at compile time (currentColor → the hex), a token / any other expression is lowered to the
 * SvgSource's source-level `tintColor` (a mask recolor the renderers honor). The `id` must be a
 * string literal.
 *
 * WHERE the SVG bodies come from is the caller's business, passed in as a resolver:
 *   - CLI design compile: vendored into the design folder from the icon registry (lock + notices);
 *   - CLI app compiles: the user-level cache (~/.lecodes/cache/icons) + registry on miss;
 *   - backend app compile + design board: vendored `assets/icons/**` text assets first, then a
 *     server-side cache + registry on miss.
 * App compiles never write icons into the project — attribution for platform-deployed apps rides
 * the `// icons:` bundle header (buildIconsHeaderValue) and the platform, not an in-repo notice.
 */

/** `pack:name`, each part lowercase `[a-z0-9-]` (matches the registry's ids). */
export const ICON_ID = /^([a-z0-9]+(?:-[a-z0-9]+)*):([a-z0-9]+(?:-[a-z0-9]+)*)$/

export type IconCall = {
  /** Index of `assetIcon` in the source. */
  start: number
  /** Index just past the call's closing `)`. */
  end: number
  /** The literal id (`"lucide:bell"`), or "" when the id wasn't a literal. */
  id: string
  pack: string
  name: string
  /** A literal color from `{ color: "…" }` to bake into the SVG at compile time (currentColor → color). */
  bake?: string
  /** A non-literal `{ color: expr }` (e.g. a token) — inlined verbatim as the SvgSource's source-level
   *  tintColor, which the renderer masks the SVG with. Mutually exclusive with `bake`. */
  tint?: string
  /** Set when the call is malformed (non-literal id, bad id shape, bad opts) — a hard error. */
  error?: string
}

// ---- lexing (comment/string/template aware, dependency-free) ------------------------------------

const isIdentStart = (c: string) => (c >= "A" && c <= "Z") || (c >= "a" && c <= "z") || c === "_" || c === "$"
const isIdentPart = (c: string) => isIdentStart(c) || (c >= "0" && c <= "9")

/** Advance past a string/template literal that opens at `start`; returns the index just after the
 *  closing quote. Template `${…}` interpolations (and strings nested in them) are skipped. */
const skipString = (src: string, start: number): number => {
  const quote = src[start]
  const n = src.length
  let i = start + 1
  while (i < n) {
    const c = src[i]
    if (c === "\\") { i += 2; continue }
    if (quote === "`" && c === "$" && src[i + 1] === "{") { i = skipBraces(src, i + 1); continue }
    if (c === quote) return i + 1
    i++
  }
  return n
}

/** From the index of a `{`, return the index just after its matching `}` (string-aware). */
const skipBraces = (src: string, open: number): number => {
  const n = src.length
  let depth = 0
  let i = open
  while (i < n) {
    const c = src[i]
    if (c === '"' || c === "'" || c === "`") { i = skipString(src, i); continue }
    if (c === "{") depth++
    else if (c === "}") { depth--; if (depth === 0) return i + 1 }
    i++
  }
  return n
}

/** Index of the `)` matching the `(` at `open`, or -1. Skips strings and comments. */
const matchParen = (src: string, open: number): number => {
  const n = src.length
  let depth = 0
  let i = open
  while (i < n) {
    const c = src[i]
    if (c === '"' || c === "'" || c === "`") { i = skipString(src, i); continue }
    if (c === "/" && src[i + 1] === "/") { i += 2; while (i < n && src[i] !== "\n") i++; continue }
    if (c === "/" && src[i + 1] === "*") { i += 2; while (i < n && !(src[i] === "*" && src[i + 1] === "/")) i++; i += 2; continue }
    if (c === "(") depth++
    else if (c === ")") { depth--; if (depth === 0) return i }
    i++
  }
  return -1
}

/** Split on a top-level (depth-0, outside strings) single-char delimiter. */
const splitTopLevel = (text: string, delim: string): string[] => {
  const parts: string[] = []
  const n = text.length
  let depth = 0
  let last = 0
  let i = 0
  while (i < n) {
    const c = text[i]
    if (c === '"' || c === "'" || c === "`") { i = skipString(text, i); continue }
    if (c === "(" || c === "[" || c === "{") depth++
    else if (c === ")" || c === "]" || c === "}") depth = Math.max(0, depth - 1)
    else if (depth === 0 && c === delim) { parts.push(text.slice(last, i)); last = i + 1 }
    i++
  }
  parts.push(text.slice(last))
  return parts
}

/** The value of a whole-string literal (`"x"`, `'x'`, plain `` `x` ``), or null if `text` isn't one. */
const stringLiteralValue = (text: string): string | null => {
  const t = text.trim()
  if (t.length < 2) return null
  const q = t[0]
  if ((q !== '"' && q !== "'" && q !== "`") || t[t.length - 1] !== q) return null
  const inner = t.slice(1, -1)
  if (q === "`" && inner.includes("${")) return null // interpolated → not static
  // A same-kind unescaped quote before the end means it's two literals, not one.
  if (inner.includes(q) && !inner.includes("\\" + q)) return null
  return inner.replace(/\\(.)/g, "$1")
}

// ---- scanning -----------------------------------------------------------------------------------

/** Find every `assetIcon(...)` call in a source file. Pure and offline; positions are into `source`.
 *  A call with a non-literal id / bad id / non-literal color is returned with `.error` set. */
export const scanAssetIconCalls = (source: string): IconCall[] => {
  const calls: IconCall[] = []
  const n = source.length
  let i = 0
  while (i < n) {
    const c = source[i]
    if (c === '"' || c === "'" || c === "`") { i = skipString(source, i); continue }
    if (c === "/" && source[i + 1] === "/") { i += 2; while (i < n && source[i] !== "\n") i++; continue }
    if (c === "/" && source[i + 1] === "*") { i += 2; while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++; i += 2; continue }
    if (isIdentStart(c)) {
      let j = i + 1
      while (j < n && isIdentPart(source[j])) j++
      if (source.slice(i, j) === "assetIcon" && prevNonSpace(source, i) !== ".") {
        const call = parseCall(source, i, j)
        if (call) { calls.push(call); i = call.end; continue }
      }
      i = j
      continue
    }
    i++
  }
  return calls
}

/** The last non-whitespace char before `i` (to reject `obj.assetIcon`), or "". */
const prevNonSpace = (src: string, i: number): string => {
  let k = i - 1
  while (k >= 0 && /\s/.test(src[k])) k--
  return k >= 0 ? src[k] : ""
}

/** Parse an `assetIcon` call whose identifier spans [start, identEnd). Returns null if it isn't a
 *  call (no `(`), else an IconCall (with `.error` set for a malformed one). */
const parseCall = (src: string, start: number, identEnd: number): IconCall | null => {
  const n = src.length
  let i = identEnd
  while (i < n && /\s/.test(src[i])) i++
  if (src[i] !== "(") return null
  const close = matchParen(src, i)
  if (close === -1) return null

  const call: IconCall = { start, end: close + 1, id: "", pack: "", name: "" }
  const args = splitTopLevel(src.slice(i + 1, close), ",")
  const id = stringLiteralValue(args[0] ?? "")
  if (id === null) {
    call.error = `assetIcon(...) needs a string-literal id like "lucide:bell" (got \`${(args[0] ?? "").trim() || "()"}\`).`
    return call
  }
  const m = ICON_ID.exec(id)
  if (!m) {
    call.error = `assetIcon("${id}"): id must be "pack:name", lowercase [a-z0-9-] (e.g. "lucide:bell").`
    return call
  }
  call.id = id
  call.pack = m[1]
  call.name = m[2]

  const optsText = args[1]?.trim()
  if (optsText) {
    const color = extractColor(optsText)
    if (color.error) { call.error = color.error; return call }
    if (color.bake !== undefined) call.bake = color.bake
    else if (color.tint !== undefined) call.tint = color.tint
  }
  return call
}

/** Pull `color` out of an `{ color: … }` opts literal. A string literal is baked (currentColor → it);
 *  any other expression (a token, a call) is kept verbatim to apply as a runtime tint. A missing color
 *  or unknown keys are fine; a non-object `opts` is an error. */
const extractColor = (optsText: string): { bake?: string, tint?: string, error?: string } => {
  if (!optsText.startsWith("{") || !optsText.endsWith("}")) {
    return { error: `assetIcon(id, opts): opts must be an object literal like { color: "#5b8cff" }.` }
  }
  for (const prop of splitTopLevel(optsText.slice(1, -1), ",")) {
    const kv = splitTopLevel(prop, ":")
    if (kv.length < 2) continue
    const key = kv[0].trim().replace(/^["'`]|["'`]$/g, "")
    if (key !== "color") continue
    const raw = kv.slice(1).join(":").trim()
    if (!raw) continue
    const literal = stringLiteralValue(raw)
    return literal !== null ? { bake: literal } : { tint: raw }
  }
  return {}
}

// ---- rewriting ----------------------------------------------------------------------------------

/** Rewrite each `assetIcon(...)` call in `source` to an inline SvgSource — baking a literal color into
 *  the SVG, or attaching a non-literal color as the source-level tint. Calls not in `svgById` (resolved
 *  elsewhere) are left untouched. */
export const rewriteAssetIcons = (source: string, calls: IconCall[], svgById: Map<string, string>): string => {
  let out = source
  // Apply back-to-front so earlier indices stay valid.
  for (const call of [...calls].sort((a, b) => b.start - a.start)) {
    const svg = svgById.get(call.id)
    if (svg === undefined) continue
    out = out.slice(0, call.start) + iconExpr(svg, call) + out.slice(call.end)
  }
  return out
}

/** The inline replacement expression for one resolved call. */
const iconExpr = (svg: string, call: IconCall): string => {
  const body = call.bake ? svg.split("currentColor").join(call.bake) : svg
  const src = `SvgSource(\`${escapeTemplate(body)}\`)`
  // A non-literal color rides the source-level tint channel (the renderer masks the SVG with it).
  return call.tint ? `Object.assign(${src}, { tintColor: (${call.tint}) })` : src
}

const escapeTemplate = (s: string) => s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")

// ---- the macro pass -----------------------------------------------------------------------------

export type AssetIconEntry = { path: string, type: string, text?: string }

/** id[] → id → SVG body. A resolver may throw its own contextual error for an unresolvable id
 *  (unknown pack, missing icon, not vendored); any id it silently omits fails the pass generically. */
export type AssetIconResolver = (ids: string[]) => Promise<Map<string, string>> | Map<string, string>

/**
 * Resolve + rewrite every `assetIcon(...)` across the compile's text entries (mutates
 * `entries[].text`). Returns the resolved ids — `[]` when nothing uses `assetIcon` (the resolver is
 * not called). Throws (aborting the compile) on a malformed call or an unresolvable icon.
 */
export const applyAssetIconMacro = async (
  entries: AssetIconEntry[],
  resolveSvgs: AssetIconResolver,
): Promise<string[]> => {
  const scanned = entries
    .filter((e) => e.type === "text" && typeof e.text === "string" && e.text.includes("assetIcon"))
    .map((e) => ({ entry: e, calls: scanAssetIconCalls(e.text!) }))
    .filter((s) => s.calls.length > 0)
  if (scanned.length === 0) return []

  const errors = scanned.flatMap((s) => s.calls.filter((c) => c.error).map((c) => c.error!))
  if (errors.length) throw new Error([...new Set(errors)].join("\n"))

  const ids = [...new Set(scanned.flatMap((s) => s.calls.map((c) => c.id)))]
  const svgById = await resolveSvgs(ids)

  const missing = ids.filter((id) => !svgById.has(id))
  if (missing.length) throw new Error(`assetIcon: could not resolve icon(s): ${missing.join(", ")}.`)

  for (const { entry, calls } of scanned) entry.text = rewriteAssetIcons(entry.text!, calls, svgById)
  return ids
}

/** The `// icons:` header payload for a compile that resolved `ids`: the distinct packs, sorted —
 *  the bundle's self-describing record of which icon sets it redistributes (licenses live in the
 *  registry's licenses.json; the platform joins the two for attribution UI). "" when no icons. */
export const buildIconsHeaderValue = (ids: string[]): string =>
  [...new Set(ids.map((id) => id.split(":")[0]))].sort().join(";")

// ---- "did you mean" (for icon-not-found errors) ---------------------------------------------------

/** Levenshtein with an early-exit cap: returns cap+1 as soon as the distance exceeds `cap`. */
const editDistance = (a: string, b: string, cap: number): number => {
  if (Math.abs(a.length - b.length) > cap) return cap + 1
  let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
  for (let i = 1; i <= a.length; i++) {
    const cur = [i]
    let rowMin = i
    for (let j = 1; j <= b.length; j++) {
      cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
      if (cur[j] < rowMin) rowMin = cur[j]
    }
    if (rowMin > cap) return cap + 1
    prev = cur
  }
  return prev[b.length]
}

/** The closest icon names to `want` in one pack's meta (`icons: { name: { kw } }`), best-first,
 *  max 5. Ranks: substring overlap (typing "bell" → "bell-ring"), then small edit distance (typos:
 *  "bel" → "bell"), then a shared keyword (a dash-part of `want` appearing in an icon's kw list). */
export const suggestIconNames = (want: string, icons: Record<string, { kw?: string[] }>): string[] => {
  const wantParts = new Set(want.split("-").filter(Boolean))
  const scored: { name: string, rank: number }[] = []
  for (const [name, info] of Object.entries(icons)) {
    const lenDiff = Math.abs(name.length - want.length) / 100
    let rank: number
    if (name.includes(want) || want.includes(name)) rank = lenDiff
    else {
      const d = editDistance(want, name, 2)
      if (d <= 2) rank = 1 + d
      else if (info.kw?.some((k) => wantParts.has(k))) rank = 4 + lenDiff
      else continue
    }
    scored.push({ name, rank })
  }
  return scored.sort((a, b) => a.rank - b.rank || (a.name < b.name ? -1 : 1)).slice(0, 5).map((s) => s.name)
}

/** Reads one registry-relative text file (`lucide/meta.json`), or null when absent/unreachable. */
export type RegistryReader = (rel: string) => Promise<string | null>

/** Build the legible error for an icon the registry didn't have: fetches the pack's meta.json for
 *  "did you mean" suggestions; a pack with no meta falls back to licenses.json for the available-pack
 *  list; nothing reachable at all reads as a connectivity problem. Environment-free — callers pass
 *  their registry reader. */
export const iconNotFoundError = async (
  pack: string,
  name: string,
  read: RegistryReader,
  registry?: string,
): Promise<Error> => {
  const metaRaw = await read(`${pack}/meta.json`)
  if (metaRaw !== null) {
    let icons: Record<string, { kw?: string[] }> = {}
    try { icons = (JSON.parse(metaRaw) as { icons?: typeof icons }).icons ?? {} } catch { /* corrupt meta → no suggestions */ }
    const similar = suggestIconNames(name, icons)
    const hint = similar.length
      ? ` Did you mean ${similar.map((s) => `"${pack}:${s}"`).join(", ")}?`
      : ` No similar name in the "${pack}" pack either.`
    return new Error(`assetIcon: icon "${pack}:${name}" not found.${hint}`)
  }
  const licRaw = await read("licenses.json")
  if (licRaw !== null) {
    try {
      const packs = Object.keys(JSON.parse(licRaw) as Record<string, unknown>).sort()
      return new Error(`assetIcon: unknown icon pack "${pack}" (in "${pack}:${name}"). Available packs: ${packs.join(", ")}.`)
    } catch { /* corrupt licenses → fall through to the connectivity error */ }
  }
  // `transient` lets callers that cache compile results skip caching this one: unlike a missing
  // icon, an unreachable registry is not a property of the source being compiled.
  return Object.assign(new Error(
    `assetIcon: can't reach the icon registry${registry ? ` (${registry})` : ""} to resolve "${pack}:${name}". `
    + `Check your connection, or set LECODES_ICON_REGISTRY.`,
  ), { transient: true })
}

/** `…assets/icons/<pack>/<name>.svg` — an icon the design flow vendored into the project tree. */
const VENDORED_ICON_PATH = /(?:^|\/)assets\/icons\/([a-z0-9]+(?:-[a-z0-9]+)*)\/([a-z0-9]+(?:-[a-z0-9]+)*)\.svg$/

/** id → SVG body for every icon vendored in the compile's own tree (`assets/icons/<pack>/<name>.svg`
 *  text entries — the design flow vendors those). App-compile resolvers check this FIRST, so a
 *  pushed/pulled project with a design folder resolves its icons offline, exactly like the design
 *  pipeline, and only genuinely new ids go to the cache/registry. */
export const collectVendoredIcons = (entries: AssetIconEntry[]): Map<string, string> => {
  const map = new Map<string, string>()
  for (const e of entries) {
    if (e.type !== "text" || typeof e.text !== "string") continue
    const m = VENDORED_ICON_PATH.exec(e.path)
    if (m) map.set(`${m[1]}:${m[2]}`, e.text)
  }
  return map
}
