#!/usr/bin/env npx tsx
/**
 * Generates dist/greyhaven.htmx.css — a framework-agnostic CSS companion
 * to the React component library. Exposes every component via `data-slot`
 * (+ `data-variant` / `data-size`) attribute selectors so HTMX / server-
 * rendered projects can use the design system without React.
 *
 * Input:
 *   components/ui/*.tsx
 *
 * Output:
 *   dist/greyhaven.htmx.css
 *
 * Extraction strategy:
 *   1. AST-walk each .tsx file
 *   2. For `const xVariants = cva("base", { variants, defaultVariants })`,
 *      capture base + variants + defaults.
 *   3. For any JSX element with `data-slot="X"`, capture the static string
 *      in its `className` (direct string, or first arg of cn(...)).
 *   4. Merge the two: a slot with both a cva binding and static cn classes
 *      (rare) gets both; otherwise one or the other.
 *
 * Limitations:
 *   - Dynamic / conditional classes are dropped (logged as warnings).
 *   - Components relying on runtime state (data-state, Radix Portals) emit
 *     only their static visual rules. Open/close / focus / positioning JS
 *     is the consumer's problem.
 */

import * as ts from 'typescript'
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
const UI_DIR = path.join(ROOT, 'components/ui')
const OUT_FILE = path.join(ROOT, 'dist/greyhaven.htmx.css')

type CvaExtract = {
  sourceFile: string
  variableName: string
  base: string
  variants: Record<string, Record<string, string>>
  defaultVariants: Record<string, string>
}

type SlotExtract = {
  sourceFile: string
  slot: string
  classes: string
  /**
   * Names of CVA functions referenced inside the slot's `cn(...)` call — e.g.
   * `cn(toggleVariants({variant,size}), 'rounded-none ...')` on
   * ToggleGroupItem records `['toggleVariants']`. Used to emit the
   * referenced CVA's base + variant rules under this slot's selector, so
   * slots that *compose* another component's variant system (instead of
   * declaring their own cva) still inherit its padding/height/states.
   */
  viaVariants: string[]
}

type Warning = { sourceFile: string; message: string }

const cvaExtracts: CvaExtract[] = []
const slotExtracts: SlotExtract[] = []
const warnings: Warning[] = []

// ─── String extraction helpers ──────────────────────────────────────────

function getStringLiteral(node: ts.Node): string | null {
  if (ts.isStringLiteral(node)) return node.text
  if (ts.isNoSubstitutionTemplateLiteral(node)) return node.text
  // Template with only literal parts: `${''}foo${''}` — rare, skip
  return null
}

// Object-literal property shortcut: returns first ObjectLiteralExpression named `name`.
function getPropertyInitializer(
  obj: ts.ObjectLiteralExpression,
  name: string,
): ts.Expression | null {
  for (const prop of obj.properties) {
    if (ts.isPropertyAssignment(prop) && prop.name && ts.isIdentifier(prop.name) && prop.name.text === name) {
      return prop.initializer
    }
  }
  return null
}

// ─── CVA extractor ──────────────────────────────────────────────────────

function extractCva(
  declList: ts.VariableDeclaration,
  call: ts.CallExpression,
  sourceFile: string,
): void {
  if (!ts.isIdentifier(declList.name)) return
  const variableName = declList.name.text

  const baseArg = call.arguments[0]
  const configArg = call.arguments[1]
  const base = baseArg ? (getStringLiteral(baseArg) ?? '') : ''
  if (!base) {
    warnings.push({ sourceFile, message: `cva: ${variableName}: non-literal base, skipping` })
    return
  }
  if (!configArg || !ts.isObjectLiteralExpression(configArg)) {
    warnings.push({ sourceFile, message: `cva: ${variableName}: no config object, skipping` })
    return
  }

  const variants: Record<string, Record<string, string>> = {}
  const variantsNode = getPropertyInitializer(configArg, 'variants')
  if (variantsNode && ts.isObjectLiteralExpression(variantsNode)) {
    for (const prop of variantsNode.properties) {
      if (!ts.isPropertyAssignment(prop)) continue
      if (!prop.name || !ts.isIdentifier(prop.name)) continue
      const axisName = prop.name.text
      if (!ts.isObjectLiteralExpression(prop.initializer)) continue
      const values: Record<string, string> = {}
      for (const subProp of prop.initializer.properties) {
        if (!ts.isPropertyAssignment(subProp)) continue
        let key: string | null = null
        if (subProp.name) {
          if (ts.isIdentifier(subProp.name)) key = subProp.name.text
          else if (ts.isStringLiteral(subProp.name)) key = subProp.name.text
        }
        if (!key) continue
        const classes = getStringLiteral(subProp.initializer)
        if (classes === null) {
          warnings.push({
            sourceFile,
            message: `cva: ${variableName}.${axisName}.${key}: non-literal classes, skipping value`,
          })
          continue
        }
        values[key] = classes
      }
      variants[axisName] = values
    }
  }

  const defaultVariants: Record<string, string> = {}
  const defaultsNode = getPropertyInitializer(configArg, 'defaultVariants')
  if (defaultsNode && ts.isObjectLiteralExpression(defaultsNode)) {
    for (const prop of defaultsNode.properties) {
      if (!ts.isPropertyAssignment(prop)) continue
      if (!prop.name || !ts.isIdentifier(prop.name)) continue
      const axisName = prop.name.text
      const value = getStringLiteral(prop.initializer)
      if (value !== null) defaultVariants[axisName] = value
    }
  }

  cvaExtracts.push({ sourceFile, variableName, base, variants, defaultVariants })
}

