#!/usr/bin/env python3
"""adia-scaffold — lay the minimal bones of an adia-ui app (structure, not opinions).

It scaffolds the load-bearing skeleton and stops — exact package paths, versions, and the first
real screen are yours (and the a2ui MCP's), because those are what drift. Modes:

  adia-scaffold spa <name> [-o DIR] [--force]
      A client-rendered app: the four-axis layout (spec/ plan/ app/ skills/), a static host
      document (cascade-ordered links + one registration script), and a self-booting placeholder
      surface.

  adia-scaffold ssr <name> --framework {next,nuxt,sveltekit,astro} [-o DIR] [--force]
      The adia integration layer to drop into an EXISTING framework app: a client-boundary provider
      (deferred registration) for the chosen framework + a README integration checklist.

  adia-scaffold page <name> [-o DIR] [--duo] [--force]
      Add a page to a surface: a page-trio (<name>.html + .contents.html + .contents.js exporting
      setup) — or a page-DUO (no .contents.js) with --duo, for a purely declarative page.

  adia-scaffold component <tag> [-o DIR] [--force]
      Add a light-DOM component folder: components/<tag>/<tag>.{js,css} (a lint-clean skeleton —
      self-booting container, two-block @scope, token-only).

  adia-scaffold selftest
      Scaffold each shape to a temp dir and assert the expected files exist. Exit 1 on failure.

Refuses to overwrite existing files unless --force. Stdlib only (Python 3.8+).
"""
import argparse
import json
import os
import re
import subprocess
import sys
import tarfile
import tempfile


def _slug(name):
    s = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
    return s or "app"


def _tag(name):
    """A valid custom-element tag (must contain a hyphen)."""
    s = _slug(name)
    return s if "-" in s else f"{s}-app"


def _cls(tag):
    return "UI" + "".join(p.capitalize() for p in tag.split("-"))


# ---- SPA templates ---------------------------------------------------------

def _spa_files(name):
    tag, title = _tag(name), name.strip() or _slug(name)
    cls = _cls(tag)
    return {
        "spec/BRIEF.md": f"# {title} — brief\n\nWhat this app is, who it's for, the one job it does.\n",
        "plan/ROADMAP.md": f"# {title} — roadmap\n\n- [ ] First surface\n",
        "skills/.gitkeep": "# app-specific expert skill goes here (optional)\n",
        "app/shared/.gitkeep": "# cross-surface source: DataClient, loaders, mappers, images\n",
        f"app/{tag}/src/index.html": _SPA_HTML.format(tag=tag, title=title),
        f"app/{tag}/src/index.css": _SPA_PAGE_CSS.format(tag=tag),
        f"app/{tag}/src/components/{tag}/{tag}.js": _SPA_JS.format(tag=tag, cls=cls, title=title),
        f"app/{tag}/src/components/{tag}/{tag}.css": _SPA_CSS.format(tag=tag),
        f"app/{tag}/vite.config.js": _VITE_CONFIG.format(tag=tag),
        f"app/{tag}/package.json": _PKG_JSON.format(tag=tag, title=title),
        f"app/{tag}/README.md": _SPA_README.format(tag=tag, title=title),
    }


_SPA_HTML = """<!doctype html>
<html lang="en" data-theme="auto">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>{title}</title>

  <!--
    HOW TO SERVE @adia-ai/web-components — pick one:

    A) Vite (recommended, npm consumer): run `npm install` then `vite` from app/{tag}/.
       Vite resolves @adia-ai/* bare specifiers — CSS *and* JS — through its module graph
       automatically; the importmap below is NOT needed in this mode. That's why the
       foundation + per-component CSS below is imported from the registration script
       (./components/{tag}/{tag}.js), never linked as a raw `/node_modules/...` URL — this
       file's own vite.config.js sets `root: 'src'`, so a *static* URL at that literal path
       resolves against src/ (where node_modules doesn't exist) and 404s — silently, behind
       Vite's SPA-fallback middleware, which serves this index.html back with a 200.

    B) Import-map (no bundler, e.g. native browser modules or a CDN):
       Uncomment the importmap block below and point the paths to wherever the
       @adia-ai packages are served — a local static server, esm.sh, or unpkg. There's no
       module graph to carry a CSS import without a bundler, so also uncomment the matching
       <link> pair below and point it at the same location.

    C) Monorepo / dev server (framework contributors only):
       The /packages/web-components/... paths resolve when the monorepo's own vite dev
       server is running (its root serves the whole repo). This is NOT a consumer
       deployment mode.

    Cascade order is load-bearing (later wins):
      foundation (host.css) → per-component primitive CSS (page.css, text.css) → page
      framing → surface chrome — the first two arrive via the registration script's CSS
      imports, in that order (see ADR-0107; ./components/{tag}/{tag}.js's own header
      documents the barrel-vs-piecemeal trade-off and the kitchen-sink opt-in).
  -->

  <!--
  OPTION B — import-map (uncomment + adjust URLs when not using Vite):
  <script type="importmap">
  {{
    "imports": {{
      "@adia-ai/web-components": "https://esm.sh/@adia-ai/web-components",
      "@adia-ai/web-components/": "https://esm.sh/@adia-ai/web-components/"
    }}
  }}
  </script>
  <link rel="stylesheet" href="https://esm.sh/@adia-ai/web-components/styles/host.css" />
  <link rel="stylesheet" href="https://esm.sh/@adia-ai/web-components/components/page.css" />
  <link rel="stylesheet" href="https://esm.sh/@adia-ai/web-components/components/text.css" />
  -->

  <link rel="stylesheet" href="./index.css" />                                              <!-- page framing -->
  <link rel="stylesheet" href="./components/{tag}/{tag}.css" />                            <!-- the surface's chrome -->

  <!-- One registration script: imports the foundation + per-component CSS (cascade order
       preserved by import order), then registers only page-ui + text-ui — the tags this
       placeholder markup uses (ADR-0107). Bare @adia-ai/* specifiers resolve via Vite's
       module graph (node_modules) or the importmap above — never a static
       /node_modules/... URL. -->
  <script type="module" src="./components/{tag}/{tag}.js"></script>
</head>
<body>
  <{tag}></{tag}>
</body>
</html>
"""

