"""Build and publish gold starter-v2 trees for catalog templates.

Never writes starter or main. development + starter-v2 only.
Aura/ecommerce are catalog lookbooks, not checkout.
"""
from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
NUXT_BOILER = ROOT / "boilerplates" / "nuxt_app"
REACT_BOILER = ROOT / "boilerplates" / "react_app"
WORK = Path(r"E:\workroom\2026\_gold_templates")
SKIP = {"p2hs-template-accounting-nuxt"}

TEMPLATES = [
    ("p2hs-template-accounting-react", "react_app", "accounting-react", "books"),
    ("p2hs-template-accounting-react1", "react_app", "accounting-react1", "books"),
    ("p2hs-template-animation-services-nuxt", "nuxt_app", "animation-services-nuxt", "site"),
    ("p2hs-template-animation-services-react", "react_app", "animation-services-react", "site"),
    ("p2hs-template-aquafix-flow-nuxt", "nuxt_app", "aquafix-flow-nuxt", "site"),
    ("p2hs-template-aquafix-flow-react", "react_app", "aquafix-flow-react", "site"),
    ("p2hs-template-aura-atelier", "nuxt_app", "aura-atelier", "lookbook"),
    ("p2hs-template-budget-expense-tracker", "nuxt_app", "budget-expense-tracker", "site"),
    ("p2hs-template-budget-expense-tracker-nuxt", "nuxt_app", "budget-expense-tracker-nuxt", "site"),
    ("p2hs-template-budget-expense-tracker-react", "react_app", "budget-expense-tracker-react", "site"),
    ("p2hs-template-business-profile-react", "react_app", "business-profile-react", "site"),
    ("p2hs-template-busniess-profile-nuxt", "nuxt_app", "busniess-profile-nuxt", "site"),
    ("p2hs-template-busniess-react", "react_app", "busniess-react", "site"),
    ("p2hs-template-customer-account-health-nuxt", "nuxt_app", "customer-account-health-nuxt", "site"),
    ("p2hs-template-customer-account-health-react", "react_app", "customer-account-health-react", "site"),
    ("p2hs-template-doctors-app-1", "nuxt_app", "doctors-app-1", "clinic"),
    ("p2hs-template-doctors-app-react", "react_app", "doctors-app-react", "clinic"),
    ("p2hs-template-ecommerce-website", "react_app", "ecommerce-website", "lookbook"),
    ("p2hs-template-pie-payoff-planner", "react_app", "pie-payoff-planner", "site"),
    ("p2hs-template-pie-payoff-planner-nuxt", "nuxt_app", "pie-payoff-planner-nuxt", "site"),
    ("p2hs-template-pie-voice", "react_app", "pie-voice", "site"),
    ("p2hs-template-pie-voice-nuxt", "nuxt_app", "pie-voice-nuxt", "site"),
    ("p2hs-template-project-knowledge-base-nuxt", "nuxt_app", "project-knowledge-base-nuxt", "knowledge"),
    ("p2hs-template-project-knowledge-base-react", "react_app", "project-knowledge-base-react", "knowledge"),
    ("p2hs-template-revio-landing-page-nuxt", "nuxt_app", "revio-landing-page-nuxt", "site"),
    ("p2hs-template-revio-landing-page-react", "react_app", "revio-landing-page-react", "site"),
    ("p2hs-template-royal-construction-nuxt", "nuxt_app", "royal-construction-nuxt", "site"),
    ("p2hs-template-royal-construction-react", "react_app", "royal-construction-react", "site"),
]

GITIGNORE = """\
.output
.amplify-hosting
.data
.nuxt
.nitro
.cache
dist
node_modules
logs
*.log
.DS_Store
.fleet
.idea
.cursor
.cursorrules
.P2HS
.env
.env.*
!.env.example
.dev.vars
.p2hs.json
.p2hs/
.p2hs-control/
CLAUDE.md
AGENTS.md
p2hs.platform.json
*.p2hs-local.bak
.p2hs/local-bak/
.history
"""

CLOUD_ACCESS = """\
# Cloud access

The browser calls relative `/api` only. Server-side BFF adapters bind
`CLOUD_GATEWAY_URL` and `CLOUD_SERVICE_TOKEN`. Do not add public gateway or
Core URLs to client env.
"""

LEAKED = (
    "NUXT_PUBLIC_P2HS_GATEWAY_URL",
    "VITE_P2HS_GATEWAY_URL",
    "NUXT_PUBLIC_P2HS_CLOUD_PUBLIC_URL",
    "VITE_P2HS_CLOUD_PUBLIC_URL",
    "NUXT_PUBLIC_P2HS_CORE_PUBLIC_URL",
    "VITE_P2HS_CORE_PUBLIC_URL",
    "gatewayPublicUrl",
    "cloudgate.p2hs.com",
)

