"""Templates domain — business logic."""
from __future__ import annotations

import os
import re
import tarfile
from typing import Optional
from uuid import UUID

import httpx
import structlog

from src.domain.templates.models import Template
from src.domain.templates.repository import TemplateRepository
from src.domain.templates.schemas import (
    TemplateBootstrapCreate,
    TemplateBootstrapResponse,
    TemplateCreate,
    PublicTemplateResponse,
    TemplateResponse,
    TemplateUpdate,
)

logger = structlog.get_logger()

_SUBDOMAIN_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$")
_BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]{1,100}$")
_EXCLUDED_TEMPLATE_SEED_NAMES = frozenset({
    ".git",
    "__pycache__",
    ".venv",
    "venv",
    "node_modules",
    ".nuxt",
    "dist",
    ".output",
    ".next",
    "build",
})


def _validate_subdomain(label: str, field: str) -> str:
    label = (label or "").strip().lower()
    if not label or not _SUBDOMAIN_RE.fullmatch(label):
        raise ValueError(
            f"{field} must be 1–63 chars: lowercase letters, digits, hyphens; "
            "must not start or end with a hyphen."
        )
    return label


def _validate_branch_name(branch: str, field: str) -> str:
    value = (branch or "").strip()
    if not value or value.startswith("/") or value.endswith("/") or ".." in value:
        raise ValueError(f"{field} must be a valid Git branch name.")
    if not _BRANCH_RE.fullmatch(value):
        raise ValueError(f"{field} must contain only letters, digits, '.', '_', '-', or '/'.")
    return value


def _derive_template_repo_name(explicit: Optional[str], ref: str) -> str:
    raw = (explicit or f"p2hs-template-{ref}").strip().lower()
    raw = re.sub(r"[^a-z0-9_.-]+", "-", raw)
    raw = re.sub(r"-{2,}", "-", raw).strip(".-")
    if not raw:
        raise ValueError("github_repo_name produced an empty repository name.")
    return raw[:100]


def _project_root() -> str:
    here = os.path.dirname(os.path.abspath(__file__))
    return os.path.dirname(os.path.dirname(os.path.dirname(here)))


def _collect_seed_files(framework: str, settings=None) -> dict[str, str | bytes]:
    """Collect starter files, preferring the latest boilerplate snapshot."""
    snapshot_files = _collect_snapshot_seed_files(framework, settings)
    if snapshot_files:
        return _with_cloud_access_files(snapshot_files, framework)

    root = os.path.join(_project_root(), "boilerplates", framework)
    if not os.path.isdir(root):
        return _with_cloud_access_files(_fallback_seed_files(framework), framework)

    files: dict[str, str | bytes] = {}
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_TEMPLATE_SEED_NAMES]
        for filename in filenames:
            if filename in _EXCLUDED_TEMPLATE_SEED_NAMES or filename.endswith(".pyc"):
                continue
            path = os.path.join(dirpath, filename)
            rel = os.path.relpath(path, root).replace("\\", "/")
            with open(path, "rb") as fh:
                raw = fh.read()
            try:
                files[rel] = raw.decode("utf-8")
            except UnicodeDecodeError:
                files[rel] = raw
    return _with_cloud_access_files(files or _fallback_seed_files(framework), framework)


def _collect_snapshot_seed_files(framework: str, settings=None) -> dict[str, str | bytes]:
    from src.workers.boilerplate_store import find_latest_version, get_snapshot_dir, snapshot_path

    snapshot_dir = get_snapshot_dir(settings)
    version = find_latest_version(framework, snapshot_dir)
    if not version:
        return {}

    path = snapshot_path(framework, version, snapshot_dir)
    if not os.path.isfile(path):
        return {}

    files: dict[str, str | bytes] = {}
    with tarfile.open(path, "r:gz") as tf:
        for member in tf.getmembers():
            name = member.name.replace("\\", "/").lstrip("/")
            parts = [p for p in name.split("/") if p]
            if not parts or ".." in parts or any(p in _EXCLUDED_TEMPLATE_SEED_NAMES for p in parts):
                continue
            if not member.isfile():
                continue
            fh = tf.extractfile(member)
            if fh is None:
                continue
            raw = fh.read()
            rel = "/".join(parts)
            try:
                files[rel] = raw.decode("utf-8")
            except UnicodeDecodeError:
                files[rel] = raw
    logger.info("template_seed_snapshot_selected", framework=framework, version=version, path=path)
    return files