_VITE_CONFIG = """import {{ defineConfig }} from 'vite';
import {{ resolve }} from 'node:path';

// Consumer vite config for {tag}.
// Run: npm install && npx vite
//
// root: 'src' makes src/index.html the dev-server entry, and its relative CSS/JS
// paths resolve against src/ — but a *static* URL like `/node_modules/...` in an
// <link>/<script src> would resolve against src/ too (node_modules lives outside
// it) and 404 silently, behind Vite's SPA fallback. @adia-ai/* CSS and JS bare
// specifiers avoid this: Vite's MODULE resolver (not its static file server)
// walks node_modules the normal Node way — see src/index.html's own comment.
export default defineConfig({{
  root: 'src',
  server: {{
    open: true,
  }},
  build: {{
    outDir: '../dist',
    emptyOutDir: true,
  }},
}});
"""

_PKG_JSON = """{{
  "name": "{tag}",
  "version": "0.1.0",
  "description": "{title}",
  "private": true,
  "type": "module",
  "scripts": {{
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  }},
  "dependencies": {{
    "@adia-ai/web-components": "latest"
  }},
  "devDependencies": {{
    "vite": "^5.0.0"
  }}
}}
"""

_SPA_README = """# {title}

Scaffolded by `adia-scaffold spa` (ADR-0107). Run `npm install` then `npm run dev`
from `app/{tag}/`.

## Bundle cost

This scaffold registers `@adia-ai/web-components` **per component** by default —
`./components/{tag}/{tag}.js` imports only `page-ui` + `text-ui` (the tags this
placeholder markup uses), plus their per-component CSS and the `styles/host.css`
foundation layer (tokens + resets + page-frame). That is a deliberate change from
the kit's zero-config barrel:

| | Per-component (this scaffold's default) | Kitchen-sink barrel |
|---|---|---|
| JS import | `@adia-ai/web-components/components/button` (per tag you use) | `@adia-ai/web-components` |
| CSS import | `@adia-ai/web-components/components/button.css` + `styles/host.css` | `@adia-ai/web-components/css` |
| Registers | only the tags you import | all ~90 primitives |
| Icons | none until you opt in | the full Phosphor set — `core/icons-phosphor.js`'s own header documents ~9,000 SVG files (~1,500 names × 6 weights) as `import.meta.glob` chunks, regardless of how many `<icon-ui>` names your app actually renders |

Add a component (e.g. `button-ui`): `import '@adia-ai/web-components/components/button';` +
`import '@adia-ai/web-components/components/button.css';` (the exports map
already ships every primitive this way — see the barrel's own header comment,
"Apps with bundle-size SLOs should skip this barrel, import primitives
piecemeal").

**Opt into icons without the full Phosphor set** — register only the names you
use (`core/icons-phosphor.js`'s own JSDoc has the full recipe):

```js
import {{ installIconLoaders }} from '@adia-ai/web-components/core/icons';
installIconLoaders({{
  regular: import.meta.glob(
    '../node_modules/@phosphor-icons/core/assets/regular/{{caret-right,moon,sun,x}}.svg',
    {{ query: '?raw', import: 'default', eager: true }}
  ),
}});
```

**Explicit kitchen-sink opt-in** — if you'd rather have every primitive +
every icon registered zero-config (dev convenience, small internal tools,
apps with no bundle-size SLO), swap `./components/{tag}/{tag}.js`'s imports
for the barrel:

```js
import '@adia-ai/web-components';      // registers every *-ui tag
import '@adia-ai/web-components/css';  // foundation + every primitive's CSS
```

This is still fully supported — nothing about ADR-0107 deprecates or changes
the barrel's behavior, it only changes what a NEW `adia-scaffold spa` run
writes by default.
"""

