// `font(...)` — the compile-time font macro (docs/font-system-plan.md). Using a font IS installing
// it: every call compiles to the family-name string literal's identifier, and the faces it implies
// are registered by a generated sibling module the file imports (ESM imports hoist, so registration
// runs before the file's own body — no app-side gating, ever).
//
//   theme({ fontFamily: font("manrope") })        // registry family → "Manrope"
//   font("ibm-plex-sans", { weights: [400, 600] })// narrow/extend the face set
//   font("./fonts/Brand.ttf")                     // project file → real family from its name table
//
// Registry ids resolve against the vendored snapshot (fontRegistry.ts). Two source modes:
//   cdn    — faces stay remote: the generated code bakes registry URLs, picking woff2/ttf via the
//            host's `_creatorUI.fontFormat` (absent → ttf). Platform/server-backed compiles.
//   vendor — the caller downloads faces into the project (assets/fonts/ + fonts.lock.json) and
//            hands back their entry paths; faces then ride the normal asset pipeline (dev URLs,
//            `id:` + fetchLocal, lecodesfile://) — fully offline, and local faces register
//            synchronously at boot (zero swap).
//
// Project-file fonts (path-shaped arg) are entries already; the file's family/weight/italic are
// read from the font itself (sfnt.ts). Registration is idempotent per (family, weight, style) —
// hosts dedupe — so several files declaring the same font cost nothing.
//
// A file that DECLARES its own `font` identifier (function/const/import) is skipped entirely —
// real scoping wins, the macro only exists where the global would.

import { FONT_REGISTRY, faceFileName, fontRegistryBase } from "./fontRegistry"
import { parseSfnt } from "./sfnt"

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

export type FontCall = {
  /** Index of `font` in the source. */
  start: number
  /** Index just past the call's closing `)`. */
  end: number
  /** The literal argument. */
  arg: string
  kind: "registry" | "file"
  /** Registry id (kind "registry"). */
  id?: string
  /** `{ weights }` option (kind "registry"). */
  weights?: number[]
  /** `{ italic: true }` option — italic faces are added alongside the upright ones. */
  italic?: boolean
  /** Root-relative entry path of the font file (kind "file"). */
  filePath?: string
  /** Set when the call is malformed — a hard compile error. */
  error?: string
}

export type FontFaceRef = {
  family: string
  weight: number
  italic: boolean
  /** cdn: extension-less URL base (`…/manrope-400.`) — append woff2/ttf. entry: a project entry. */
  source: { kind: "cdn", urlBase: string } | { kind: "entry", path: string }
}

export type FontMacroOptions = {
  mode: "cdn" | "vendor"
  /** Registry base for cdn URL baking (default: fontRegistryBase()). */
  registryBase?: string
  /** vendor mode: materialize one registry face into the project and return its ENTRY path
   *  (downloading + lockfile bookkeeping is the caller's job). Called once per unique face. */
  vendorFace?: (face: { id: string, family: string, weight: number, italic: boolean, fileName: string }) => string | Promise<string>
  /** Bytes of a project entry, for `font('./x.ttf')` (CLI: disk; backend: uploads). */
  readBytes?: (entryPath: string) => Uint8Array | null
}

const FONT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const CODE_FILE = /\.[tj]sx?$/
/** A file that declares/imports its own `font` — the macro must not touch it. */
const DECLARES_FONT = /\b(?:function|const|let|var|class)\s+font\b|\bimport\s+(?:\{[^}]*\bfont\b[^}]*\}|font)\s+from\b/

// ---- lexing (comment/string/template aware; same approach as the assetIcon macro) ---------------

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

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
}

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
}

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
}

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
}

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
  if (inner.includes(q) && !inner.includes("\\" + q)) return null
  return inner.replace(/\\(.)/g, "$1")
}

const prevNonSpace = (src: string, i: number): string => {
  let k = i - 1
  while (k >= 0 && /\s/.test(src[k])) k--
  return k >= 0 ? src[k] : ""
}

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

/** Find every bare `font(...)` call. Pure and offline; positions index into `source`.
 *  Malformed calls come back with `.error` set (a hard compile error). */
export const scanFontCalls = (source: string): FontCall[] => {
  const calls: FontCall[] = []
  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) === "font" && prevNonSpace(source, i) !== ".") {
        const call = parseCall(source, i, j)
        if (call) { calls.push(call); i = call.end; continue }
      }
      i = j
      continue
    }
    i++
  }
  return calls
}