def _with_cloud_access_files(files: dict[str, str | bytes], framework: str) -> dict[str, str | bytes]:
    """Overlay browser-safe P2HS Cloud/Core integration metadata for template authors."""
    merged: dict[str, str | bytes] = dict(files)
    from src.domain.codegen.record_adapter import platform_adapter_files
    for path, content in platform_adapter_files(framework).items():
        merged[path] = content
    merged["p2hs.platform.json"] = _platform_manifest(framework)
    merged["p2hs/cloud-access.md"] = _cloud_access_readme(framework)
    from src.domain.templates.cloud_contract import CLOUD_CONTRACT_INSTRUCTIONS
    if framework in {"nuxt_app", "react_app", "html"}:
        merged["p2hs/backend-contract.md"] = CLOUD_CONTRACT_INSTRUCTIONS
        instructions = merged.get(".cursor/instructions.md", "")
        if isinstance(instructions, bytes):
            instructions = instructions.decode("utf-8")
        if "p2hs/backend-contract.md" not in instructions:
            instructions += "\n\nRead and follow p2hs/backend-contract.md before implementing backend features.\n"
        merged[".cursor/instructions.md"] = instructions

    env_example = _cloud_env_example(framework)
    existing = merged.get(".env.example")
    if isinstance(existing, bytes):
        try:
            existing = existing.decode("utf-8")
        except UnicodeDecodeError:
            existing = ""
    if isinstance(existing, str) and existing.strip():
        body = existing.rstrip()
        defined = set(re.findall(r"^\s*([A-Z][A-Z0-9_]*)=", body, re.MULTILINE))
        missing = [line for line in env_example.splitlines()
                   if "=" in line and not line.startswith("#") and line.split("=", 1)[0] not in defined]
        if missing:
            body += "\n\n# P2HS Cloud bindings (service credentials are server-only).\n" + "\n".join(missing)
        merged[".env.example"] = body.rstrip() + "\n"
    else:
        merged[".env.example"] = env_example.rstrip() + "\n"
    return merged