// ─── Slot extractor ─────────────────────────────────────────────────────

function extractSlot(
  element: ts.JsxOpeningLikeElement,
  sourceFile: string,
): void {
  let slot: string | null = null
  let classes: string | null = null
  const viaVariants: string[] = []

  const attrs = element.attributes.properties
  for (const attr of attrs) {
    if (!ts.isJsxAttribute(attr)) continue
    const attrName = attr.name.getText()
    if (attrName === 'data-slot') {
      if (attr.initializer && ts.isStringLiteral(attr.initializer)) {
        slot = attr.initializer.text
      }
    } else if (attrName === 'className' && attr.initializer) {
      if (ts.isStringLiteral(attr.initializer)) {
        classes = attr.initializer.text
      } else if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
        const expr = attr.initializer.expression
        if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
          classes = (expr as ts.StringLiteral).text
        } else if (ts.isCallExpression(expr)) {
          const callName = expr.expression.getText()
          if (callName === 'cn' || callName.endsWith('Variants')) {
            // First string-literal arg is the static class baseline.
            const first = expr.arguments.find((a) => getStringLiteral(a) !== null)
            if (first) classes = getStringLiteral(first)
            // Any *call* arg whose callee is a `xVariants` identifier means this
            // slot inherits from another component's variant system (e.g.
            // ToggleGroupItem: `cn(toggleVariants({variant,size}), 'rounded-none...')`).
            // Record the names so the emitter can pull in those CVAs' base + variants.
            for (const arg of expr.arguments) {
              if (!ts.isCallExpression(arg)) continue
              const argCallee = arg.expression
              if (ts.isIdentifier(argCallee) && argCallee.text.endsWith('Variants')) {
                viaVariants.push(argCallee.text)
              }
            }
          }
        }
      }
    }
  }

  if (slot) {
    slotExtracts.push({ sourceFile, slot, classes: classes ?? '', viaVariants })
  }
}

// ─── AST walker ─────────────────────────────────────────────────────────

function walkFile(filePath: string): void {
  const relPath = path.relative(ROOT, filePath)
  const source = fs.readFileSync(filePath, 'utf-8')
  const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)

  function visit(node: ts.Node): void {
    // CVA binding: `const xVariants = cva(...)`
    if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
      const callee = node.initializer.expression
      if (ts.isIdentifier(callee) && callee.text === 'cva') {
        extractCva(node, node.initializer, relPath)
      }
    }
    // JSX element with data-slot
    if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
      extractSlot(node, relPath)
    }
    ts.forEachChild(node, visit)
  }

  visit(sf)
}

// ─── CSS emitter ────────────────────────────────────────────────────────

// Classes that can't be used with @apply in Tailwind v4.
//   peer / group          — sibling/parent markers, pure class hooks with no CSS
//   peer/<name> / group/<name>  — named variants of the above
//   contents              — reserved Tailwind escape
//   not-prose             — ships with @tailwindcss/typography (optional plugin)
// Consumers who need these can add them directly on the HTML element.
const NON_APPLIABLE_EXACT = new Set([
  'peer',         // sibling marker
  'group',        // parent marker
  'contents',     // reserved
  'not-prose',    // @tailwindcss/typography
  'origin-top-center', // not a stock Tailwind v4 utility (upstream bug)
  'destructive',  // toast's own marker class (used with group-[.destructive]: selectors)
])