PROFILES = {
    "books": {
        "title": "Practice books",
        "blurb": "Customers, invoice records, and saved reports. Not a general ledger.",
        "starter_id": "internal_operations",
        "caps": ["crm.customer", "billing.invoice", "reporting.dashboard"],
        "packs": ["customer-records", "billing-management", "reporting-dashboards"],
        "keep_blocks": {
            "admin.shell", "admin.record_table", "admin.record_detail",
            "crm.customer", "billing.invoice", "reporting.dashboard",
            "files.attachments", "auth.sign_in",
        },
        "nuxt_hosts": {
            "AdminRecordDetail.vue", "AdminRecordTable.vue",
            "CustomerCreate.vue", "CustomerDetail.vue", "CustomerEdit.vue", "CustomerTable.vue",
            "InvoiceCreate.vue", "InvoiceDetail.vue", "InvoiceEdit.vue", "InvoiceTable.vue",
            "ReportCreate.vue", "ReportDetail.vue", "ReportEdit.vue", "ReportTable.vue",
        },
        "gaps": [("unsupported", "full_accounting_gl", "Not a general ledger. Do not invent checkout.")],
        "assumptions": ["Not a general ledger", "Not Stripe checkout"],
        "nav": [("Overview", "/admin"), ("Customers", "/customers"), ("Invoices", "/invoices"), ("Reports", "/reports")],
    },
    "clinic": {
        "title": "Clinic desk",
        "blurb": "Appointment reserve plus customer records. Not an EHR and not checkout.",
        "starter_id": "booking",
        "caps": ["booking.appointment", "crm.customer"],
        "packs": ["appointment-booking", "customer-records"],
        "keep_blocks": {
            "admin.shell", "admin.record_table", "admin.record_detail",
            "booking.appointment", "crm.customer", "auth.sign_in", "files.attachments",
        },
        "nuxt_hosts": {
            "AdminRecordDetail.vue", "AdminRecordTable.vue", "BookingForm.vue",
            "CustomerCreate.vue", "CustomerDetail.vue", "CustomerEdit.vue", "CustomerTable.vue",
            "SuccessState.vue",
        },
        "gaps": [("unsupported", "ehr", "Not an EHR or clinical chart.")],
        "assumptions": ["Not an EHR", "Not checkout"],
        "nav": [("Overview", "/admin"), ("Book", "/"), ("Customers", "/customers")],
    },
    "knowledge": {
        "title": "Knowledge base",
        "blurb": "Staff documents on Cloud records. Not a second wiki database.",
        "starter_id": "document_heavy",
        "caps": ["documents", "files.attachments"],
        "packs": ["documents-lifecycle", "file-attachments"],
        "keep_blocks": {
            "admin.shell", "admin.record_table", "admin.record_detail",
            "documents", "files.attachments", "auth.sign_in",
        },
        "nuxt_hosts": {
            "AdminRecordDetail.vue", "AdminRecordTable.vue",
            "DocumentsCreate.vue", "DocumentsDetail.vue", "DocumentsEdit.vue", "DocumentsTable.vue",
        },
        "gaps": [],
        "assumptions": ["Documents are Cloud records, not a private wiki"],
        "nav": [("Overview", "/admin"), ("Documents", "/documents")],
    },
    "site": {
        "title": "Site",
        "blurb": "Public contact form on relative /api. This is a lookbook seed, not a restamped marketing site.",
        "starter_id": "internal_operations",
        "caps": ["forms.contact"],
        "packs": ["contact-capture"],
        "keep_blocks": {
            "admin.shell", "admin.record_table", "admin.record_detail",
            "forms.contact", "auth.sign_in",
        },
        "nuxt_hosts": {
            "AdminRecordDetail.vue", "AdminRecordTable.vue", "ContactPublicForm.vue",
        },
        "gaps": [],
        "assumptions": ["Lookbook seed, not the live marketing demo"],
        "nav": [("Home", "/"), ("Inbox", "/admin")],
    },
    "lookbook": {
        "title": "Catalog lookbook",
        "blurb": "Catalog records and a contact form. Not checkout, cart, or payments.",
        "starter_id": "catalog_orders",
        "caps": ["catalog.product", "forms.contact"],
        "packs": ["product-catalog", "contact-capture"],
        "keep_blocks": {
            "admin.shell", "admin.record_table", "admin.record_detail",
            "catalog.product", "forms.contact", "auth.sign_in",
        },
        "nuxt_hosts": {
            "AdminRecordDetail.vue", "AdminRecordTable.vue",
            "CatalogCreate.vue", "CatalogDetail.vue", "CatalogEdit.vue", "CatalogTable.vue",
            "ContactPublicForm.vue",
        },
        "gaps": [("unsupported", "payments.checkout", "Catalog lookbook must not become checkout.")],
        "assumptions": ["Not checkout", "Not Stripe", "Not cart"],
        "nav": [("Lookbook", "/"), ("Catalog", "/catalog"), ("Contact", "/contact"), ("Admin", "/admin")],
    },
}