_SPA_PAGE_CSS = """/* Page framing — size + center the surface. Tokens only; never re-roll :where(html,body). */
{tag} {{
  display: block;
  max-inline-size: 80rem;
  margin-inline: auto;
}}
"""

_SPA_JS = """// Per-component registration + CSS (ADR-0107) — only the tags this placeholder
// markup actually uses. page-ui and text-ui are the only real custom elements
// below; header-ui/section-ui are plain tags page-ui's own CSS styles as
// descendants (see page.css), so they need no separate import.
// Payload: ~2 files vs. the barrel's ~90-element / ~9,000-icon-chunk cost —
// see this app's README ("Bundle cost" section) for the numbers and the
// explicit kitchen-sink opt-in.
import '@adia-ai/web-components/styles/host.css';  // tokens + resets + page-frame (foundation layer)
import '@adia-ai/web-components/components/page';
import '@adia-ai/web-components/components/page.css';
import '@adia-ai/web-components/components/text';
import '@adia-ai/web-components/components/text.css';
// Kitchen-sink alternative — swap the six imports above for these two to register
// every primitive + link the full barrel stylesheet (adds ~90 elements' worth of
// JS and CSS, and the full Phosphor icon set via icons-phosphor.js):
//   import '@adia-ai/web-components';
//   import '@adia-ai/web-components/css';
import {{ defineIfFree }} from '@adia-ai/web-components/core/register';
import {{ UIElement }} from '@adia-ai/web-components/core/element';

class {cls} extends UIElement {{
  #booted = false;
  connected() {{
    if (this.#booted) return; // the callback re-fires whenever the element moves in the DOM
    this.#booted = true;
    // a11y baked in (gh#1252): region landmark on page-ui + a SEMANTIC heading —
    // text-ui variants are presentational-only (text.yaml), so the heading is an
    // authored slot child carrying role="heading" aria-level="1" explicitly.
    this.innerHTML = `
      <page-ui role="region" aria-label="{title}">
        <header-ui>
          <text-ui slot="heading" variant="title" role="heading" aria-level="1">{title}</text-ui>
        </header-ui>
        <section-ui>
          <text-ui>Scaffolded by adia-ui-factory. Build the real surface with /screen-composition.</text-ui>
        </section-ui>
      </page-ui>`;
  }}
}}
defineIfFree('{tag}', {cls});
export {{ {cls} }};
"""

_SPA_CSS = """@scope ({tag}) {{
  :where(:scope) {{          /* zero-specificity tokens — themes + consumers override cleanly */
    --{tag}-gap: var(--a-space-4);
  }}
  :scope {{                  /* base — size-agnostic: the CONSUMER owns width/height */
    display: block;
    padding: var(--a-space-4);
  }}
}}
"""


# ---- SSR templates ---------------------------------------------------------

_SSR = {
    "next": {
        "path": "app/providers/adia-provider.tsx",
        "hook": "useEffect",
        "link": "<Link> from next/link + app/**/page.tsx",
        "bind": "a ref + useEffect to set non-string props",
        "body": """'use client';
import { useEffect } from 'react';

// Client-boundary registration. A top-level `import '@adia-ai/web-components'` throws
// `HTMLElement is not defined` during SSR — defer it into useEffect. Mount <AdiaProvider> high.
export function AdiaProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    import('@adia-ai/web-components')
      .then(() => import('@adia-ai/web-modules/shell'))
      .then(() => import('@adia-ai/web-components/css'));
  }, []);
  return <>{children}</>;
}
""",
    },
    "nuxt": {
        "path": "components/AdiaKit.client.vue",
        "hook": "onMounted",
        "link": "<NuxtLink> + pages/**.vue",
        "bind": ":prop= (property binding)",
        "body": """<script setup lang=\"ts\">
import { onMounted } from 'vue';

// .client.vue is Nuxt's SSR boundary; register on the client only.
onMounted(async () => {
  await import('@adia-ai/web-components');
  await import('@adia-ai/web-modules/shell');
  await import('@adia-ai/web-components/css');
});
</script>

<template>
  <slot />
</template>
""",
    },
    "sveltekit": {
        "path": "src/lib/AdiaKit.svelte",
        "hook": "onMount",
        "link": "<a href> + src/routes/**/+page.svelte",
        "bind": "bind:value (two-way)",
        "body": """<script>
  import { onMount } from 'svelte';

  // onMount is SvelteKit's hydration barrier — register on the client only.
  onMount(async () => {
    await import('@adia-ai/web-components');
    await import('@adia-ai/web-modules/shell');
    await import('@adia-ai/web-components/css');
  });
</script>

<slot />
""",
    },
    "astro": {
        "path": "src/components/AdiaKit.astro",
        "hook": "a client <script>",
        "link": "<a href> (or <ViewTransitions/>)",
        "bind": "drop to a framework island for reactive props",
        "body": """---
// CSS is server-safe (no browser APIs); JS registration runs in the browser <script>.
import '@adia-ai/web-components/css';
---
<slot />
<script>
  import '@adia-ai/web-components';
  import '@adia-ai/web-modules/shell';
</script>
""",
    },
}