function isNonAppliable(cls: string): boolean {
  if (NON_APPLIABLE_EXACT.has(cls)) return true
  // Named peer/group markers: e.g., `group/drawer-content`, `peer/email`
  if (/^(peer|group)\/[A-Za-z0-9_-]+$/.test(cls)) return true
  return false
}

function uniqueClasses(s: string): string {
  // Collapse whitespace; preserve order and arbitrary variants.
  // Strip non-appliable marker classes (peer/group); consumers add them directly
  // on the HTML element when they need sibling/parent state styling.
  let tokens = s
    .replace(/\s+/g, ' ')
    .trim()
    .split(' ')
    .filter((c) => !isNonAppliable(c))

  // Always strip `leading-*` utilities from @apply. Tailwind v4's `text-*`
  // size utilities use `--tw-leading` as an override mechanism, but once
  // `leading-*` is applied via @apply it sets `--tw-leading` on the element,
  // and subsequent user-passed `text-sm`/`text-xl` classes still resolve
  // line-height through that inherited variable — defeating the override.
  // React + tailwind-merge removes `leading-*` at className-merge time when
  // a text-size utility is passed; replicate that behavior by stripping it
  // unconditionally so user `class="text-xl"` overrides produce the same
  // line-height React ends up with.
  const LEADING = /^leading-/
  tokens = tokens.filter((t) => !LEADING.test(t))

  return tokens.join(' ')
}