def _platform_manifest(framework: str) -> str:
    import json

    if framework == "nuxt_app":
        public_env = {
            "project_id": "NUXT_PUBLIC_P2HS_PROJECT_ID",
            "client_id": "NUXT_PUBLIC_P2HS_CLIENT_ID",
        }
        server_env = {
            "cloud_gateway_url": "CLOUD_GATEWAY_URL",
            "cloud_service_token": "CLOUD_SERVICE_TOKEN",
        }
        bff_path = "/api/cloud"
    elif framework == "react_app":
        public_env = {
            "project_id": "VITE_P2HS_PROJECT_ID",
            "client_id": "VITE_P2HS_CLIENT_ID",
        }
        server_env = {
            "cloud_gateway_url": "CLOUD_GATEWAY_URL",
            "cloud_service_token": "CLOUD_SERVICE_TOKEN",
        }
        bff_path = "/api/p2hs/cloud"
    else:
        public_env = {
            "project_id": "window.__P2HS__.projectId",
            "client_id": "window.__P2HS__.clientId",
        }
        server_env = {
            "cloud_gateway_url": "CLOUD_GATEWAY_URL",
            "cloud_service_token": "CLOUD_SERVICE_TOKEN",
        }
        bff_path = None

    manifest = {
        "schema_version": 4,
        "backend": {
            "provider": "p2hs_cloud",
            "authoritative_store": "cloud_records",
            "auth_provider": "p2hs_cloud",
            "local_database_allowed": False,
            "contract_document": "p2hs/backend-contract.md",
            "provision_manifest": "p2hs/provision.json",
            "rls": {
                "user_requests": "Forward the app user's Authorization header unchanged.",
                "service_token": "Bypasses RLS; server-only, use only for explicitly authorized privileged operations.",
                "service_create": "Requires top-level owner_user_id for an rls_enabled entity.",
            },
        },
        "description": "PC Gateway integration contract for starter template authors.",
        "gateway": {
            "role": "PC Gateway — app-user auth, records, storage, AI, realtime, edge functions, connectors.",
            "server_url_env": server_env.get("cloud_gateway_url"),
            "service_token_env": server_env.get("cloud_service_token"),
            "bff_path": bff_path,
            "note": "Browser source uses relative /api only. Server runtime may bind CLOUD_GATEWAY_URL. Never put a Cloud or Core origin in public/Vite/Nuxt env.",
            "typical_routes": [
                "POST /v1/projects/{project_id}/auth/signin",
                "POST /v1/projects/{project_id}/auth/signup",
                "POST /v1/projects/{project_id}/records/query",
                "GET  /v1/projects/{project_id}/entities/{entity}/records",
                "POST /v1/projects/{project_id}/storage/{bucket}/presign",
                "POST /v1/projects/{project_id}/bookings/reserve",
                "POST /v1/projects/{project_id}/jobs",
                "GET  /v1/projects/{project_id}/realtime/{channel}",
            ],
        },
        "client_registration": {
            "project_id_env": public_env["project_id"],
            "client_id_env": public_env["client_id"],
            "note": "Trusted-client IDs and capability grants are provisioned by P2HS Core. Browser code must never embed service tokens or provider secrets.",
        },
        "capabilities": [
            "records",
            "storage",
            "ai",
            "realtime",
            "auth",
            "edge_functions",
            "connectors",
            "imports",
            "bookings",
            "reminders",
            "jobs",
            "usage_metering",
        ],
    }
    return json.dumps(manifest, indent=2) + "\n"


def _cloud_env_example(framework: str) -> str:
    if framework == "nuxt_app":
        return """# P2HS Gateway bindings. Core injects real values during project env sync.
NUXT_PUBLIC_P2HS_PROJECT_ID=
NUXT_PUBLIC_P2HS_CLIENT_ID=
PROJECT_ID=
# Server-only. Browser calls relative /api. Never expose in browser code.
CLOUD_GATEWAY_URL=
CLOUD_SERVICE_TOKEN=
"""
    if framework == "react_app":
        return """# P2HS Gateway bindings. Core injects real values during project env sync.
VITE_P2HS_PROJECT_ID=
VITE_P2HS_CLIENT_ID=
PROJECT_ID=
# Server-only (Pages Functions / BFF). Browser calls relative /api.
CLOUD_GATEWAY_URL=
CLOUD_SERVICE_TOKEN=
"""
    return """# P2HS Gateway bindings. Core injects real values into generated project env metadata.
# Public browser config may use window.__P2HS__ or meta tags.
# Server-only Pages Functions read CLOUD_GATEWAY_URL, CLOUD_SERVICE_TOKEN, and PROJECT_ID.
CLOUD_GATEWAY_URL=
CLOUD_SERVICE_TOKEN=
PROJECT_ID=
"""


def _cloud_access_readme(framework: str) -> str:
    return f"""# P2HS Gateway Access

This starter connects exclusively to PC Gateway for all backend operations.

Read `p2hs/backend-contract.md` for the authoritative persistence and RLS contract.
Nuxt/Nitro may render pages and host thin BFF routes; it must not host a second database.

- All data ops (records, storage, AI, functions, bookings, reminders, jobs) go through the BFF at `/api/cloud/`.
- Browser source uses relative `/api` only. Do not read a public Cloud or Core URL.
- Reserve bookable slots through Cloud `bookings.reserve`, not a plain record write.
- Schedule reminders with `reminder.schedule` (email or Twilio SMS); do not invent local cron.
- PayPal checkout uses `paypal.create_order` / `paypal.capture_order` via `integration.execute`.
- Project/client IDs are injected by the platform. Server runtime binds `CLOUD_GATEWAY_URL`.
- Do not commit service tokens, provider credentials, or user secrets.
- Generated apps do not call P2HS Core directly — Core is the internal control plane.

Framework: `{framework}`
Contract: `p2hs.platform.json`
"""