def _ssr_files(name, framework):
    spec = _SSR[framework]
    title = name.strip() or _slug(name)
    readme = _SSR_README.format(
        title=title, framework=framework, path=spec["path"],
        hook=spec["hook"], link=spec["link"], bind=spec["bind"])
    return {spec["path"]: spec["body"], "README.md": readme}


_SSR_README = """# {title} — adia-ui integration ({framework})

Drop `{path}` into your {framework} app and mount it high (around the layout/root).

Integration checklist (the `host-wiring` skill + its ssr-integration reference own the depth):

1. **Registration is client-only.** This provider defers the kit import into {hook}; never import
   `@adia-ai/web-components` at server module top-level (it throws `HTMLElement is not defined`).
2. **Routing stays the framework's.** Do NOT mount `<router-ui>` — exactly one route owner. Use {link}.
3. **Data:** fetch on the server, pass as initial props, refresh on the client.
4. **State:** cross-cutting state (sidebar, nav, optimistic UI) goes in cookies/session — the shell
   re-mounts per navigation, so component-lifetime signals are lost.
5. **Props:** set non-string props as properties ({bind}), not stringified attributes.
6. **CSS:** import the kit CSS once (server-safe).
"""


# ---- page & component templates --------------------------------------------

_PAGE_HTML = """<!doctype html>
<html lang="en" data-theme="auto">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>{title}</title>
  <!-- Foundation + barrel CSS, and registration: bare @adia-ai/* specifiers resolve
       through Vite's module graph (or an importmap without a bundler) — never a static
       /node_modules/... URL, which 404s silently under Vite's own SPA fallback (see
       spa-architecture.md for the importmap / CDN options). -->
  <script type="module">
    import '@adia-ai/web-components';
    import '@adia-ai/web-components/css';
  </script>
</head>
<body>
  <main id="page-root"><p>Loading…</p></main>
  <script type="module">
    // page-trio loader: fetch the fragment, inject it, then run setup() if a .contents.js exists.
    const root = document.getElementById('page-root');
    root.innerHTML = await (await fetch('./{slug}.contents.html')).text();
{js_import}  </script>
</body>
</html>
"""

_PAGE_CONTENTS = """<col-ui gap="4">
  <text-ui variant="heading">{title}</text-ui>
  <text-ui>Page scaffold. Compose the real content with /screen-composition.</text-ui>
</col-ui>
"""

_PAGE_JS = """export default function setup(root) {
  // Wire behavior here: events, property-API (e.g. el.columns = [...]), data fetch, streaming.
  // Register any custom components this page uses via a side-effect import.
}
"""

_COMPONENT_JS = """import {{ defineIfFree }} from '@adia-ai/web-components/core/register';
import {{ UIElement }} from '@adia-ai/web-components/core/element';

class {cls} extends UIElement {{
  #booted = false;
  connected() {{
    if (this.#booted) return; // the callback re-fires whenever the element moves in the DOM
    this.#booted = true;
    this.innerHTML = `<col-ui gap="2"><text-ui>{tag}</text-ui></col-ui>`;
  }}
}}
defineIfFree('{tag}', {cls});
export {{ {cls} }};
"""


def _page_files(name, duo):
    slug, title = _slug(name), (name.strip() or _slug(name))
    js_import = "" if duo else f"    (await import('./{slug}.contents.js')).default?.(root);\n"
    files = {
        f"{slug}.html": _PAGE_HTML.format(slug=slug, title=title, js_import=js_import),
        f"{slug}.contents.html": _PAGE_CONTENTS.format(title=title),
    }
    if not duo:
        files[f"{slug}.contents.js"] = _PAGE_JS
    return files, slug