def _write(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8", newline="\n")


def _copy_file(src: Path, dest: Path) -> None:
    dest.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(src, dest)


def _clear_dest(dest: Path) -> None:
    dest.mkdir(parents=True, exist_ok=True)
    for child in dest.iterdir():
        if child.name == ".git":
            continue
        if child.is_dir():
            shutil.rmtree(child)
        else:
            child.unlink()


def _pack(slug: str) -> dict:
    return {"kind": "feature_pack", "id": slug, "min_version": None}


def _prim(slug: str) -> dict:
    return {"kind": "primitive", "id": slug, "min_version": None}


def _app(slug: str) -> dict:
    return {"kind": "app_block", "id": slug, "min_version": None}


def _plan(profile_id: str, title: str) -> dict:
    spec = PROFILES[profile_id]
    blocks = [
        {
            "id": "admin.shell",
            "version": "1.0.0",
            "reason": f"Staff chrome for {title}.",
            "slots": {"route": "/admin", "title": spec["title"]},
            "dependencies": [_prim("auth.roles")],
            "protected_bindings": ["admin_auth", "admin_bff"],
        }
    ]
    extra = {
        "crm.customer": (
            "Client records. Not a pipeline.",
            "/customers",
            ["customer-records"],
        ),
        "billing.invoice": (
            "Invoice records. paid is status, not a payment.",
            "/invoices",
            ["billing-management"],
        ),
        "reporting.dashboard": (
            "Saved report definitions over Cloud records.",
            "/reports",
            ["reporting-dashboards"],
        ),
        "booking.appointment": (
            "Public reserve form. Not an EHR.",
            "/",
            ["appointment-booking"],
        ),
        "documents": (
            "Staff documents as Cloud records.",
            "/documents",
            ["documents-lifecycle"],
        ),
        "files.attachments": (
            "File attachments on records.",
            "/documents",
            ["file-attachments"],
        ),
        "forms.contact": (
            "Public contact capture.",
            "/",
            ["contact-capture"],
        ),
        "catalog.product": (
            "Catalog records. Not checkout.",
            "/catalog",
            ["product-catalog"],
        ),
        "admin.record_table": ("Shared listing host.", "/admin", []),
        "admin.record_detail": ("Shared detail host.", "/admin", []),
        "auth.sign_in": ("Staff sign-in host.", "/admin", []),
    }
    for block_id in spec["keep_blocks"]:
        if block_id == "admin.shell":
            continue
        reason, route, packs = extra.get(block_id, (block_id, "/admin", []))
        deps = [_prim("data.entity")] if block_id not in {"forms.contact", "booking.appointment", "auth.sign_in"} else []
        deps.extend(_pack(p) for p in packs)
        if block_id not in {"admin.record_table", "admin.record_detail", "auth.sign_in"}:
            deps.extend((_app("admin.shell"), _app("admin.record_table"), _app("admin.record_detail")))
        blocks.append(
            {
                "id": block_id,
                "version": "1.0.0",
                "reason": reason,
                "slots": {"route": route, "heading": spec["title"]},
                "dependencies": deps,
                "protected_bindings": ["persistence", "allowed_fields"] if block_id not in {"auth.sign_in", "forms.contact", "booking.appointment"} else [],
            }
        )
    gaps = [
        {
            "type": kind,
            "id": gid,
            "severity": "optional",
            "affects": [],
            "message": msg,
        }
        for kind, gid, msg in spec["gaps"]
    ]
    return {
        "schema_version": "1",
        "requested_capabilities": spec["caps"],
        "blocks": blocks,
        "required_feature_packs": spec["packs"],
        "required_primitives": ["data.entity", "auth.roles"],
        "configuration_requirements": [],
        "gaps": gaps,
        "runtime_dependencies": [],
        "starter_contracts": ["form.standard", "record.table", "layout.admin", "state.empty"],
        "adoption": "authoritative",
        "matched_recipe_id": None,
        "starter_id": spec["starter_id"],
        "feature_pack_ids": spec["packs"],
        "forbidden_capabilities": ["payments.checkout"],
        "product_archetype": spec["starter_id"],
        "unsupported_assumptions": spec["assumptions"],
    }


def _filter_hosts(src_path: Path, keep: set[str]) -> dict:
    src = json.loads(src_path.read_text(encoding="utf-8"))
    return {"version": src.get("version", 1), "hosts": [row for row in src["hosts"] if row.get("block") in keep]}


def _contracts(src_path: Path) -> dict:
    src = json.loads(src_path.read_text(encoding="utf-8"))
    return src


def _assert_clean(dest: Path) -> None:
    for path in dest.rglob("*"):
        if not path.is_file() or any(part in {".git", "node_modules"} for part in path.parts):
            continue
        if path.suffix.lower() in {".png", ".jpg", ".woff", ".woff2", ".gz"}:
            continue
        text = path.read_text(encoding="utf-8", errors="ignore")
        for marker in LEAKED:
            if marker in text:
                raise RuntimeError(f"{dest.name} leaked {marker} in {path.relative_to(dest)}")


def _nuxt_crud(block: str, folder: str, title: str, prefix: str, blurb: str) -> dict[str, str]:
    table = f"{prefix}Table"
    create = f"{prefix}Create"
    detail = f"{prefix}Detail"
    edit = f"{prefix}Edit"
    imp = f"~/components/starter/blocks/{table}.vue"
    return {
        f"pages/{folder}/index.vue": f"""<script setup>
import {table} from "{imp}";
function openRow(row) {{
  if (row?.id) navigateTo(`/{folder}/${{row.id}}`);
}}
</script>
<template>
  <section data-app-block="{block}" data-route="/{folder}">
    <StarterPageHeader title="{title}" description="{blurb}">
      <template #actions>
        <NuxtLink to="/{folder}/new">New</NuxtLink>
      </template>
    </StarterPageHeader>
    <{table} @select="openRow" />
  </section>
</template>
""",
        f"pages/{folder}/new.vue": f"""<script setup>
import {create} from "~/components/starter/blocks/{create}.vue";
function onCreated(record) {{
  if (record?.id) navigateTo(`/{folder}/${{record.id}}`);
  else navigateTo("/{folder}");
}}
</script>
<template>
  <section data-app-block="{block}">
    <StarterPageHeader title="New {title.lower()}" />
    <{create} @created="onCreated" />
  </section>
</template>
""",
        f"pages/{folder}/[id]/index.vue": f"""<script setup>
import {detail} from "~/components/starter/blocks/{detail}.vue";
const route = useRoute();
</script>
<template>
  <section data-app-block="{block}">
    <StarterPageHeader title="{title}">
      <template #actions>
        <NuxtLink :to="`/{folder}/${{route.params.id}}/edit`">Edit</NuxtLink>
      </template>
    </StarterPageHeader>
    <{detail} :record-id="String(route.params.id)" />
  </section>
</template>
""",
        f"pages/{folder}/[id]/edit.vue": f"""<script setup>
import {edit} from "~/components/starter/blocks/{edit}.vue";
const route = useRoute();
function onUpdated() {{
  navigateTo(`/{folder}/${{route.params.id}}`);
}}
</script>
<template>
  <section data-app-block="{block}">
    <StarterPageHeader title="Edit {title.lower()}" />
    <{edit} :record-id="String(route.params.id)" @updated="onUpdated" />
  </section>
</template>
""",
    }


def _nuxt_layout(nav: list[tuple[str, str]], title: str) -> str:
    links = "\n      ".join(f'<NuxtLink to="{href}">{label}</NuxtLink>' for label, href in nav)
    return f"""<template>
  <StarterAdminLayout title="{title}">
    <template #nav>
      {links}
    </template>
    <slot />
  </StarterAdminLayout>
</template>
"""


def _nuxt_pages(profile_id: str, spec: dict) -> dict[str, str]:
    files = {
        "app.vue": "<template>\n  <NuxtLayout>\n    <NuxtPage />\n  </NuxtLayout>\n</template>\n",
        "layouts/default.vue": _nuxt_layout(spec["nav"], spec["title"]),
        "pages/admin/index.vue": f"""<template>
  <section data-app-block="admin.shell">
    <StarterPageHeader title="{spec['title']}" description="{spec['blurb']}" />
  </section>
</template>
""",
    }
    if profile_id == "books":
        files.update(_nuxt_crud("crm.customer", "customers", "Customers", "Customer", "Standing client records."))
        files.update(_nuxt_crud("billing.invoice", "invoices", "Invoices", "Invoice", "paid is status, not a payment."))
        files.update(_nuxt_crud("reporting.dashboard", "reports", "Reports", "Report", "Saved report definitions."))
        files["pages/index.vue"] = '<script setup>\nawait navigateTo("/admin");\n</script>\n'
    elif profile_id == "clinic":
        files.update(_nuxt_crud("crm.customer", "customers", "Customers", "Customer", "Client records, not a chart."))
        files["pages/index.vue"] = """<script setup>
import BookingForm from "~/components/starter/blocks/BookingForm.vue";
</script>
<template>
  <section data-app-block="booking.appointment">
    <StarterPageHeader title="Book" description="Reserve a slot. Not an EHR." />
    <BookingForm />
  </section>
</template>
"""
    elif profile_id == "knowledge":
        files.update(_nuxt_crud("documents", "documents", "Documents", "Documents", "Cloud document records."))
        files["pages/index.vue"] = '<script setup>\nawait navigateTo("/documents");\n</script>\n'
    elif profile_id == "site":
        files["pages/index.vue"] = """<script setup>
import ContactPublicForm from "~/components/starter/blocks/ContactPublicForm.vue";
</script>
<template>
  <section data-app-block="forms.contact">
    <ContactPublicForm heading="Contact" />
  </section>
</template>
"""
    else:
        files.update(_nuxt_crud("catalog.product", "catalog", "Catalog", "Catalog", "Catalog records. Not checkout."))
        files["pages/index.vue"] = '<script setup>\nawait navigateTo("/catalog");\n</script>\n'
        files["pages/contact.vue"] = """<script setup>
import ContactPublicForm from "~/components/starter/blocks/ContactPublicForm.vue";
</script>
<template>
  <section data-app-block="forms.contact">
    <ContactPublicForm heading="Contact" />
  </section>
</template>
"""
    return files


def assemble_nuxt(dest: Path, ref: str, profile_id: str) -> None:
    spec = PROFILES[profile_id]
    _copy_file(NUXT_BOILER / "package.json", dest / "package.json")
    _copy_file(NUXT_BOILER / "nuxt.config.ts", dest / "nuxt.config.ts")
    nuxt_cfg = (dest / "nuxt.config.ts").read_text(encoding="utf-8")
    if "failOnError" not in nuxt_cfg:
        nuxt_cfg = nuxt_cfg.replace(
            "nitro: {\n    preset: process.env.NITRO_PRESET,\n    // Required for Cloudflare Workers runtime\n    cloudflareModuleWorker: true,\n  },",
            "nitro: {\n    preset: process.env.NITRO_PRESET,\n    cloudflareModuleWorker: true,\n    prerender: { failOnError: false },\n  },",
        )
        _write(dest / "nuxt.config.ts", nuxt_cfg)
    if (NUXT_BOILER / "assets").is_dir():
        shutil.copytree(NUXT_BOILER / "assets", dest / "assets", dirs_exist_ok=True)
    shutil.copytree(NUXT_BOILER / "components" / "starter", dest / "components" / "starter", dirs_exist_ok=True)
    blocks = dest / "components" / "starter" / "blocks"
    for path in list(blocks.iterdir()):
        if path.name not in spec["nuxt_hosts"] and path.suffix == ".vue":
            path.unlink()
    (dest / "lib" / "entity-schemas").mkdir(parents=True, exist_ok=True)
    _copy_file(NUXT_BOILER / "lib" / "record-form.mjs", dest / "lib" / "record-form.mjs")
    _copy_file(NUXT_BOILER / "lib" / "p2hs-app.js", dest / "lib" / "p2hs-app.js")
    for schema in (NUXT_BOILER / "lib" / "entity-schemas").glob("*.json"):
        _copy_file(schema, dest / "lib" / "entity-schemas" / schema.name)
    _copy_file(NUXT_BOILER / "composables" / "useP2hsApi.ts", dest / "composables" / "useP2hsApi.ts")
    _copy_file(NUXT_BOILER / "composables" / "useP2hs.ts", dest / "composables" / "useP2hs.ts")
    _write(dest / ".gitignore", GITIGNORE)
    _write(dest / ".env.example", "# Browser-safe IDs only. No public Cloud or Core URL.\n")
    _write(dest / "README.md", _readme(ref, profile_id))
    _write(dest / "p2hs" / "cloud-access.md", CLOUD_ACCESS)
    _write(dest / "p2hs" / "app-blocks-plan.json", json.dumps(_plan(profile_id, spec["title"]), indent=2) + "\n")
    _write(dest / "p2hs" / "app-block-hosts.json", json.dumps(_filter_hosts(NUXT_BOILER / "p2hs" / "app-block-hosts.json", spec["keep_blocks"]), indent=2) + "\n")
    _write(dest / "p2hs" / "starter-contracts.json", json.dumps(_contracts(NUXT_BOILER / "p2hs" / "starter-contracts.json"), indent=2) + "\n")
    for relative, content in _nuxt_pages(profile_id, spec).items():
        _write(dest / relative, content)
    _finish(dest, "nuxt_app", ref)


def _readme(ref: str, profile_id: str) -> str:
    spec = PROFILES[profile_id]
    return (
        f"# {spec['title']} gold seed (`{ref}`)\n\n"
        f"{spec['blurb']}\n\n"
        "- `development` — Cloudflare Pages test\n"
        "- `starter-v2` — clone candidate\n"
        "- `starter` / `main` — unchanged until this deploy is proven\n\n"
        "Browser source never embeds a Cloud or Core origin. `payments.checkout` is forbidden.\n"
    )


def _react_app_tsx(profile_id: str, spec: dict) -> str:
    nav = spec["nav"]
    links = "\n        ".join(f'<Link to="{href}">{label}</Link>' for label, href in nav)
    routes = [
        'import { Link, Route, Routes, useNavigate, useParams } from "react-router";',
        'import { AdminLayout, PageHeader } from "./components/starter/library";',
    ]
    if profile_id in {"books", "clinic"}:
        routes.append('import { CustomerTable, CustomerCreate, CustomerDetail, CustomerEdit } from "./components/starter/crm-blocks";')
    if profile_id == "books":
        routes.append('import { InvoiceTable, InvoiceCreate, InvoiceDetail, InvoiceEdit } from "./components/starter/invoice-blocks";')
        routes.append('import { ReportTable, ReportCreate, ReportDetail, ReportEdit } from "./components/starter/report-blocks";')
    if profile_id == "clinic":
        routes.append('import { BookingForm } from "./components/starter/booking-blocks";')
    if profile_id == "knowledge":
        routes.append('import { DocumentsTable, DocumentsCreate, DocumentsDetail, DocumentsEdit } from "./components/starter/blocks";')
    if profile_id == "site":
        routes.append('import { ContactPublicForm } from "./components/starter/blocks";')
    if profile_id == "lookbook":
        routes.append('import { CatalogTable, CatalogCreate, CatalogDetail, CatalogEdit } from "./components/starter/catalog-blocks";')
        routes.append('import { ContactPublicForm } from "./components/starter/blocks";')

    body = f"""
function Shell({{ children }}) {{
  return (
    <AdminLayout title="{spec['title']}" nav={{(
      <>
        {links}
      </>
    )}}>
      {{children}}
    </AdminLayout>
  );
}}

function Overview() {{
  return (
    <section>
      <PageHeader title="{spec['title']}" description="{spec['blurb']}" />
    </section>
  );
}}
"""
    extras = ""
    if profile_id == "books":
        extras = _react_crud_family("customers", "Customer") + _react_crud_family("invoices", "Invoice") + _react_crud_family("reports", "Report")
        route_jsx = """
        <Route path="/" element={<Overview />} />
        <Route path="/admin" element={<Overview />} />
        <Route path="/customers" element={<CustomersIndex />} />
        <Route path="/customers/new" element={<CustomersNew />} />
        <Route path="/customers/:id" element={<CustomersShow />} />
        <Route path="/customers/:id/edit" element={<CustomersEdit />} />
        <Route path="/invoices" element={<InvoicesIndex />} />
        <Route path="/invoices/new" element={<InvoicesNew />} />
        <Route path="/invoices/:id" element={<InvoicesShow />} />
        <Route path="/invoices/:id/edit" element={<InvoicesEdit />} />
        <Route path="/reports" element={<ReportsIndex />} />
        <Route path="/reports/new" element={<ReportsNew />} />
        <Route path="/reports/:id" element={<ReportsShow />} />
        <Route path="/reports/:id/edit" element={<ReportsEdit />} />
"""
    elif profile_id == "clinic":
        extras = _react_crud_family("customers", "Customer") + """
function Book() {
  return (
    <section>
      <PageHeader title="Book" description="Reserve a slot. Not an EHR." />
      <BookingForm />
    </section>
  );
}
"""
        route_jsx = """
        <Route path="/" element={<Book />} />
        <Route path="/admin" element={<Overview />} />
        <Route path="/customers" element={<CustomersIndex />} />
        <Route path="/customers/new" element={<CustomersNew />} />
        <Route path="/customers/:id" element={<CustomersShow />} />
        <Route path="/customers/:id/edit" element={<CustomersEdit />} />
"""
    elif profile_id == "knowledge":
        extras = _react_crud_family("documents", "Documents")
        route_jsx = """
        <Route path="/" element={<DocumentsIndex />} />
        <Route path="/admin" element={<Overview />} />
        <Route path="/documents" element={<DocumentsIndex />} />
        <Route path="/documents/new" element={<DocumentsNew />} />
        <Route path="/documents/:id" element={<DocumentsShow />} />
        <Route path="/documents/:id/edit" element={<DocumentsEditPage />} />
"""
    elif profile_id == "site":
        extras = """
function Home() {
  return (
    <section>
      <ContactPublicForm heading="Contact" />
    </section>
  );
}
"""
        route_jsx = """
        <Route path="/" element={<Home />} />
        <Route path="/admin" element={<Overview />} />
"""
    else:
        extras = _react_crud_family("catalog", "Catalog") + """
function Contact() {
  return (
    <section>
      <ContactPublicForm heading="Contact" />
    </section>
  );
}
"""
        route_jsx = """
        <Route path="/" element={<CatalogIndex />} />
        <Route path="/admin" element={<Overview />} />
        <Route path="/catalog" element={<CatalogIndex />} />
        <Route path="/catalog/new" element={<CatalogNew />} />
        <Route path="/catalog/:id" element={<CatalogShow />} />
        <Route path="/catalog/:id/edit" element={<CatalogEditPage />} />
        <Route path="/contact" element={<Contact />} />
"""
    return "\n".join(routes) + "\n" + body + extras + f"""
export default function App() {{
  return (
    <Shell>
      <Routes>
{route_jsx}
      </Routes>
    </Shell>
  );
}}
"""


def _react_crud_family(folder: str, prefix: str) -> str:
    table = f"{prefix}Table"
    create = f"{prefix}Create"
    detail = f"{prefix}Detail"
    edit = f"{prefix}Edit"
    index = f"{prefix}Index" if prefix != "Documents" else "DocumentsIndex"
    new = f"{prefix}New" if prefix != "Documents" else "DocumentsNew"
    show = f"{prefix}Show" if prefix != "Documents" else "DocumentsShow"
    edit_page = f"{prefix}EditPage" if prefix not in {"Documents", "Catalog"} else f"{prefix}EditPage"
    if prefix == "Customer":
        index, new, show, edit_page = "CustomersIndex", "CustomersNew", "CustomersShow", "CustomersEdit"
    elif prefix == "Invoice":
        index, new, show, edit_page = "InvoicesIndex", "InvoicesNew", "InvoicesShow", "InvoicesEdit"
    elif prefix == "Report":
        index, new, show, edit_page = "ReportsIndex", "ReportsNew", "ReportsShow", "ReportsEdit"
    elif prefix == "Catalog":
        index, new, show, edit_page = "CatalogIndex", "CatalogNew", "CatalogShow", "CatalogEditPage"
    return f"""
function {index}() {{
  const navigate = useNavigate();
  return (
    <section>
      <PageHeader title="{prefix}" actions={{<Link to="/{folder}/new">New</Link>}} />
      <{table} onSelect={{(row) => row?.id && navigate(`/{folder}/${{row.id}}`)}} />
    </section>
  );
}}
function {new}() {{
  const navigate = useNavigate();
  return (
    <section>
      <PageHeader title="New" />
      <{create} onCreated={{(row) => navigate(row?.id ? `/{folder}/${{row.id}}` : "/{folder}")}} />
    </section>
  );
}}
function {show}() {{
  const {{ id }} = useParams();
  return (
    <section>
      <PageHeader title="{prefix}" actions={{<Link to={{`/{folder}/${{id}}/edit`}}>Edit</Link>}} />
      <{detail} recordId={{id}} />
    </section>
  );
}}
function {edit_page}() {{
  const {{ id }} = useParams();
  const navigate = useNavigate();
  return (
    <section>
      <PageHeader title="Edit" />
      <{edit} recordId={{id}} onUpdated={{() => navigate(`/{folder}/${{id}}`)}} />
    </section>
  );
}}
"""


def assemble_react(dest: Path, ref: str, profile_id: str) -> None:
    spec = PROFILES[profile_id]
    for name in (
        "package.json",
        "package-lock.json",
        "vite.config.ts",
        "vite-plugin-p2hs-devtools.js",
        "tsconfig.json",
        "tsconfig.node.json",
        "index.html",
        "postcss.config.js",
        "tailwind.config.js",
    ):
        src = REACT_BOILER / name
        if src.is_file():
            _copy_file(src, dest / name)
    pkg_path = dest / "package.json"
    if pkg_path.is_file():
        pkg = json.loads(pkg_path.read_text(encoding="utf-8"))
        pkg.setdefault("scripts", {})["build"] = "vite build"
        _write(pkg_path, json.dumps(pkg, indent=2) + "\n")
    _copy_file(REACT_BOILER / "src" / "main.tsx", dest / "src" / "main.tsx")
    _copy_file(REACT_BOILER / "src" / "vite-env.d.ts", dest / "src" / "vite-env.d.ts")
    if (REACT_BOILER / "src" / "index.css").is_file():
        _copy_file(REACT_BOILER / "src" / "index.css", dest / "src" / "index.css")
    if (REACT_BOILER / "src" / "starter-tokens.css").is_file():
        _copy_file(REACT_BOILER / "src" / "starter-tokens.css", dest / "src" / "starter-tokens.css")
        main = (dest / "src" / "main.tsx").read_text(encoding="utf-8")
        if "starter-tokens.css" not in main:
            main = main.replace('import "./index.css";', 'import "./index.css";\nimport "./starter-tokens.css";')
            _write(dest / "src" / "main.tsx", main)
    shutil.copytree(REACT_BOILER / "src" / "components" / "starter", dest / "src" / "components" / "starter", dirs_exist_ok=True)
    shutil.copytree(REACT_BOILER / "src" / "lib", dest / "src" / "lib", dirs_exist_ok=True)
    _write(dest / "src" / "App.tsx", "// @ts-nocheck\n" + _react_app_tsx(profile_id, spec))
    _write(dest / ".gitignore", GITIGNORE)
    _write(dest / ".env.example", "# Browser-safe IDs only. No public Cloud or Core URL.\n")
    _write(dest / "README.md", _readme(ref, profile_id))
    _write(dest / "p2hs" / "cloud-access.md", CLOUD_ACCESS)
    _write(dest / "p2hs" / "app-blocks-plan.json", json.dumps(_plan(profile_id, spec["title"]), indent=2) + "\n")
    hosts_src = REACT_BOILER / "p2hs" / "app-block-hosts.json"
    if hosts_src.is_file():
        _write(dest / "p2hs" / "app-block-hosts.json", json.dumps(_filter_hosts(hosts_src, spec["keep_blocks"]), indent=2) + "\n")
    else:
        _write(dest / "p2hs" / "app-block-hosts.json", json.dumps(_filter_hosts(NUXT_BOILER / "p2hs" / "app-block-hosts.json", spec["keep_blocks"]), indent=2) + "\n")
    contracts_src = REACT_BOILER / "p2hs" / "starter-contracts.json"
    _write(dest / "p2hs" / "starter-contracts.json", json.dumps(_contracts(contracts_src if contracts_src.is_file() else NUXT_BOILER / "p2hs" / "starter-contracts.json"), indent=2) + "\n")
    _finish(dest, "react_app", ref)


def _finish(dest: Path, framework: str, ref: str) -> None:
    sys.path.insert(0, str(ROOT))
    from src.domain.app_blocks.schemas import AppBlockPlan
    from src.domain.codegen.port_adapter import install_catalog_adapters
    from src.domain.templates.service import build_template_deploy_workflow

    AppBlockPlan.model_validate_json((dest / "p2hs" / "app-blocks-plan.json").read_text(encoding="utf-8"))
    install_catalog_adapters(str(dest), framework, {"legacy_contact_submit": False})
    _write(
        dest / ".github" / "workflows" / "deploy.yml",
        build_template_deploy_workflow(
            ref=ref,
            framework=framework,
            main_branch="main",
            dev_branch="development",
        ),
    )
    _assert_clean(dest)


def assemble(dest: Path, framework: str, ref: str, profile_id: str) -> None:
    _clear_dest(dest)
    if framework == "nuxt_app":
        assemble_nuxt(dest, ref, profile_id)
    else:
        assemble_react(dest, ref, profile_id)


def _run(cmd: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess:
    env = os.environ.copy()
    env["GIT_TERMINAL_PROMPT"] = "0"
    return subprocess.run(cmd, cwd=cwd, env=env, text=True, capture_output=True)


def publish_one(repo: str, framework: str, ref: str, profile_id: str) -> str:
    dest = WORK / repo
    WORK.mkdir(parents=True, exist_ok=True)
    if dest.exists() and (dest / ".git").exists():
        pulled = _run(["git", "fetch", "origin"], cwd=dest)
        if pulled.returncode != 0:
            shutil.rmtree(dest)
    if not dest.exists():
        cloned = _run(["gh", "repo", "clone", f"P2HS/{repo}", str(dest)])
        if cloned.returncode != 0:
            return f"FAIL clone {repo}: {cloned.stderr[-400:]}"
    checkout = _run(["git", "checkout", "development"], cwd=dest)
    if checkout.returncode != 0:
        _run(["git", "checkout", "-B", "development", "origin/development"], cwd=dest)
        checkout = _run(["git", "checkout", "development"], cwd=dest)
        if checkout.returncode != 0:
            _run(["git", "checkout", "-B", "development"], cwd=dest)
            _run(["git", "checkout", "development"], cwd=dest)
    try:
        assemble(dest, framework, ref, profile_id)
    except Exception as exc:
        return f"FAIL assemble {repo}: {exc}"
    _run(["git", "add", "-A"], cwd=dest)
    staged = _run(["git", "diff", "--cached", "--quiet"], cwd=dest)
    if staged.returncode == 0:
        # still ensure starter-v2 exists
        _run(["git", "branch", "starter-v2"], cwd=dest)
        push_v2 = _run(["git", "push", "-u", "origin", "starter-v2"], cwd=dest)
        push_dev = _run(["git", "push", "-u", "origin", "development"], cwd=dest)
        return f"SKIP no changes {repo} (push v2={push_v2.returncode} dev={push_dev.returncode})"
    commit = _run(
        ["git", "commit", "-m", f"Rebuild development onto App Block gold seed ({profile_id})."],
        cwd=dest,
    )
    if commit.returncode != 0:
        return f"FAIL commit {repo}: {commit.stderr[-400:]}"
    push_dev = _run(["git", "push", "origin", "HEAD:development"], cwd=dest)
    if push_dev.returncode != 0:
        return f"FAIL push development {repo}: {push_dev.stderr[-400:]}"
    _run(["git", "branch", "-f", "starter-v2", "HEAD"], cwd=dest)
    push_v2 = _run(["git", "push", "-u", "origin", "starter-v2", "--force"], cwd=dest)
    if push_v2.returncode != 0:
        return f"FAIL push starter-v2 {repo}: {push_v2.stderr[-400:]}"
    sha = _run(["git", "rev-parse", "--short", "HEAD"], cwd=dest).stdout.strip()
    return f"OK {repo} {sha} {profile_id} {framework}"


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--publish", action="store_true")
    parser.add_argument("--only", default="")
    args = parser.parse_args()
    log_path = WORK / "publish.log"
    WORK.mkdir(parents=True, exist_ok=True)
    results = []
    for repo, framework, ref, profile_id in TEMPLATES:
        if repo in SKIP:
            results.append(f"SKIP already gold {repo}")
            continue
        if args.only and args.only not in {repo, ref, profile_id, framework}:
            continue
        if not args.publish:
            print(f"would publish {repo} {framework} {profile_id}")
            continue
        print(f"publishing {repo} ...", flush=True)
        line = publish_one(repo, framework, ref, profile_id)
        print(line, flush=True)
        results.append(line)
        log_path.write_text("\n".join(results) + "\n", encoding="utf-8")
    print("----")
    print("\n".join(results))


if __name__ == "__main__":
    main()