def _fallback_seed_files(framework: str) -> dict[str, str]:
    if framework == "html":
        return {
            "index.html": "<!doctype html>\n<html><head><meta charset=\"utf-8\"><title></title></head><body></body></html>\n",
        }
    if framework == "nuxt_app":
        return {
            "package.json": '{"scripts":{"generate":"nuxt generate"},"dependencies":{"nuxt":"latest"}}\n',
            "app.vue": "<template><NuxtPage /></template>\n",
            "pages/index.vue": "<template><main /></template>\n",
            "nuxt.config.ts": "export default defineNuxtConfig({ ssr: true })\n",
        }
    return {
        "package.json": (
            '{"scripts":{"build":"vite --host 0.0.0.0"},'
            '"dependencies":{"@vitejs/plugin-react":"latest","vite":"latest","react":"latest","react-dom":"latest"},'
            '"devDependencies":{}}\n'
        ),
        "index.html": '<div id="root"></div><script type="module" src="/src/main.jsx"></script>\n',
        "src/main.jsx": (
            'import React from "react";\nimport { createRoot } from "react-dom/client";\n'
            'createRoot(document.getElementById("root")).render(<main />);\n'
        ),
    }


def build_template_deploy_workflow(
    *,
    ref: str,
    framework: str,
    main_branch: str,
    dev_branch: str,
) -> str:
    """GitHub Actions workflow for template demo deployment to CF Pages."""
    from src.domain.hosting.deployment_targets import pages_project_name, get_pages_build_config

    cf_project = pages_project_name(ref)
    build_cfg = get_pages_build_config(framework)
    output_dir = build_cfg.destination_dir or "."

    build_steps: list[str] = []
    if build_cfg.build_command:
        build_steps = [
            "      - uses: actions/setup-node@v4",
            "        with:",
            "          node-version: '22'",
            "      - name: Install locked app dependencies\n"
            "        run: test -f package-lock.json && npm ci",
            f"      - run: {build_cfg.build_command}",
        ]

    steps = ["      - uses: actions/checkout@v4"] + build_steps + [
        "      - name: Deploy to CF Pages",
        "        uses: cloudflare/wrangler-action@v3",
        "        with:",
        "          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}",
        "          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}",
        "          command: >-",
        f"            pages deploy {output_dir}",
        f"            --project-name={cf_project}",
        "            --branch=${{ github.ref_name }}",
        "            --commit-hash=${{ github.sha }}",
    ]

    return (
        "name: Deploy Template to CF Pages\n"
        "on:\n"
        "  push:\n"
        f"    branches: [{main_branch}, {dev_branch}]\n"
        "  workflow_dispatch: {}\n"
        "\n"
        "jobs:\n"
        "  deploy:\n"
        "    runs-on: ubuntu-latest\n"
        "    permissions:\n"
        "      contents: read\n"
        "    env:\n"
        "      FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n"
        "    steps:\n"
        + "\n".join(steps)
        + "\n"
    )