def _component_files(name):
    tag = _tag(name)
    cls = _cls(tag)
    return {
        f"components/{tag}/{tag}.js": _COMPONENT_JS.format(tag=tag, cls=cls),
        f"components/{tag}/{tag}.css": _SPA_CSS.format(tag=tag),
    }, tag


# ---- writer ----------------------------------------------------------------

def _write(root, files, force):
    written, skipped = [], []
    for rel, content in files.items():
        dest = os.path.join(root, rel)
        if os.path.exists(dest) and not force:
            skipped.append(rel)
            continue
        os.makedirs(os.path.dirname(dest), exist_ok=True)
        with open(dest, "w", encoding="utf-8") as f:
            f.write(content)
        written.append(rel)
    return written, skipped


def _scaffold(mode, name, framework, outdir, force):
    files = _spa_files(name) if mode == "spa" else _ssr_files(name, framework)
    root = os.path.join(outdir, _slug(name))
    written, skipped = _write(root, files, force)
    return root, written, skipped


def _inventory(app_root):
    """Score an app dir against project-shapes.md's structure rubric.

    Emits the inventory scorecard (gate · pass/fail · cited path) — the
    mechanizable 4 of the rubric's 5 gates. Shape-match ('the layout
    matches one of the three shapes') and duplicated-cross-surface-code
    stay the model's judgment; the scorecard prints them as JUDGMENT rows
    so no report silently omits them. Factory-audit Wave 2 (gh#259):
    the rubric was the skill's done-gate with nothing scoring it.
    """
    rows = []  # (gate, status, evidence)

    # Gate 1 — four-axis present (spec/ + plan/ + app/; skills/ optional)
    missing = [d for d in ("spec", "plan", "app") if not os.path.isdir(os.path.join(app_root, d))]
    rows.append(("four-axis present", "PASS" if not missing else "FAIL",
                 "spec/ plan/ app/ all present" if not missing else f"missing: {', '.join(missing)}/"))

    # Gates 3+4 — walk surfaces under app/
    duo_bad, trio_bad, loose_components = [], [], []
    app_dir = os.path.join(app_root, "app")
    for dirpath, dirnames, filenames in os.walk(app_dir):
        dirnames[:] = [d for d in dirnames if d not in ("node_modules", "dist", ".git")]
        for fn in filenames:
            path = os.path.join(dirpath, fn)
            rel = os.path.relpath(path, app_root)
            if fn.endswith(".contents.js"):
                # trio member: must export setup
                try:
                    src = open(path, encoding="utf-8", errors="ignore").read()
                except OSError:
                    src = ""
                if "export" not in src or "setup" not in src:
                    trio_bad.append(rel + " (no exported setup)")
                html = path[: -len(".contents.js")] + ".html"
                if not os.path.exists(html):
                    duo_bad.append(rel + " (orphan .contents.js — dead-DUO smell)")
            if fn.endswith(".js") and not fn.endswith((".contents.js", ".config.js", ".test.js")):
                parent = os.path.basename(dirpath)
                grandparent = os.path.basename(os.path.dirname(dirpath))
                base = fn[:-3]
                # component form: components/<tag>/<tag>.js
                if grandparent == "components" and parent != base:
                    loose_components.append(rel + f" (dir '{parent}' ≠ tag '{base}')")
                elif parent == "components":
                    loose_components.append(rel + " (bare file directly under components/)")
    rows.append(("page form correct", "PASS" if not (duo_bad or trio_bad) else "FAIL",
                 "; ".join(duo_bad + trio_bad) or "every .contents.js exports setup, none orphaned"))
    rows.append(("components foldered", "PASS" if not loose_components else "FAIL",
                 "; ".join(loose_components) or "every component is components/<tag>/<tag>.js"))

    rows.append(("shape declared & matched", "JUDGMENT",
                 "compare the tree against project-shapes.md's three shape trees — not scriptable"))
    rows.append(("no duplicated cross-surface code", "JUDGMENT",
                 "review app/shared/ vs per-surface copies — not scriptable"))

    width = max(len(r[0]) for r in rows)
    print(f"[inventory] {app_root}")
    fails = 0
    for gate, status, evidence in rows:
        mark = {"PASS": "✓", "FAIL": "✗", "JUDGMENT": "◆"}[status]
        if status == "FAIL":
            fails += 1
        print(f"  {mark} {gate.ljust(width)}  {status:<8} {evidence}")
    print(f"[inventory] {fails} mechanized gate(s) failing; 2 judgment gates remain the model's."
          if fails else "[inventory] mechanized gates clean; 2 judgment gates remain the model's.")
    return 1 if fails else 0