const parseCall = (src: string, start: number, identEnd: number): FontCall | 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: FontCall = { start, end: close + 1, arg: "", kind: "registry" }
  const args = splitTopLevel(src.slice(i + 1, close), ",")
  const arg = stringLiteralValue(args[0] ?? "")
  if (arg === null) {
    call.error = `font(...) needs a string-literal argument — a registry id ("manrope") or a project path ("./fonts/Brand.ttf"). Got \`${(args[0] ?? "").trim() || "()"}\`.`
    return call
  }
  call.arg = arg

  if (arg.startsWith("./") || arg.startsWith("../")) {
    call.kind = "file"
    if (!/\.(ttf|otf)$/i.test(arg)) {
      call.error = `font("${arg}"): project fonts must be .ttf/.otf files.`
      return call
    }
    if (args.length > 1 && args.slice(1).some((a) => a.trim())) {
      call.error = `font("${arg}"): a project font takes no options — weight and style come from the file itself.`
      return call
    }
    return call
  }

  if (arg === "serif" || arg === "sans-serif" || arg === "monospace" || arg === "monospaced") {
    call.error = `font("${arg}"): system font families need no font() — pass the string straight to fontFamily, e.g. .style({ fontFamily: "${arg}" }).`
    return call
  }
  if (!FONT_ID.test(arg)) {
    call.error = `font("${arg}"): expected a registry id (lowercase [a-z0-9-], e.g. "manrope") or a relative path ("./fonts/Brand.ttf").`
    return call
  }
  call.kind = "registry"
  call.id = arg

  const optsText = args[1]?.trim()
  if (optsText) {
    const err = parseOptions(optsText, call)
    if (err) { call.error = err; return call }
  }
  return call
}