def _to_response(t: Template, plan_slugs: list[str]) -> TemplateResponse:
    return TemplateResponse(
        id=t.id,
        name=t.name,
        ref=t.ref,
        description=t.description,
        thumbnail_url=t.thumbnail_url,
        tags=t.tags,
        github_repo=t.github_repo,
        framework=t.framework,
        main_branch=t.main_branch,
        dev_branch=t.dev_branch,
        starter_branch=t.starter_branch,
        starter_sha=t.starter_sha,
        demo_subdomain=t.demo_subdomain,
        dev_subdomain=t.dev_subdomain,
        demo_url=t.demo_url,
        dev_url=t.dev_url,
        deploy_status=t.deploy_status,
        is_active=t.is_active,
        is_default=t.is_default,
        is_showcased=t.is_showcased,
        showcase_rank=t.showcase_rank,
        plan_slugs=plan_slugs,
        created_at=t.created_at,
        updated_at=t.updated_at,
    )


def _to_public_response(t: Template) -> PublicTemplateResponse:
    """Map a template to the small field set approved for anonymous visitors."""
    return PublicTemplateResponse(
        name=t.name,
        ref=t.ref,
        description=t.description,
        thumbnail_url=t.thumbnail_url,
        tags=t.tags,
        framework=t.framework,
        demo_url=t.demo_url,
    )