# ---- REQ-04: template↔exports-map drift gate (packed-tarball resolution) --
#
# Resolves every @adia-ai/web-components / @adia-ai/web-modules bare
# specifier the templates emit against the PACKED, extracted artifact —
# not the repo tree (npm pack's `files` filtering can diverge from it) and
# not the public registry (gh#1120's acceptance needs the same-cut
# artifact; lockstep releases make same-cut resolution the binding check).
# `scripts/verify/exports-wildcard-resolution.mjs` (gh#296) is the existing
# precedent for this exact style of live-resolution check; this is its
# manual-walk equivalent in Python, scoped to what the scaffold emits.

_SPECIFIER_RE = re.compile(
    r"""(?:\bimport\s*\(\s*|\bimport\s+|\bfrom\s+)['"](@adia-ai/(?:web-components|web-modules)(?:/[^'"]*)?)['"]"""
)


def _extract_specifiers(text):
    """Every @adia-ai/web-components|web-modules bare-specifier import in a
    generated file's source text (static import, side-effect import, or
    dynamic import() — the three forms the ssr templates use)."""
    return {m.group(1) for m in _SPECIFIER_RE.finditer(text)}


def _emitted_specifiers_by_mode():
    """Every specifier each scaffold mode's templates emit, keyed by a
    label for the FAIL line — spa, all four ssr frameworks, page, and
    component (REQ-04's named blast radius)."""
    by_mode = {}
    by_mode["spa"] = set().union(*[_extract_specifiers(c) for c in _spa_files("Demo App").values()])
    for fw in sorted(_SSR):
        files = _ssr_files(f"demo-{fw}", fw)
        by_mode[f"ssr/{fw}"] = set().union(*[_extract_specifiers(c) for c in files.values()])
    page_files, _ = _page_files("Live View", False)
    by_mode["page"] = set().union(*[_extract_specifiers(c) for c in page_files.values()]) if page_files else set()
    component_files, _ = _component_files("data-badge")
    by_mode["component"] = set().union(*[_extract_specifiers(c) for c in component_files.values()])
    return by_mode


def _split_specifier(spec):
    """'@adia-ai/web-components/core/register' -> ('@adia-ai/web-components', 'core/register').
    Bare '@adia-ai/web-components' -> (pkg, '')."""
    for pkg in ("@adia-ai/web-components", "@adia-ai/web-modules"):
        if spec == pkg:
            return pkg, ""
        if spec.startswith(pkg + "/"):
            return pkg, spec[len(pkg) + 1:]
    return None, None


def _pick_export_target(target):
    """Pick the 'import' condition (falling back to 'default') from an
    exports-map value — either a bare string or a conditions dict."""
    if isinstance(target, str):
        return target
    if isinstance(target, dict):
        for cond in ("import", "default"):
            v = target.get(cond)
            if isinstance(v, str):
                return v
    return None


def _resolve_export(pkg_json, subpath):
    """Manual walk of pkg_json's exports map (exact key, else the single-*
    wildcard key with the longest matching prefix) + wildcard substitution.
    Returns the resolved relative path (still '*'-free), or None if no key
    in the map matches this subpath at all."""
    exports = pkg_json.get("exports")
    if not isinstance(exports, dict):
        return None
    key = "." if subpath == "" else "./" + subpath
    if key in exports:
        return _pick_export_target(exports[key])
    # Node's own exports resolution breaks a same-prefix tie by preferring the
    # pattern with the longer prefix+suffix (i.e. the more specific match) —
    # e.g. './components/*.css' over './components/*' for a '.css' subpath.
    # Comparing prefix length alone (as this used to) picks whichever key
    # iterates first among equal-prefix wildcards, silently mis-resolving any
    # spec that should hit the more specific pattern.
    best = None
    for k, v in exports.items():
        if k.count("*") != 1:
            continue
        prefix, _, suffix = k.partition("*")
        if key.startswith(prefix) and key.endswith(suffix) and len(key) >= len(prefix) + len(suffix):
            specificity = len(prefix) + len(suffix)
            if best is None or specificity > best[0]:
                captured = key[len(prefix):len(key) - len(suffix)] if suffix else key[len(prefix):]
                best = (specificity, captured, v)
    if best is None:
        return None
    _, captured, target = best
    rel = _pick_export_target(target)
    return rel.replace("*", captured) if rel else None


def _pack_and_extract(pkg_dir, tmp):
    """npm pack pkg_dir for real (no network — local tarballing of the
    `files` allowlist) and extract the tarball, so resolution runs against
    what actually SHIPS, not the repo tree."""
    out = subprocess.run(
        ["npm", "pack", "--silent", "--pack-destination", tmp],
        cwd=pkg_dir, capture_output=True, text=True,
    )
    if out.returncode != 0:
        raise RuntimeError(f"npm pack failed in {pkg_dir}: {(out.stderr or out.stdout).strip()}")
    tgz_name = out.stdout.strip().splitlines()[-1]
    extract_dir = os.path.join(tmp, "extracted-" + tgz_name)
    os.makedirs(extract_dir, exist_ok=True)
    with tarfile.open(os.path.join(tmp, tgz_name)) as tf:
        tf.extractall(extract_dir)  # noqa: S202 — trusted, just-packed local tarball
    root = os.path.join(extract_dir, "package")
    with open(os.path.join(root, "package.json"), encoding="utf-8") as f:
        pkg_json = json.load(f)
    return root, pkg_json, tgz_name


