/*
  Generates `src/assets/icons.generated.js` — the icon data the UI's own chrome draws with.

  Why inline the data rather than fetch it:

  - The webfonts it replaces cost ~577 kB of binary plus a 66 kB (gzipped) class table, to draw 227
    icons out of the ~8,800 those fonts contain.
  - Fetching the same icons from `/_icons` at runtime would be small, but it would make the admin UI
    depend on the icon service: resolution is gated on the set being enabled, `DELETE /icons/sets/:prefix`
    removes every stored icon for a set, and `offline` mode skips the upstream API entirely. An
    administrator disabling the `mdi` set would blank the interface they were using to do it.

  Inlining sidesteps all of that: the chrome's icons are build output, and nothing at runtime can take
  them away. Icons the USER picks still resolve through `/_icons` as before — see `WIcon`.

  The output is committed. Builds are then reproducible and need no network, and the diff shows
  exactly which icons changed. `check-icons.mjs` fails if it drifts out of step with the source.

  Usage: node scripts/generate-icons.mjs [--check]
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const ROOT = fileURLToPath(new URL('../', import.meta.url))
const SRC = path.join(ROOT, 'src')
const OUT = path.join(SRC, 'assets/icons.generated.js')

/**
 * Icon sets we bundle from. A prefix not listed here is left to resolve at runtime — which for an icon
 * written into this repo's own source means it does not render at all unless an administrator happens to
 * have added that set, so a new set has to be listed HERE and installed as `@iconify-json/<prefix>`.
 *
 * The skip is silent on purpose: `prefix:name` also describes every permission string in the frontend
 * (`write:pages`, `manage:system`), and those must not be mistaken for icons.
 */
const SETS = ['mdi', 'la']

/**
 * A quoted string that is EXACTLY an Iconify reference. Requiring the whole literal to match is what
 * keeps arbitrary `prefix:name`-shaped strings (i18n keys, CSS values) out of the bundle.
 */
const REF = /(["'`])([a-z0-9]+(?:-[a-z0-9]+)*:[a-z0-9]+(?:[-.][a-z0-9]+)*)\1/g

function* sourceFiles(dir) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, entry.name)
    if (entry.isDirectory()) {
      yield* sourceFiles(full)
    } else if (/\.(vue|js)$/.test(entry.name) && !entry.name.endsWith('.generated.js')) {
      yield full
    }
  }
}

/** Every statically written reference to one of the bundled sets. */
export function collectRefs() {
  const found = new Map()
  for (const file of sourceFiles(SRC)) {
    const src = fs.readFileSync(file, 'utf8')
    for (const m of src.matchAll(REF)) {
      const ref = m[2]
      if (!SETS.includes(ref.split(':')[0])) {
        continue
      }
      if (!found.has(ref)) {
        found.set(ref, [])
      }
      found.get(ref).push(path.relative(ROOT, file))
    }
  }
  return found
}

/**
 * Resolve a name against a set, following aliases.
 *
 * An alias may carry its own transform (`hFlip`, `rotate`, …) on top of the icon it points at. Those
 * are applied by the renderer, so they have to travel with the body rather than being dropped.
 */
function resolveIcon(set, name, seen = new Set()) {
  if (seen.has(name)) {
    return null
  }
  seen.add(name)
  if (set.icons[name]) {
    return { ...set.icons[name] }
  }
  const alias = set.aliases?.[name]
  if (!alias) {
    return null
  }
  const target = resolveIcon(set, alias.parent, seen)
  if (!target) {
    return null
  }
  const { parent, ...transforms } = alias
  return { ...target, ...transforms }
}

export function build() {
  const refs = collectRefs()
  const sets = Object.fromEntries(
    SETS.map((p) => [
      p,
      JSON.parse(
        fs.readFileSync(path.join(ROOT, `node_modules/@iconify-json/${p}/icons.json`), 'utf8')
      )
    ])
  )

  const icons = {}
  const missing = []
  for (const ref of [...refs.keys()].sort()) {
    const [prefix, name] = ref.split(':')
    const set = sets[prefix]
    const icon = resolveIcon(set, name)
    if (!icon) {
      missing.push({ ref, where: refs.get(ref) })
      continue
    }
    icons[ref] = {
      body: icon.body,
      // -> Default to the set's own grid; an icon may override it
      width: icon.width ?? set.width ?? 16,
      height: icon.height ?? set.height ?? 16,
      ...(icon.rotate ? { rotate: icon.rotate } : {}),
      ...(icon.hFlip ? { hFlip: true } : {}),
      ...(icon.vFlip ? { vFlip: true } : {})
    }
  }
  return { icons, missing, refs }
}

function serialize(icons) {
  const entries = Object.entries(icons)
    .map(([ref, data]) => `  ${JSON.stringify(ref)}: ${JSON.stringify(data)}`)
    .join(',\n')
  return `/*
  GENERATED by scripts/generate-icons.mjs — do not edit.

  Icon data for every Iconify reference written literally in the source, inlined so the interface
  never waits on (or depends on) the icon service. Regenerate with \`npm run icons\` after adding or
  removing an icon; \`check-icons.mjs\` fails the build if this drifts.

  ${Object.keys(icons).length} icons.
*/
export const BUNDLED_ICONS = {
${entries}
}
`
}

const { icons, missing } = build()

if (missing.length) {
  console.error(`\n${missing.length} reference(s) not found in the installed icon sets:`)
  for (const m of missing) {
    console.error(`  ${m.ref}  (${[...new Set(m.where)].join(', ')})`)
  }
  process.exit(1)
}

const output = serialize(icons)

if (process.argv.includes('--check')) {
  const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : ''
  if (current !== output) {
    console.error('icons.generated.js is out of date — run `npm run icons`')
    process.exit(1)
  }
  console.log(`OK  ${Object.keys(icons).length} icons, bundle up to date`)
} else {
  fs.mkdirSync(path.dirname(OUT), { recursive: true })
  fs.writeFileSync(OUT, output)
  const bytes = Buffer.byteLength(output)
  console.log(
    `wrote ${Object.keys(icons).length} icons to src/assets/icons.generated.js (${bytes.toLocaleString()} B)`
  )
}