class TemplateService:
    def __init__(self, repo: TemplateRepository):
        self._repo = repo

    async def bootstrap_starter_template(
        self,
        body: TemplateBootstrapCreate,
        admin_user_id: UUID,
    ) -> TemplateBootstrapResponse:
        """Create a template catalog row plus its GitHub starter repo."""
        from src.config import get_settings
        from src.integrations.github_client import GitHubAppClient

        settings = get_settings()
        ref = body.template_ref.strip().lower()
        existing = await self._repo.get_by_ref(ref)
        if existing:
            raise ValueError(f"Template ref '{ref}' is already in use.")

        if body.framework not in {"react_app", "nuxt_app", "html"}:
            raise ValueError("framework must be one of: react_app, nuxt_app, html.")

        starter_branch = _validate_branch_name(body.branch, "branch")
        demo_sub = _validate_subdomain(ref, "template_ref")
        dev_sub = _validate_subdomain(f"dev-{ref}", "dev_subdomain")
        repo_name = _derive_template_repo_name(body.github_repo_name, ref)
        org = (settings.github_org or "").strip() or None
        installation_id = (
            body.github_installation_id
            or settings.github_template_installation_id
            or settings.github_installation_id
        )

        github = GitHubAppClient(installation_id=installation_id)
        try:
            repo_info = await github.ensure_repository(repo_name, private=True, org=org)

            files = _collect_seed_files(body.framework, settings=settings)
            files[".github/workflows/deploy.yml"] = build_template_deploy_workflow(
                ref=ref,
                framework=body.framework,
                main_branch="main",
                dev_branch="development",
            )
            files[".p2hs-template.json"] = (
                "{\n"
                f'  "template_ref": "{ref}",\n'
                f'  "framework": "{body.framework}",\n'
                f'  "starter_branch": "{starter_branch}"\n'
                "}\n"
            )

            starter_sha = await github.create_commit(
                repo_info.full_name,
                "main",
                files,
                "feat: bootstrap P2HS template starter",
            )
            await github.create_branch_from_sha(repo_info.full_name, "development", starter_sha)
            await github.create_branch_from_sha(repo_info.full_name, starter_branch, starter_sha)

            collaborators = [
                u.strip()
                for u in (settings.github_template_repo_collaborators or "").split(",")
                if u.strip()
            ]
            if collaborators:
                try:
                    await github.invite_collaborators(repo_info.full_name, collaborators)
                except Exception as exc:
                    logger.warning(
                        "template_collaborator_invite_failed",
                        repo=repo_info.full_name,
                        error=str(exc),
                    )
        except httpx.HTTPStatusError as exc:
            detail = ""
            if exc.response is not None:
                detail = exc.response.text[:500]
            if exc.response is not None and exc.response.status_code in (403, 429):
                body = detail.lower()
                if "secondary rate limit" in body or "too many requests" in body:
                    raise ValueError(
                        "GitHub is temporarily throttling repository creation. "
                        "The operation was retried briefly; wait a few minutes and retry. "
                        "If the repository was already created, retrying is safe."
                    ) from exc
            raise ValueError(
                "GitHub template repo bootstrap failed. Confirm the GitHub App installation "
                "has Contents read/write access to the new repo, then retry. "
                f"GitHub status={exc.response.status_code if exc.response else 'unknown'} {detail}"
            ) from exc

        t = await self._repo.create(
            name=body.name,
            ref=ref,
            github_repo=repo_info.full_name,
            framework=body.framework,
            demo_subdomain=demo_sub,
            dev_subdomain=dev_sub,
            description=body.description,
            thumbnail_url=body.thumbnail_url,
            tags=body.tags,
            github_installation_id=body.github_installation_id,
            main_branch="main",
            dev_branch="development",
            starter_branch=starter_branch,
            is_active=body.is_active,
            is_default=body.is_default,
            is_showcased=body.is_showcased,
            showcase_rank=body.showcase_rank,
            created_by=admin_user_id,
        )
        t.starter_sha = starter_sha
        if body.plan_slugs:
            await self._repo.replace_plan_access(t.id, body.plan_slugs)

        from src.domain.projects.platform_files_sync import PLATFORM_FILES_VERSION
        await self._repo.set_platform_files_version(t.id, PLATFORM_FILES_VERSION)

        apex = (settings.hosting_public_apex or "p2hsapps.com").strip().lower()
        logger.info(
            "template_bootstrapped",
            template_id=str(t.id),
            ref=ref,
            repo=repo_info.full_name,
            starter_sha=starter_sha[:12],
        )
        return TemplateBootstrapResponse(
            template_id=t.id,
            template_ref=ref,
            github_repo=repo_info.full_name,
            github_repo_url=getattr(repo_info, "html_url", None),
            main_branch="main",
            dev_branch="development",
            starter_branch=starter_branch,
            starter_sha=starter_sha,
            demo_url=f"https://{demo_sub}.{apex}/",
            dev_url=f"https://{dev_sub}.{apex}/",
            deploy_status=t.deploy_status,
        )

    async def register(
        self, body: TemplateCreate, admin_user_id: UUID
    ) -> TemplateResponse:
        ref = body.ref.strip().lower()
        # Validate uniqueness
        existing = await self._repo.get_by_ref(ref)
        if existing:
            raise ValueError(f"Template ref '{ref}' is already in use.")

        demo_sub = _validate_subdomain(
            body.demo_subdomain or ref, "demo_subdomain"
        )
        dev_sub = _validate_subdomain(
            body.dev_subdomain or f"dev-{ref}", "dev_subdomain"
        )

        github_repo = (body.github_repo or "").strip()
        if "/" not in github_repo or github_repo.startswith("http"):
            raise ValueError("github_repo must be in 'owner/repo' format.")

        t = await self._repo.create(
            name=body.name,
            ref=ref,
            github_repo=github_repo,
            framework=body.framework,
            demo_subdomain=demo_sub,
            dev_subdomain=dev_sub,
            description=body.description,
            thumbnail_url=body.thumbnail_url,
            tags=body.tags,
            github_installation_id=body.github_installation_id,
            main_branch=body.main_branch,
            dev_branch=body.dev_branch,
            starter_branch=body.starter_branch,
            is_active=body.is_active,
            is_default=body.is_default,
            is_showcased=body.is_showcased,
            showcase_rank=body.showcase_rank,
            created_by=admin_user_id,
        )

        if body.plan_slugs:
            await self._repo.replace_plan_access(t.id, body.plan_slugs)

        logger.info("template_registered", template_id=str(t.id), ref=ref)
        plan_slugs = await self._repo.get_plan_slugs(t.id)
        return _to_response(t, plan_slugs)

    async def get(self, template_id: UUID) -> Optional[TemplateResponse]:
        t = await self._repo.get_by_id(template_id)
        if not t:
            return None
        plan_slugs = await self._repo.get_plan_slugs(t.id)
        return _to_response(t, plan_slugs)

    async def get_by_ref(self, ref: str) -> Optional[TemplateResponse]:
        t = await self._repo.get_by_ref(ref)
        if not t:
            return None
        plan_slugs = await self._repo.get_plan_slugs(t.id)
        return _to_response(t, plan_slugs)

    async def get_by_ref_for_plan(self, ref: str, plan_slug: str) -> Optional[TemplateResponse]:
        """Active template by ref only if visible to ``plan_slug`` (matches list endpoint)."""
        t = await self._repo.get_by_ref(ref)
        if not t or not t.is_active:
            return None
        if not await self._repo.plan_access_allows(t.id, plan_slug):
            return None
        plan_slugs = await self._repo.get_plan_slugs(t.id)
        return _to_response(t, plan_slugs)

    async def list_all(self, *, include_inactive: bool = False) -> list[TemplateResponse]:
        templates = await self._repo.list_all(include_inactive=include_inactive)
        results = []
        for t in templates:
            plan_slugs = await self._repo.get_plan_slugs(t.id)
            results.append(_to_response(t, plan_slugs))
        return results

    async def list_for_plan(self, plan_slug: str) -> list[TemplateResponse]:
        templates = await self._repo.list_for_plan(plan_slug)
        results = []
        for t in templates:
            plan_slugs = await self._repo.get_plan_slugs(t.id)
            results.append(_to_response(t, plan_slugs))
        return results

    async def list_showcased(self) -> list[PublicTemplateResponse]:
        """Return only fields approved for unauthenticated marketing use."""
        templates = await self._repo.list_showcased()
        return [_to_public_response(t) for t in templates]

    async def get_showcased_by_ref(self, ref: str) -> Optional[PublicTemplateResponse]:
        """Return a public template only when it remains marketing-ready."""
        template = await self._repo.get_showcased_by_ref(ref)
        return _to_public_response(template) if template else None

    async def update(
        self, template_id: UUID, body: TemplateUpdate
    ) -> Optional[TemplateResponse]:
        fields_set = body.model_fields_set
        t = await self._repo.update(
            template_id,
            name=body.name,
            description=body.description,
            thumbnail_url=body.thumbnail_url,
            tags=body.tags,
            framework=body.framework,
            main_branch=body.main_branch,
            dev_branch=body.dev_branch,
            starter_branch=body.starter_branch,
            is_active=body.is_active,
            is_default=body.is_default,
            is_showcased=body.is_showcased,
            showcase_rank=body.showcase_rank,
            clear_description="description" in fields_set and body.description is None,
            clear_thumbnail_url="thumbnail_url" in fields_set and body.thumbnail_url is None,
            clear_tags="tags" in fields_set and body.tags is None,
            clear_showcase_rank="showcase_rank" in fields_set and body.showcase_rank is None,
        )
        if not t:
            return None
        if body.plan_slugs is not None:
            await self._repo.replace_plan_access(template_id, body.plan_slugs)
        plan_slugs = await self._repo.get_plan_slugs(t.id)
        return _to_response(t, plan_slugs)

    async def delete(self, template_id: UUID) -> bool:
        t = await self._repo.get_by_id(template_id)
        if not t:
            return False
        await self._repo.soft_delete(template_id)
        logger.info("template_deleted", template_id=str(template_id))
        return True

    async def record_starter_sha(self, template_id: UUID, sha: str) -> None:
        await self._repo.update(template_id, starter_sha=sha)
        logger.info("template_starter_sha_updated", template_id=str(template_id), sha=sha[:12])

    async def mark_deploy_result(
        self,
        template_id: UUID,
        *,
        environment: str,
        status: str,
        demo_url: Optional[str] = None,
        dev_url: Optional[str] = None,
        cf_pages_project_name: Optional[str] = None,
    ) -> None:
        await self._repo.update(
            template_id,
            deploy_status=status,
            demo_url=demo_url,
            dev_url=dev_url,
            cf_pages_project_name=cf_pages_project_name,
        )