def _selftest_exports_resolution():
    """REQ-04 — every specifier the templates emit must resolve, under the
    PACKED artifact's real exports map, to a file that exists in the
    extracted tarball. Packs BOTH @adia-ai/web-components and
    @adia-ai/web-modules at this repo's current lockstep version."""
    script_dir = os.path.dirname(os.path.abspath(__file__))
    repo_root = os.path.abspath(os.path.join(script_dir, "..", "..", "..", ".."))
    pkg_dirs = {
        "@adia-ai/web-components": os.path.join(repo_root, "packages", "web-components"),
        "@adia-ai/web-modules": os.path.join(repo_root, "packages", "web-modules"),
    }
    ok = True
    by_mode = _emitted_specifiers_by_mode()
    with tempfile.TemporaryDirectory() as tmp:
        packed = {}
        for pkg_name, pkg_dir in pkg_dirs.items():
            try:
                packed[pkg_name] = _pack_and_extract(pkg_dir, tmp)
            except Exception as e:
                print(f"selftest: FAIL — could not npm pack {pkg_name}: {e}", file=sys.stderr)
                ok = False
        for mode, specs in by_mode.items():
            for spec in sorted(specs):
                pkg_name, subpath = _split_specifier(spec)
                if pkg_name is None or pkg_name not in packed:
                    continue
                root, pkg_json, tgz_name = packed[pkg_name]
                version = pkg_json.get("version", "?")
                rel = _resolve_export(pkg_json, subpath)
                if rel is None:
                    print(f"selftest: FAIL — {mode} emits '{spec}'; "
                          f"not resolvable in {pkg_name}-{version}.tgz exports", file=sys.stderr)
                    ok = False
                    continue
                if not os.path.exists(os.path.join(root, rel.lstrip("./"))):
                    print(f"selftest: FAIL — {mode} emits '{spec}'; resolves to "
                          f"'{rel}' which does not exist in {pkg_name}-{version}.tgz", file=sys.stderr)
                    ok = False
    return ok


def _selftest_hint_drift():
    """REQ-05 — the /adia-scaffold command doc's argument-hint must equal
    the script's OWN argparse mode choices (via _build_parser(), not a
    third hardcoded copy), or the boot journey's first documented command
    can silently name a mode the script rejects (gh#1121's repro:
    `adia-scaffold app my-app` — 'app' was never a real mode)."""
    _, sub = _build_parser()
    choices = set(sub.choices.keys())
    script_dir = os.path.dirname(os.path.abspath(__file__))
    doc_path = os.path.join(script_dir, "..", "commands", "project-scaffolding.md")
    try:
        with open(doc_path, encoding="utf-8") as f:
            text = f.read()
    except OSError as e:
        print(f"selftest: FAIL — cannot read {doc_path}: {e}", file=sys.stderr)
        return False
    m = re.search(r'argument-hint:\s*"(\[[^\]]*\])', text)
    if not m:
        print(f"selftest: FAIL — {doc_path} has no argument-hint mode-bracket", file=sys.stderr)
        return False
    hinted = set(m.group(1).strip("[]").split("|"))
    if hinted != choices:
        print(f"selftest: FAIL — argument-hint modes {sorted(hinted)} != "
              f"argparse choices {sorted(choices)} ({doc_path})", file=sys.stderr)
        return False
    return True