function emitCss(): string {
  const lines: string[] = []
  const header = `/*! Greyhaven Design System — HTMX / Framework-Agnostic CSS Layer
 * Auto-generated from components/ui/*.tsx by scripts/generate-htmx-css.ts — DO NOT EDIT
 *
 * Usage:
 *   <link href="greyhaven.htmx.css" rel="stylesheet">
 *
 * Requires:
 *   - Tokens: import tokens-light.css + tokens-dark.css before this file
 *   - Tailwind v4: this file uses @apply against Tailwind utility classes.
 *     It must be processed by Tailwind v4 (e.g., via \`tailwindcss -i input.css\`).
 *     Your consumer Tailwind input should \`@import "./greyhaven.htmx.css";\`.
 *
 * Consume via data-slot / data-variant / data-size attributes:
 *   <div data-slot="card">
 *     <div data-slot="card-header"><div data-slot="card-title">Hello</div></div>
 *     <div data-slot="card-content">…</div>
 *   </div>
 *   <button data-slot="button" data-variant="outline" data-size="sm">Click</button>
 *   <span data-slot="badge" data-variant="success">Active</span>
 */

`
  lines.push(header)
  // Emit in @layer utilities so individual Tailwind utility classes on child
  // elements (e.g. <svg class="h-3.5">) don't override our compound selectors
  // by layer precedence alone. Within the same layer, specificity decides.
  lines.push('@layer utilities {\n')

  // Index slot → classes (dedupe: multiple JSX elements may declare the same slot).
  const slotMap = new Map<string, Set<string>>()
  for (const s of slotExtracts) {
    if (!s.classes) continue
    if (!slotMap.has(s.slot)) slotMap.set(s.slot, new Set())
    slotMap.get(s.slot)!.add(uniqueClasses(s.classes))
  }

  // Index cva by slot-name heuristic: `xVariants` → component slot "x" when JSX
  // in the same file uses `data-slot="x"`. If ambiguous, fall back to stripping
  // "Variants" and kebab-casing.
  const cvaBySlot = new Map<string, CvaExtract>()
  const cvaByName = new Map<string, CvaExtract>()
  for (const cva of cvaExtracts) {
    cvaByName.set(cva.variableName, cva)
    const stripped = cva.variableName.replace(/Variants$/, '')
    const slot = stripped
      .replace(/([a-z])([A-Z])/g, '$1-$2')
      .toLowerCase()
    cvaBySlot.set(slot, cva)
  }

  // Aggregate viaVariants per slot (a slot may appear in multiple JSX sites).
  const slotVia = new Map<string, Set<string>>()
  for (const s of slotExtracts) {
    if (s.viaVariants.length === 0) continue
    if (!slotVia.has(s.slot)) slotVia.set(s.slot, new Set())
    for (const v of s.viaVariants) slotVia.get(s.slot)!.add(v)
  }

  // Emit slots in sorted order for stable output.
  const allSlots = Array.from(
    new Set([...slotMap.keys(), ...cvaBySlot.keys()]),
  ).sort()

  for (const slot of allSlots) {
    const selfCva = cvaBySlot.get(slot)
    // Gather CVAs to apply under this slot's selector: its own (if any), plus
    // any CVAs referenced via `cn(xVariants(...), ...)` in the JSX (e.g.
    // ToggleGroupItem inherits from toggleVariants). Dedup.
    const cvas: CvaExtract[] = []
    if (selfCva) cvas.push(selfCva)
    for (const name of slotVia.get(slot) ?? []) {
      const aliasCva = cvaByName.get(name)
      if (aliasCva && !cvas.includes(aliasCva)) cvas.push(aliasCva)
    }
    const staticSets = slotMap.get(slot)
    const staticClasses = staticSets ? Array.from(staticSets).join(' ') : ''

    lines.push(`  /* ── ${slot} ─────────────────────────────────────────── */`)

    // Emit selectors wrapped in :where() so the attribute selectors contribute
    // zero specificity. This matches how React + tailwind-merge behave: user
    // overrides passed as `className` (e.g., `class="bg-primary/90"`) must
    // win over the variant defaults. If we used bare [data-slot][data-variant]
    // selectors, their specificity (0,2,0) would beat a plain utility class
    // (0,1,0) and silently drop user overrides.
    const SLOT = `:where([data-slot="${slot}"])`

    // Base rule: every contributing CVA's base + any static classes from JSX.
    const basePieces: string[] = []
    for (const c of cvas) if (c.base) basePieces.push(c.base)
    if (staticClasses) basePieces.push(staticClasses)
    const base = uniqueClasses(basePieces.join(' '))
    if (base) {
      lines.push(`  ${SLOT} { @apply ${base}; }`)
    }

    // Emit variant rules for each contributing CVA, under this slot's selector.
    // When a slot inherits (e.g. toggle-group-item via toggleVariants), its
    // data-variant/data-size attributes on the DOM drive the inherited CVA's
    // rules just like they drive the self-CVA's.
    for (const c of cvas) {
      for (const [axis, values] of Object.entries(c.variants)) {
        const defaultValue = c.defaultVariants[axis]
        const axisAttr = `data-${axis}`
        for (const [key, classes] of Object.entries(values)) {
          if (!classes.trim()) continue
          if (key === defaultValue) {
            // Default: apply when attr absent OR explicitly set to this key.
            // Emit as TWO separate rules (no comma-joined selector list), because
            // Tailwind v4 miscompiles arbitrary variants like `has-[>svg]:px-3`
            // when @applied inside a rule with a comma-separated selector list
            // (it emits a stray `)` and the resulting selector is invalid).
            // Wrap :not() in :where() so it contributes zero specificity;
            // otherwise plain utility classes like .bg-primary/90 would tie on
            // specificity (both at 0,1,0) and lose by source order, breaking
            // user className overrides.
            lines.push(
              `  ${SLOT}:where(:not([${axisAttr}])) { @apply ${uniqueClasses(classes)}; }`,
            )
            lines.push(
              `  ${SLOT}:where([${axisAttr}="${key}"]) { @apply ${uniqueClasses(classes)}; }`,
            )
          } else {
            lines.push(
              `  ${SLOT}:where([${axisAttr}="${key}"]) { @apply ${uniqueClasses(classes)}; }`,
            )
          }
        }
      }
    }

    lines.push('')
  }

  lines.push('}\n')

  if (warnings.length > 0) {
    lines.push('/* Extraction warnings:')
    for (const w of warnings) {
      lines.push(` * [${w.sourceFile}] ${w.message}`)
    }
    lines.push(' */\n')
  }

  return lines.join('\n')
}

// ─── Entry ──────────────────────────────────────────────────────────────

function main(): void {
  const files = fs
    .readdirSync(UI_DIR)
    .filter((f) => f.endsWith('.tsx'))
    .map((f) => path.join(UI_DIR, f))

  for (const file of files) {
    walkFile(file)
  }

  const css = emitCss()
  fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true })
  fs.writeFileSync(OUT_FILE, css, 'utf-8')

  console.log(`Parsed ${files.length} component files`)
  console.log(`  ${cvaExtracts.length} cva bindings`)
  console.log(`  ${slotExtracts.length} data-slot occurrences`)
  console.log(`  ${warnings.length} warnings`)
  console.log(`Wrote ${path.relative(ROOT, OUT_FILE)} (${(fs.statSync(OUT_FILE).size / 1024).toFixed(1)} KB)`)
}

main()