/** Parse `{ weights: [400, 700], italic: true }` — literals only, unknown keys are errors. */
const parseOptions = (optsText: string, call: FontCall): string | null => {
  if (!optsText.startsWith("{") || !optsText.endsWith("}")) {
    return `font("${call.arg}", opts): opts must be an object literal like { weights: [400, 700] }.`
  }
  for (const prop of splitTopLevel(optsText.slice(1, -1), ",")) {
    if (!prop.trim()) continue
    const kv = splitTopLevel(prop, ":")
    if (kv.length < 2) return `font("${call.arg}"): couldn't parse option \`${prop.trim()}\`.`
    const key = kv[0].trim().replace(/^["'`]|["'`]$/g, "")
    const raw = kv.slice(1).join(":").trim()
    if (key === "weights") {
      const m = /^\[\s*(\d+(?:\s*,\s*\d+)*)\s*,?\s*\]$/.exec(raw)
      if (!m) return `font("${call.arg}"): weights must be a literal number array, e.g. { weights: [400, 700] }.`
      call.weights = m[1].split(",").map((s) => parseInt(s.trim(), 10))
    } else if (key === "italic") {
      if (raw !== "true" && raw !== "false") return `font("${call.arg}"): italic must be a literal true/false.`
      call.italic = raw === "true"
    } else {
      return `font("${call.arg}"): unknown option "${key}" (did you mean weights/italic?).`
    }
  }
  return null
}

// ---- path helpers (entry paths are "/"-separated; a leading "/" is tolerated) -------------------

const norm = (p: string) => p.replace(/\\/g, "/").replace(/^\//, "")
const dirOf = (p: string) => { const n = norm(p); const i = n.lastIndexOf("/"); return i === -1 ? "" : n.slice(0, i) }
const baseOf = (p: string) => { const n = norm(p); return n.slice(n.lastIndexOf("/") + 1) }

/** Resolve `rel` ("./x", "../x") against `dir`; "" segments and "." collapse, ".." pops. */
const resolveRel = (dir: string, rel: string): string => {
  const out = dir ? dir.split("/") : []
  for (const seg of rel.split("/")) {
    if (!seg || seg === ".") continue
    if (seg === "..") { if (out.length === 0) return "" ; out.pop() }
    else out.push(seg)
  }
  return out.join("/")
}

/** A relative import specifier from `fromDir` to entry `toPath` (always "./" or "../" prefixed). */
const relSpecifier = (fromDir: string, toPath: string): string => {
  const from = fromDir ? fromDir.split("/") : []
  const to = norm(toPath).split("/")
  let common = 0
  while (common < from.length && common < to.length - 1 && from[common] === to[common]) common++
  const ups = from.length - common
  const rest = to.slice(common).join("/")
  return ups === 0 ? `./${rest}` : `${"../".repeat(ups)}${rest}`
}

// ---- the compile pass ---------------------------------------------------------------------------

const faceKey = (family: string, weight: number, italic: boolean) => `${family}|${weight}|${italic ? "i" : "n"}`

/** Resolve + rewrite every `font(...)` across the compile's text entries: calls become imported
 *  family-name consts, and each using file gains a generated sibling module that registers the
 *  faces (pushed as new text entries). Mutates `entries`. Returns the unique faces (header input);
 *  `[]` when nothing uses the macro. Throws — aborting the compile — on any malformed call. */
export const applyFontMacro = async (entries: FontMacroEntry[], opts: FontMacroOptions): Promise<FontFaceRef[]> => {
  const scanned = entries
    .filter((e) => e.type === "text" && CODE_FILE.test(e.path) && typeof e.text === "string" && e.text.includes("font("))
    .filter((e) => !DECLARES_FONT.test(e.text!))
    .map((e) => ({ entry: e, calls: scanFontCalls(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) => `${s.entry.path}: ${c.error!}`))
  if (errors.length) throw new Error([...new Set(errors)].join("\n"))

  const registryBase = (opts.registryBase ?? fontRegistryBase()).replace(/\/+$/, "")
  const entryByPath = new Map(entries.map((e) => [norm(e.path), e]))
  const faces = new Map<string, FontFaceRef>()
  const fileFaceCache = new Map<string, { family: string, weight: number, italic: boolean }>()

  /** Resolve one call to its family name + the faces it implies (source set later for registry). */
  const resolveCall = (entryPath: string, call: FontCall): { family: string, callFaces: { id?: string, family: string, weight: number, italic: boolean }[] } => {
    if (call.kind === "file") {
      const filePath = resolveRel(dirOf(entryPath), call.arg)
      const target = filePath && entryByPath.get(filePath)
      if (!target) throw new Error(`${entryPath}: font("${call.arg}"): no such file in the project (resolved to "${filePath || call.arg}").`)
      call.filePath = filePath
      let info = fileFaceCache.get(filePath)
      if (!info) {
        if (!opts.readBytes) throw new Error(`${entryPath}: font("${call.arg}"): project-font files aren't supported in this compile context.`)
        const bytes = opts.readBytes(filePath)
        if (!bytes) throw new Error(`${entryPath}: font("${call.arg}"): couldn't read "${filePath}".`)
        try { info = parseSfnt(bytes) } catch (e) {
          throw new Error(`${entryPath}: font("${call.arg}"): ${(e as Error).message}`)
        }
        fileFaceCache.set(filePath, info)
      }
      return { family: info.family, callFaces: [{ family: info.family, weight: info.weight, italic: info.italic }] }
    }

    const meta = FONT_REGISTRY[call.id!]
    if (!meta) {
      const known = Object.keys(FONT_REGISTRY)
      throw new Error(`${entryPath}: font("${call.id}"): unknown registry font. Available: ${known.slice(0, 10).join(", ")}${known.length > 10 ? ", …" : ""}`)
    }
    const weights = call.weights ?? meta.defaultWeights
    const badW = weights.filter((w) => !meta.weights.includes(w))
    if (badW.length) throw new Error(`${entryPath}: font("${call.id}"): no ${badW.join("/")} face — available weights: ${meta.weights.join(", ")}.`)
    if (call.italic) {
      const badI = weights.filter((w) => !meta.italics.includes(w))
      if (badI.length) {
        throw new Error(`${entryPath}: font("${call.id}"): no italic ${badI.join("/")} face${meta.italics.length ? ` — italics exist for: ${meta.italics.join(", ")}` : " — the family ships no italics"}.`)
      }
    }
    const callFaces = weights.flatMap((w) => call.italic
      ? [{ id: call.id, family: meta.family, weight: w, italic: false }, { id: call.id, family: meta.family, weight: w, italic: true }]
      : [{ id: call.id, family: meta.family, weight: w, italic: false }])
    return { family: meta.family, callFaces }
  }

  // Pass 1: resolve every call; collect the global unique face set (sources still pending).
  type FileWork = { entry: FontMacroEntry, calls: { call: FontCall, family: string }[], faceKeys: Set<string> }
  const pendingRegistry = new Map<string, { id: string, family: string, weight: number, italic: boolean }>()
  const work: FileWork[] = []
  for (const { entry, calls } of scanned) {
    const fw: FileWork = { entry, calls: [], faceKeys: new Set() }
    for (const call of calls) {
      const { family, callFaces } = resolveCall(norm(entry.path), call)
      fw.calls.push({ call, family })
      for (const cf of callFaces) {
        const key = faceKey(cf.family, cf.weight, cf.italic)
        fw.faceKeys.add(key)
        if (faces.has(key)) continue
        if (cf.id === undefined) {
          faces.set(key, { family: cf.family, weight: cf.weight, italic: cf.italic, source: { kind: "entry", path: (call.filePath!) } })
        } else {
          pendingRegistry.set(key, cf as { id: string, family: string, weight: number, italic: boolean })
          faces.set(key, { family: cf.family, weight: cf.weight, italic: cf.italic, source: { kind: "cdn", urlBase: "" } })
        }
      }
    }
    work.push(fw)
  }

  // Pass 2: give registry faces their source — baked CDN url bases, or vendored project entries.
  for (const [key, face] of pendingRegistry) {
    const ref = faces.get(key)!
    if (opts.mode === "cdn") {
      ref.source = { kind: "cdn", urlBase: `${registryBase}/${face.id}/${face.id}-${face.weight}${face.italic ? "-italic" : ""}.` }
    } else {
      if (!opts.vendorFace) throw new Error(`font("${face.id}"): registry fonts need vendoring in this compile context, but no vendor hook was provided.`)
      const path = await opts.vendorFace({ ...face, fileName: faceFileName(face.id, face.weight, face.italic, "ttf") })
      ref.source = { kind: "entry", path: norm(path) }
    }
  }

  // Pass 3: rewrite each file + generate its registration module.
  for (const fw of work) {
    const entryPath = norm(fw.entry.path)
    const dir = dirOf(entryPath)
    const modBase = `.${baseOf(entryPath)}.__fonts.ts`
    const modPath = dir ? `${dir}/${modBase}` : modBase

    // family → exported const ident (per file)
    const idents = new Map<string, string>()
    for (const { family } of fw.calls) {
      if (!idents.has(family)) idents.set(family, `__font_${idents.size}`)
    }

    // Rewrite calls back-to-front so earlier indices stay valid.
    let text = fw.entry.text!
    for (const { call, family } of [...fw.calls].sort((a, b) => b.call.start - a.call.start)) {
      text = text.slice(0, call.start) + idents.get(family)! + text.slice(call.end)
    }
    // Appended, never prepended — added lines must not shift the file's source map (see assetMacro).
    text += `\nimport { ${[...idents.values()].join(", ")} } from ${JSON.stringify(`./${modBase}`)}`
    fw.entry.text = text

    const lines: string[] = [
      "// Generated by the font() compile macro: registers this file's fonts. Imports hoist, so the",
      "// faces are declared before the importing module's body runs. Hosts dedupe repeated faces.",
    ]
    const faceImports = new Map<string, string>() // entry path → import ident
    const body: string[] = []
    for (const key of [...fw.faceKeys].sort()) {
      const ref = faces.get(key)!
      let urlExpr: string
      if (ref.source.kind === "cdn") {
        urlExpr = `${JSON.stringify(ref.source.urlBase)} + (_creatorUI.fontFormat ?? "ttf")`
      } else {
        let ident = faceImports.get(ref.source.path)
        if (!ident) {
          ident = `__face_${faceImports.size}`
          faceImports.set(ref.source.path, ident)
          lines.push(`import ${ident} from ${JSON.stringify(relSpecifier(dir, ref.source.path))}`)
        }
        urlExpr = ident
      }
      const optionsExpr = ref.italic ? `{ weight: ${ref.weight}, style: "italic" }` : `{ weight: ${ref.weight} }`
      body.push(`_creatorUI.registerFont(${JSON.stringify(ref.family)}, ${urlExpr}, ${optionsExpr}, () => {}, () => {})`)
    }
    for (const [family, ident] of idents) lines.push(`export const ${ident} = ${JSON.stringify(family)}`)
    lines.push(...body)
    entries.push({ path: modPath, type: "text", text: lines.join("\n") + "\n" })
  }

  return [...faces.values()]
}

// ---- header -------------------------------------------------------------------------------------

/** The `// fonts:` header value: `Family@<weight>[i]=<url>` entries joined by ";". CDN faces carry
 *  a `{fmt}` placeholder the host replaces with its container format (woff2/ttf). Entry-sourced
 *  faces are included only when `entryUrl` yields an absolute URL (URL-mode compiles); local faces
 *  need no prefetch — they resolve synchronously via fetchLocal and already ride `// preload:`. */
export const buildFontsHeaderValue = (
  faces: FontFaceRef[],
  entryUrl?: (path: string) => string | null,
): string =>
  faces
    .map((f) => {
      const tag = `${f.family}@${f.weight}${f.italic ? "i" : ""}`
      if (f.source.kind === "cdn") return `${tag}=${f.source.urlBase}{fmt}`
      const url = entryUrl?.(f.source.path)
      return url ? `${tag}=${url}` : null
    })
    .filter((s): s is string => !!s)
    .join(";")