def _selftest():
    ok = True
    with tempfile.TemporaryDirectory() as tmp:
        _, w, _ = _scaffold("spa", "Demo App", None, tmp, False)
        need = [f for f in w if f.endswith(("index.html", "demo-app.js", "demo-app.css"))]
        missing = [n for n in ("index.html", "vite.config.js", "package.json") if not any(f.endswith(n) for f in w)]
        if missing or len(w) < 8:
            print(f"selftest: SPA scaffold incomplete (missing: {missing}, got {len(w)} files)", file=sys.stderr); ok = False
        for fw in ("next", "nuxt", "sveltekit", "astro"):
            _, w2, _ = _scaffold("ssr", f"demo-{fw}", fw, tmp, False)
            if not any("README.md" == f for f in w2) or len(w2) != 2:
                print(f"selftest: SSR/{fw} scaffold incomplete: {w2}", file=sys.stderr); ok = False
        pf, _ = _page_files("Live View", False)
        wpt, _ = _write(os.path.join(tmp, "pg"), pf, False)
        if not (any(f.endswith("live-view.html") for f in wpt) and any(f.endswith("live-view.contents.js") for f in wpt)):
            print("selftest: page-trio incomplete", file=sys.stderr); ok = False
        pfd, _ = _page_files("Static Note", True)
        wpd, _ = _write(os.path.join(tmp, "pgd"), pfd, False)
        if any(f.endswith(".contents.js") for f in wpd) or len(wpd) != 2:
            print("selftest: page-DUO should have no .contents.js", file=sys.stderr); ok = False
        cf, _ = _component_files("data-badge")
        wc, _ = _write(os.path.join(tmp, "cmp"), cf, False)
        if not any(f.endswith(os.path.join("data-badge", "data-badge.js")) for f in wc):
            print("selftest: component incomplete", file=sys.stderr); ok = False
        # inventory: a fresh scaffold's mechanized gates must pass; a broken
        # tree (bare component file, setup-less .contents.js) must fail.
        root, _, _ = _scaffold("spa", "Inv App", None, os.path.join(tmp, "inv"), False)
        import contextlib, io
        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc_good = _inventory(root)
        if rc_good != 0:
            print(f"selftest: inventory flagged a fresh scaffold:\n{buf.getvalue()}", file=sys.stderr); ok = False
        bad = os.path.join(tmp, "invbad")
        os.makedirs(os.path.join(bad, "app", "components"), exist_ok=True)
        open(os.path.join(bad, "app", "components", "loose.js"), "w").write("// bare\n")
        open(os.path.join(bad, "app", "broken.contents.js"), "w").write("// no setup here\n")
        with contextlib.redirect_stdout(io.StringIO()):
            rc_bad = _inventory(bad)
        if rc_bad == 0:
            print("selftest: inventory passed a broken tree", file=sys.stderr); ok = False
    # REQ-05 — command-doc argument-hint must equal the real argparse modes.
    if not _selftest_hint_drift():
        ok = False
    # REQ-04 — every emitted specifier must resolve against BOTH packed,
    # extracted tarballs' real exports maps (gh#1120's blast radius).
    if not _selftest_exports_resolution():
        ok = False
    print("selftest: PASS" if ok else "selftest: FAIL")
    return 0 if ok else 1


def _build_parser():
    """The one parser both main() and the selftest's hint-drift check
    (REQ-05) use — `sub.choices` is the single source of the real mode
    list, so the drift check can never itself drift from what main()
    actually accepts."""
    p = argparse.ArgumentParser(prog="adia-scaffold", add_help=True,
                                description="Lay the minimal bones of an adia-ui app.")
    sub = p.add_subparsers(dest="mode", required=True)
    for m in ("spa", "ssr"):
        sp = sub.add_parser(m)
        sp.add_argument("name")
        sp.add_argument("-o", "--out", default=".")
        sp.add_argument("--force", action="store_true")
        if m == "ssr":
            sp.add_argument("--framework", required=True,
                            choices=sorted(_SSR.keys()))
    pg = sub.add_parser("page")
    pg.add_argument("name")
    pg.add_argument("-o", "--out", default=".")
    pg.add_argument("--duo", action="store_true")
    pg.add_argument("--force", action="store_true")
    cm = sub.add_parser("component")
    cm.add_argument("name")
    cm.add_argument("-o", "--out", default=".")
    cm.add_argument("--force", action="store_true")
    inv = sub.add_parser("inventory", help="score an app dir against the structure rubric")
    inv.add_argument("app_root", nargs="?", default=".")
    sub.add_parser("selftest")
    return p, sub


def main(argv):
    p, _sub = _build_parser()
    args = p.parse_args(argv)

    if args.mode == "selftest":
        return _selftest()

    if args.mode == "inventory":
        return _inventory(args.app_root)

    if args.mode == "page":
        files, _ = _page_files(args.name, args.duo)
        root, label = args.out, ("PAGE/DUO" if args.duo else "PAGE")
        written, skipped = _write(root, files, args.force)
    elif args.mode == "component":
        files, _ = _component_files(args.name)
        root, label = args.out, "COMPONENT"
        written, skipped = _write(root, files, args.force)
    else:
        framework = getattr(args, "framework", None)
        root, written, skipped = _scaffold(args.mode, args.name, framework, args.out, args.force)
        label = args.mode.upper() + (f"/{framework}" if framework else "")
    print(f"adia-scaffold [{label}] → {root}")
    for f in written:
        print(f"  + {f}")
    for f in skipped:
        print(f"  · {f} (exists; --force to overwrite)")
    if not written:
        print("  (nothing written)")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
