// Ported from packages/lecodes-cli/src/compile/assetMacro.ts (itself from the backend). The
// compile-time `asset('./x.png')` macro is desugared into a real import so it goes through the same
// loader/resolver as a hand-written import.

// Matches `asset("./img.png")` / `asset('./img.png')` calls whose argument is a single string
// literal (handling escaped chars inside the literal). The lookbehind excludes property access —
// a METHOD named asset (`ui.asset("model")`, InspectorUI) is not the macro.
const ASSET_RE = /(?<![.\w$])asset\(\s*(["'])((?:[^"'\\]|\\.)*)\1\s*\)/g
const ASSET_CALL_RE = /(?<![.\w$])asset\(/g

/** Cheap pre-check so we don't run the regex over every file. */
export const hasAssetMacro = (src: string): boolean => src.includes("asset(")

/**
 * Blanks out comments and string/template-literal TEXT (spaces, newlines kept) so the macro scan
 * only ever fires on real code. Offsets and line numbers are preserved 1:1 with the input, so a
 * match position in the mask indexes the original source. `${…}` interpolations stay live — an
 * `asset()` inside one is a genuine call.
 *
 * Not a full lexer: a regex LITERAL containing `asset(` would still be seen as code. That case
 * doesn't occur in practice and behaves exactly as it did before this masking existed.
 */
const maskNonCode = (src: string): string => {
  const out = src.split("")
  const blank = (from: number, to: number) => {
    for (let k = from; k < to && k < out.length; k++) if (out[k] !== "\n") out[k] = " "
  }
  const tpl: number[] = [] // brace depth outside each open `${`
  let braces = 0
  let i = 0

  // Blank a template's raw text from `i` up to `${` (which resumes code mode) or the closing tick.
  const scanTemplate = () => {
    const start = i
    while (i < src.length) {
      const c = src[i]
      if (c === "\\") { i += 2; continue }
      if (c === "`") { blank(start, i); i++; return }
      if (c === "$" && src[i + 1] === "{") { blank(start, i); tpl.push(braces); i += 2; return }
      i++
    }
    blank(start, i)
  }

  while (i < src.length) {
    const c = src[i], d = src[i + 1]
    if (c === "/" && d === "/") {
      const e = src.indexOf("\n", i)
      const stop = e === -1 ? src.length : e
      blank(i, stop); i = stop; continue
    }
    if (c === "/" && d === "*") {
      const e = src.indexOf("*/", i + 2)
      const stop = e === -1 ? src.length : e + 2
      blank(i, stop); i = stop; continue
    }
    if (c === "'" || c === "\"") {
      const start = i
      i++
      while (i < src.length) {
        const q = src[i]
        if (q === "\\") { i += 2; continue }
        i++
        if (q === c || q === "\n") break
      }
      blank(start, i); continue
    }
    if (c === "`") { i++; scanTemplate(); continue }
    if (c === "{") { braces++; i++; continue }
    if (c === "}") {
      if (tpl.length > 0 && braces === tpl[tpl.length - 1]) { tpl.pop(); i++; scanTemplate(); continue }
      if (braces > 0) braces--
      i++; continue
    }
    i++
  }
  return out.join("")
}

/** One `asset('…')` call site: the path as written, plus where it sits in the file. */
export type AssetMacroRef = { path: string, index: number, length: number, line: number }

/**
 * Every `asset('…')` macro call in `src`, skipping the ones inside comments and string literals
 * (a commented-out call must not pull in an asset — nor be reported missing). Throws when a call
 * has a dynamic argument, which the macro can't resolve at compile time.
 */
export const collectAssetMacros = (src: string, filePath: string): AssetMacroRef[] => {
  const mask = maskNonCode(src)
  const refs: AssetMacroRef[] = []
  const literal = new Set<number>()
  // Matches arrive in source order, so the line counter walks the file once.
  let scanned = 0
  let line = 1
  for (const m of src.matchAll(ASSET_RE)) {
    const index = m.index ?? 0
    if (mask[index] !== "a") continue // inside a comment or a string — not code
    for (; scanned < index; scanned++) if (src[scanned] === "\n") line++
    refs.push({ path: m[2], index, length: m[0].length, line })
    literal.add(index)
  }
  // Both patterns match at the `a` of `asset`, so any live call site the string-literal form
  // didn't claim was called with something the compiler can't read.
  for (const m of src.matchAll(ASSET_CALL_RE)) {
    const index = m.index ?? 0
    if (mask[index] === "a" && !literal.has(index)) {
      throw new Error(`asset() expects a string literal, found a dynamic argument in: ${filePath}`)
    }
  }
  return refs
}

/**
 * Desugars `asset('./path.png')` into the exact import the user could have written by hand.
 * Identical paths within a file map to one identifier. Only string-literal arguments are supported.
 * `found` is this file's `collectAssetMacros` result when the caller already has it (the bundler
 * collects them to check the files exist first) — otherwise it's scanned here.
 */
export const desugarAssetMacros = (src: string, filePath: string, found?: AssetMacroRef[]): string => {
  const refs = found ?? collectAssetMacros(src, filePath)
  if (refs.length === 0) return src

  const byPath = new Map<string, string>()
  const parts: string[] = []
  let at = 0
  for (const ref of refs) {
    let name = byPath.get(ref.path)
    if (!name) {
      name = `__asset_${byPath.size}`
      byPath.set(ref.path, name)
    }
    parts.push(src.slice(at, ref.index), name)
    at = ref.index + ref.length
  }
  parts.push(src.slice(at))

  const imports = [ ...byPath ]
    .map(([ path, name ]) => `import ${name} from ${JSON.stringify(path)};`)
    .join("\n")

  // APPENDED, never prepended. ESM imports hoist, so their position is semantically irrelevant —
  // but lines added at the TOP would shift every original line, and the bundle's source map would
  // then disagree with the file the editor shows: inspector jumps and error-overlay stack frames
  // land N lines off in exactly (and only) the files that use asset(). Appending adds no offsets.
  return parts.join("") + "\n" + imports
}
