"""
Module lifecycle commands (init, build, publish, validate).
"""

from __future__ import annotations

import base64
import json
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Dict, Optional

import boto3
import click
from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError, ProfileNotFound
from rich.live import Live
from rich.prompt import Confirm
from rich.table import Table

from ..auth import Auth
from ..client import APIClient, APIError
from ..config import Config
from ..container_signing import (
    ContainerSigningError,
    default_private_key_path,
    default_public_key_path,
    generate_keypair,
    public_key_fingerprint,
    scan_image_with_trivy,
    verify_image_against_signers,
)
from ..container_signing import (
    sign_image as sign_container_image,
)
from ..env import PublishConfigError, get_publish_defaults
from ..signing import ModuleSigner
from ..validation import ModuleValidator
from .utils import _ensure_verbose, console, ensure_docker_available, temporary_environment, verbose_option

DEFAULT_MODULE_RUNTIME_IMAGE = "ghcr.io/alitheabio/hla-compass-module-runtime:py311-trixie"
MODULE_RUNTIME_IMAGE_ENVVAR = "HLA_COMPASS_MODULE_RUNTIME_IMAGE"
TERMINAL_PUBLISH_STATUSES = {"completed", "approved", "failed", "cancelled", "rejected"}
PRIVATE_ECR_HOST_RE = re.compile(r"^(?P<account>\d{12})\.dkr\.ecr\.(?P<region>[a-z0-9-]+)\.amazonaws\.com$")
DEFAULT_PLATFORM_SELECTOR = "linux/amd64"


def load_sdk_config():
    try:
        config_path = Config.get_config_path()
        if config_path.exists():
            with open(config_path) as f:
                return json.load(f)
    except Exception:
        pass
    return None


def _resolve_publish_org_id(env: str, explicit_org_id: Optional[str]) -> Optional[str]:
    if explicit_org_id:
        return explicit_org_id
    return Config.get_active_org_id(env)


def _extract_registry_host(image_ref: str) -> str:
    return str(image_ref or "").split("/", 1)[0].strip()


def _parse_private_ecr_host(host: str) -> tuple[str, str] | None:
    match = PRIVATE_ECR_HOST_RE.fullmatch(host)
    if not match:
        return None
    return match.group("account"), match.group("region")


def _ensure_registry_push_auth(image_ref: str) -> None:
    host = _extract_registry_host(image_ref)
    parsed = _parse_private_ecr_host(host)
    if parsed is None:
        return

    account_id, region = parsed
    console.print(f"[dim]Authenticating docker to ECR registry: {host}[/dim]")

    try:
        session = boto3.Session()
        client = session.client("ecr", region_name=region)
        response = client.get_authorization_token(registryIds=[account_id])
        auth_data = (response or {}).get("authorizationData") or []
        if not auth_data:
            raise click.ClickException(f"ECR did not return an authorization token for registry {host}")
        token = auth_data[0].get("authorizationToken")
        if not token:
            raise click.ClickException(f"ECR authorization token missing for registry {host}")
        username, password = base64.b64decode(token).decode("utf-8").split(":", 1)
    except (ProfileNotFound, NoCredentialsError) as exc:
        raise click.ClickException(
            f"Unable to authenticate Docker to ECR registry {host}: {exc}. Configure AWS credentials and retry."
        ) from exc
    except (BotoCoreError, ClientError, ValueError, UnicodeDecodeError) as exc:
        raise click.ClickException(f"Unable to authenticate Docker to ECR registry {host}: {exc}") from exc

    try:
        subprocess.run(
            ["docker", "login", "--username", username, "--password-stdin", host],
            input=password,
            text=True,
            capture_output=True,
            check=True,
        )
    except subprocess.CalledProcessError as exc:
        details = (exc.stderr or exc.stdout or str(exc)).strip()
        raise click.ClickException(f"Docker login to ECR registry {host} failed: {details}") from exc


def _sign_manifest_for_publish(
    manifest_for_api: Dict[str, Any],
    *,
    generate_keys: bool,
    dry_run: bool,
) -> tuple[Dict[str, Any], str]:
    signer = ModuleSigner()
    temp_keys: TemporaryDirectory[str] | None = None

    try:
        try:
            signer.get_public_key_string()
            signing_status = f"✓ Keys found ({signer.get_key_fingerprint()[:16]}...)"
        except FileNotFoundError:
            if not generate_keys:
                raise

            if dry_run:
                temp_keys = TemporaryDirectory()
                signer = ModuleSigner(keys_dir=Path(temp_keys.name))
                signer.generate_keys()
                signing_status = "Keys will be auto-generated"
            else:
                console.print("[yellow]Signing keys not found. Generating new keys...[/yellow]")
                signer.generate_keys()
                signing_status = f"✓ Generated new keys ({signer.get_key_fingerprint()[:16]}...)"

        signed_manifest = dict(manifest_for_api)
        signed_manifest["signature"] = signer.sign_manifest(signed_manifest)
        signed_manifest["publicKey"] = signer.get_public_key_string()
        signed_manifest["keyFingerprint"] = signer.get_key_fingerprint()
        signed_manifest["signatureAlgorithm"] = signer.ALGORITHM
        signed_manifest["hashAlgorithm"] = signer.HASH_ALGORITHM
        return signed_manifest, signing_status
    finally:
        if temp_keys is not None:
            temp_keys.cleanup()


def _resolve_cosign_private_key(
    *,
    key_path: Optional[Path] = None,
    generate_keys_if_missing: bool,
) -> Path:
    resolved_key = key_path or default_private_key_path()

    if resolved_key.exists():
        return resolved_key

    if key_path is not None:
        raise FileNotFoundError(f"Cosign private key not found: {resolved_key}")

    if not generate_keys_if_missing:
        raise FileNotFoundError("Cosign private key not found.")

    generate_keypair()
    return resolved_key


def _managed_publish_image_signing_metadata(
    *,
    image_ref: str,
    generate_keys_if_missing: bool,
    platform: Optional[str],
) -> tuple[str, Dict[str, str], str]:
    resolved_key = _resolve_cosign_private_key(
        generate_keys_if_missing=generate_keys_if_missing,
    )

    signable_image_ref = _resolve_platform_specific_image_ref(
        image_ref,
        platform=platform,
        required=True,
    )
    sign_container_image(signable_image_ref, key_path=resolved_key)

    public_key_path = default_public_key_path()
    if not public_key_path.exists():
        raise FileNotFoundError(f"Cosign public key not found: {public_key_path}")

    public_key_pem = public_key_path.read_text(encoding="utf-8")
    fingerprint = public_key_fingerprint(public_key_pem)
    return (
        signable_image_ref,
        {
            "publicKeyPem": public_key_pem,
            "publicKeyFingerprint": fingerprint,
        },
        fingerprint,
    )


def _attach_image_signing_metadata(manifest_for_api: Dict[str, Any], metadata: Dict[str, str]) -> Dict[str, Any]:
    payload = dict(manifest_for_api)
    existing_metadata = payload.get("metadata")
    payload_metadata = dict(existing_metadata) if isinstance(existing_metadata, dict) else {}
    payload_metadata["imageSigning"] = dict(metadata)
    payload["metadata"] = payload_metadata
    return payload


def _resolve_platform_specific_image_ref(
    image_ref: str,
    platform: Optional[str],
    *,
    required: bool,
) -> str:
    if "@" in image_ref:
        return image_ref

    selected_platform = str(platform or DEFAULT_PLATFORM_SELECTOR).split(",", 1)[0].strip() or DEFAULT_PLATFORM_SELECTOR
    platform_parts = [part for part in selected_platform.split("/") if part]
    target_os = platform_parts[0] if len(platform_parts) >= 1 else "linux"
    target_arch = platform_parts[1] if len(platform_parts) >= 2 else "amd64"
    target_variant = platform_parts[2] if len(platform_parts) >= 3 else ""

    try:
        proc = subprocess.run(
            ["docker", "buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", image_ref],
            capture_output=True,
            text=True,
            check=True,
        )
        manifest = json.loads(proc.stdout or "{}")
    except (subprocess.CalledProcessError, json.JSONDecodeError) as exc:
        if required:
            raise click.ClickException(f"Failed to resolve registry digest for {image_ref}: {exc}") from exc
        return image_ref

    image_name = image_ref.split("@", 1)[0]
    manifests = manifest.get("manifests")
    if isinstance(manifests, list):
        for entry in manifests:
            if not isinstance(entry, dict):
                continue
            platform_info = entry.get("platform") or {}
            os_name = str(platform_info.get("os") or "").strip().lower()
            arch_name = str(platform_info.get("architecture") or "").strip().lower()
            variant_name = str(platform_info.get("variant") or "").strip().lower()
            if os_name != target_os.lower() or arch_name != target_arch.lower():
                continue
            if target_variant and variant_name != target_variant.lower():
                continue
            digest = str(entry.get("digest") or "").strip()
            if digest:
                return f"{image_name}@{digest}"

    digest = str(manifest.get("digest") or "").strip()
    if digest:
        return f"{image_name}@{digest}"

    if required:
        raise click.ClickException(f"Failed to resolve registry digest for {image_ref}")
    return image_ref


def _render_trusted_signers(trusted_signers: list[Dict[str, Any]]) -> Table:
    table = Table(title="Trusted Signers", show_lines=False)
    table.add_column("Type", style="cyan", no_wrap=True)
    table.add_column("Source", style="green")
    table.add_column("Details", style="magenta")

    for signer in trusted_signers:
        signer_type = str(signer.get("type") or signer.get("signer_type") or "unknown")
        details = (
            signer.get("identityRegexp")
            or signer.get("identity_regexp")
            or signer.get("publicKeyFingerprint")
            or signer.get("public_key_fingerprint")
            or signer.get("keyId")
            or signer.get("key_id")
            or ""
        )
        table.add_row(signer_type, str(signer.get("source") or ""), str(details))
    return table


def _normalized_publish_status(payload: Dict[str, Any]) -> str:
    status = payload.get("status")
    if status:
        return str(status).lower()

    state = payload.get("state")
    if not state:
        return ""
    state_normalized = str(state).strip().lower()
    state_map = {
        "submitted": "in_progress",
        "pending": "in_progress",
        "approved": "completed",
        "completed": "completed",
        "failed": "failed",
        "cancelled": "cancelled",
        "rejected": "rejected",
    }
    return state_map.get(state_normalized, state_normalized)


def _resolve_module_runtime_image(explicit_image: Optional[str] = None) -> str:
    candidate = explicit_image or os.getenv(MODULE_RUNTIME_IMAGE_ENVVAR) or DEFAULT_MODULE_RUNTIME_IMAGE
    return str(candidate).strip()


def _requirements_file_has_hashes(path: Path) -> bool:
    try:
        return any("--hash=" in line for line in path.read_text(encoding="utf-8").splitlines())
    except FileNotFoundError:
        return False


def _build_oci_labels(manifest: Dict[str, Any]) -> Dict[str, str]:
    labels = {
        "org.opencontainers.image.title": str(manifest.get("name") or "hla-compass-module"),
        "org.opencontainers.image.version": str(manifest.get("version") or "0.0.0"),
        "org.opencontainers.image.description": str(manifest.get("description") or "HLA-Compass module image"),
    }
    repository = str(os.getenv("GITHUB_REPOSITORY") or "").strip()
    if repository:
        labels["org.opencontainers.image.source"] = f"https://github.com/{repository}"
    revision = str(os.getenv("GITHUB_SHA") or "").strip()
    if revision:
        labels["org.opencontainers.image.revision"] = revision
    return labels


def _render_publish_status_table(status_payload: Dict[str, Any]) -> Table:
    table = Table(title="Publish Status", show_lines=False)
    table.add_column("Field", style="cyan", no_wrap=True)
    table.add_column("Value", style="green")
    for key in (
        "publishId",
        "status",
        "state",
        "buildStatus",
        "currentPhase",
        "failurePhase",
        "failureReason",
        "trustPolicySource",
        "startTime",
        "endTime",
        "message",
        "logsUrl",
    ):
        if key in status_payload and status_payload.get(key) is not None:
            table.add_row(key, str(status_payload.get(key)))

    security_scan = status_payload.get("securityScan")
    if isinstance(security_scan, dict):
        summary = security_scan.get("severityCounts") or {}
        table.add_row("securityScan.status", str(security_scan.get("status")))
        if security_scan.get("failedSeverities"):
            table.add_row("securityScan.failed", ", ".join(security_scan.get("failedSeverities") or []))
        if security_scan.get("message"):
            table.add_row("securityScan.message", str(security_scan.get("message")))
        for severity, count in summary.items():
            table.add_row(f"securityScan.{severity}", str(count))

    return table


@click.command()
@verbose_option
@click.argument("name", required=False)
@click.option(
    "--template",
    type=click.Choice(["ui", "no-ui", "mcp"]),
    default="no-ui",
    help="Module template (no-ui, ui, or mcp for AI-consumable modules)",
)
@click.option("--yes", is_flag=True, help="Non-interactive mode")
@click.pass_context
def init(ctx, name, template, yes):
    """Create a new HLA-Compass module"""
    _ensure_verbose(ctx)
    if not name:
        console.print("[red]Module name required[/red]")
        sys.exit(1)

    # Validate name
    import re

    name_pattern = r"^[a-z0-9]([a-z0-9-]{1,48}[a-z0-9])?$"
    if not re.match(name_pattern, name):
        console.print(f"[red]Invalid module name '{name}'[/red]")
        console.print(f"Name must match regex: {name_pattern}")
        console.print("(Lowercase alphanumeric, hyphens, 2-50 chars, start/end with alphanumeric)")
        sys.exit(1)

    module_type = "with-ui" if template == "ui" else "no-ui"
    template_dir_name = f"{template}-template" if template != "mcp" else "mcp-template"

    # Locate template relative to hla_compass package (parent of this cli package)
    # hla_compass/cli/module.py -> hla_compass/templates
    # We need to go up two levels to sdk/python/hla_compass
    # Or better, use importlib.resources (Python 3.9+) or __file__ relative
    base_path = Path(__file__).parent.parent
    pkg_templates_dir = base_path / "templates" / template_dir_name

    if not pkg_templates_dir.exists():
        console.print(f"[red]Template not found at {pkg_templates_dir}[/red]")
        sys.exit(1)

    module_dir = Path(name)
    if module_dir.exists() and not yes:
        if not Confirm.ask(f"Directory '{name}' exists. Continue?"):
            return

    shutil.copytree(
        pkg_templates_dir,
        module_dir,
        dirs_exist_ok=True,
        ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store"),
    )

    # Update manifest
    manifest_path = module_dir / "manifest.json"
    with open(manifest_path) as f:
        manifest = json.load(f)

    manifest["name"] = name
    manifest["type"] = module_type
    sdk_config = load_sdk_config()
    author_info = sdk_config.get("author", {}) if sdk_config else {}

    # Try to get author info from git if not in config
    author_name = author_info.get("name")
    author_email = author_info.get("email")

    if not author_name:
        try:
            author_name = subprocess.check_output(["git", "config", "user.name"], text=True).strip()
        except Exception:
            author_name = os.getenv("USER", "Unknown")

    if not author_email:
        try:
            author_email = subprocess.check_output(["git", "config", "user.email"], text=True).strip()
        except Exception:
            author_email = "developer@example.com"

    manifest["author"]["name"] = author_name
    manifest["author"]["email"] = author_email

    with open(manifest_path, "w") as f:
        json.dump(manifest, f, indent=2)

    if module_type == "no-ui":
        shutil.rmtree(module_dir / "frontend", ignore_errors=True)

    # Pin the runtime SDK dependency inside the generated module to match the CLI SDK version.
    # This keeps `hla-compass init` outputs compatible with the CLI used to create them.
    requirements_path = module_dir / "backend" / "requirements.txt"
    requirements_lock_path = module_dir / "backend" / "requirements.lock.txt"
    if requirements_path.exists():
        try:
            from .._version import __version__ as sdk_version

            lines = requirements_path.read_text(encoding="utf-8").splitlines()
            rewritten: list[str] = []
            replaced = False
            for line in lines:
                stripped = line.strip()
                if (
                    stripped in {"hla-compass", "hla_compass"}
                    or stripped.startswith("hla-compass==")
                    or stripped.startswith("hla-compass>=")
                ):
                    rewritten.append(f"hla-compass=={sdk_version}")
                    replaced = True
                else:
                    rewritten.append(line)

            if not replaced:
                rewritten.insert(0, f"hla-compass=={sdk_version}")

            requirements_path.write_text("\n".join(rewritten) + "\n", encoding="utf-8")
            if not requirements_lock_path.exists():
                requirements_lock_path.write_text("\n".join(rewritten) + "\n", encoding="utf-8")
        except Exception as e:
            console.print(f"[yellow]Warning: failed to pin hla-compass dependency in requirements.txt: {e}[/yellow]")

    console.print(f"[green]✓ Module '{name}' created![/green]")


SUPPORTED_COMPUTE_TYPES = {"docker", "fargate", "batch", "lambda", "sagemaker"}


def _require_supported_compute(manifest: Dict[str, Any]) -> None:
    ctype = manifest.get("computeType") or "docker"
    if ctype not in SUPPORTED_COMPUTE_TYPES:
        console.print(f"[red]Compute type '{ctype}' is not supported by the CLI yet.[/red]")
        console.print(f"Supported: {', '.join(sorted(SUPPORTED_COMPUTE_TYPES))}")
        sys.exit(1)


def _normalize_compute_for_api(manifest: Dict[str, Any]) -> Dict[str, Any]:
    """
    Map CLI-friendly compute values to backend-accepted values.

    The platform currently expects fargate for container modules; if the manifest
    uses docker we map it to fargate before submission.
    """
    mapped = dict(manifest)
    if mapped.get("computeType") == "docker":
        mapped["computeType"] = "fargate"
        console.print("[dim]Mapped computeType 'docker' -> 'fargate' for platform compatibility.[/dim]")
    return mapped


@click.command()
@verbose_option
@click.option("--tag", help="Docker image tag")
@click.option("--registry", help="Registry prefix")
@click.option(
    "--platform",
    default="linux/amd64",
    show_default=True,
    help="Docker build platform(s), e.g. linux/amd64 or linux/amd64,linux/arm64",
)
@click.option(
    "--sdk-path",
    type=click.Path(path_type=Path),
    envvar="HLA_COMPASS_SDK_PATH",
    help="Install the SDK from a local path inside the image (for development)",
)
@click.option(
    "--base-image",
    envvar=MODULE_RUNTIME_IMAGE_ENVVAR,
    help="Platform runtime base image (defaults to the managed module runtime image)",
)
@click.option("--push", is_flag=True, help="Push image")
@click.pass_context
def build(ctx, tag, registry, platform, sdk_path, base_image, push):
    """Build module container"""
    _ensure_verbose(ctx)
    ensure_docker_available()

    manifest_path = Path("manifest.json")
    if not manifest_path.exists():
        console.print("[red]manifest.json not found[/red]")
        sys.exit(1)

    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    _require_supported_compute(manifest)
    module_name = manifest.get("name", "module")
    version = manifest.get("version", "0.0.0")

    # Default tag logic
    def _sanitize(v):
        return "".join(c if c.isalnum() or c in "-_." else "-" for c in v.lower()).strip("-.")

    default_tag = f"{_sanitize(module_name)}:{_sanitize(version)}"
    image_tag = tag or default_tag

    local_tag = image_tag
    registry_tag = None
    if registry:
        registry = registry.rstrip("/")
        # Check if the registry string already contains the repository name (common in this platform)
        # If so, we should use tags to distinguish modules instead of sub-repositories
        if "/" not in image_tag.split(":")[0]:
            # Convert "module:version" to "module-version" for the tag
            # This ensures we push to the single 'registry' repo with a unique tag
            # Ensure the tag is sanitized to prevent confusion with registry separators
            safe_tag = image_tag.replace("/", "-")
            tag_suffix = safe_tag.replace(":", "-")
            registry_tag = f"{registry}:{tag_suffix}"
        else:
            # If image_tag already has a slash, assume user knows what they are doing (custom full path)
            registry_tag = image_tag
            local_tag = image_tag  # Use full reference locally too if provided

    # NOTE: The suffix replacement logic above (replacing ':' with '-') is crucial for
    # maintaining a clean repository structure where all module versions are stored
    # as tags within a single repository per module, rather than creating separate
    # repositories for each version. This simplifies ECR management and access control.

    dist_dir = Path("dist")
    dist_dir.mkdir(parents=True, exist_ok=True)

    sdk_source = _prepare_sdk_source(sdk_path, dist_dir)
    _prepare_backend_requirements(dist_dir, sdk_source)

    # Helper to write container scripts
    _write_container_serve_script(dist_dir)
    _write_container_runner_script(dist_dir)

    resolved_base_image = _resolve_module_runtime_image(base_image)
    dockerfile_path = dist_dir / "Dockerfile.hla"
    dockerfile_path.write_text(
        _generate_dockerfile_content(manifest, sdk_source, base_image=resolved_base_image),
        encoding="utf-8",
    )

    published_tag = registry_tag or local_tag
    oci_labels = _build_oci_labels(manifest)

    multi_platform = bool(platform and "," in platform)
    if multi_platform and not push:
        console.print("[red]Multi-arch builds require --push (or specify a single --platform).[/red]")
        sys.exit(1)

    if push and multi_platform:
        _ensure_registry_push_auth(published_tag)

    console.print(f"[cyan]Building {local_tag}...[/cyan]")
    if multi_platform:
        build_cmd = [
            "docker",
            "buildx",
            "build",
            "--platform",
            platform,
            "-f",
            str(dockerfile_path),
            "-t",
            published_tag,
            ".",
            "--push",
        ]
        for key, value in oci_labels.items():
            build_cmd.extend(["--label", f"{key}={value}"])
        subprocess.run(build_cmd, check=True)
    else:
        build_cmd = ["docker", "build"]
        if platform:
            build_cmd.extend(["--platform", platform])
        for key, value in oci_labels.items():
            build_cmd.extend(["--label", f"{key}={value}"])
        build_cmd.extend(["-f", str(dockerfile_path), "-t", local_tag, "."])
        subprocess.run(build_cmd, check=True)

    if registry_tag and not multi_platform:
        subprocess.run(["docker", "tag", local_tag, registry_tag], check=True)

    if push and not multi_platform:
        _ensure_registry_push_auth(published_tag)
        console.print(f"[cyan]Pushing {published_tag}...[/cyan]")
        try:
            subprocess.run(["docker", "push", published_tag], check=True)
        except subprocess.CalledProcessError as e:
            console.print(f"[red]Failed to push image: {e}[/red]")
            if "denied" in str(e) or "unauthorized" in str(e) or "forbidden" in str(e).lower():
                console.print("[yellow]Tip: Check your permissions. You might need to run:[/yellow]")
                console.print(f"  docker login {published_tag.split('/')[0]}")
            raise e

    # Report
    report = {
        "image_tag": published_tag if multi_platform else local_tag,
        "published_tag": published_tag,
        "pushed": push,
        "platform": platform,
        "base_image": resolved_base_image,
        "oci_labels": oci_labels,
        "sdk_source": sdk_source,
    }
    (dist_dir / "build.json").write_text(json.dumps(report, indent=2), encoding="utf-8")

    return report


@click.command(name="sign-image")
@verbose_option
@click.option("--image-ref", required=True, help="Image reference to sign")
@click.option(
    "--key-path",
    type=click.Path(path_type=Path),
    help="Cosign private key path (defaults to the SDK-managed local keypair)",
)
@click.option("--generate-keys", is_flag=True, help="Generate the default local cosign keypair if missing")
@click.pass_context
def sign_image(ctx, image_ref: str, key_path: Optional[Path], generate_keys: bool):
    """Sign a pre-built OCI image with the local cosign keypair."""
    _ensure_verbose(ctx)
    try:
        resolved_key = _resolve_cosign_private_key(
            key_path=key_path,
            generate_keys_if_missing=generate_keys,
        )
    except FileNotFoundError as exc:
        if key_path is None:
            console.print("[red]Cosign private key not found.[/red]")
            console.print("Re-run with --generate-keys or provide --key-path.")
        else:
            console.print(f"[red]{exc}[/red]")
        sys.exit(1)
    except Exception as exc:
        console.print(f"[red]Failed to generate cosign keypair: {exc}[/red]")
        sys.exit(1)

    signable_image_ref = _resolve_platform_specific_image_ref(image_ref, DEFAULT_PLATFORM_SELECTOR, required=False)
    try:
        sign_container_image(signable_image_ref, key_path=resolved_key)
    except (ContainerSigningError, FileNotFoundError, subprocess.CalledProcessError) as exc:
        console.print(f"[red]Failed to sign image: {exc}[/red]")
        sys.exit(1)

    public_key_path = default_public_key_path() if key_path is None else resolved_key.with_suffix(".pub")
    if public_key_path.exists():
        fingerprint = public_key_fingerprint(public_key_path.read_text(encoding="utf-8"))
        console.print(f"[green]✓ Signed image[/green] {signable_image_ref}")
        console.print(f"[dim]Public key:[/dim] {public_key_path}")
        console.print(f"[dim]Fingerprint:[/dim] {fingerprint}")
    else:
        console.print(f"[green]✓ Signed image[/green] {signable_image_ref}")


@click.command(name="verify-image")
@verbose_option
@click.option("--env", required=True, type=click.Choice(["dev", "staging", "prod"]))
@click.option("--image-ref", required=True, help="Image reference to verify")
@click.option("--manifest", default="manifest.json", show_default=True, help="Manifest path")
@click.option("--scope", type=click.Choice(["org", "public"]), default="org", show_default=True)
@click.option("--org-id", help="Target organization id")
@click.option("--generate-keys", is_flag=True, help="Auto-generate manifest signing keys if missing")
@click.pass_context
def verify_image(ctx, env: str, image_ref: str, manifest: str, scope: str, org_id: Optional[str], generate_keys: bool):
    """Verify an image against the platform's resolved signer and scan policy."""
    _ensure_verbose(ctx)
    manifest_path = Path(manifest)
    if not manifest_path.exists():
        console.print(f"[red]Manifest not found: {manifest_path}[/red]")
        sys.exit(1)

    with temporary_environment(env):
        selected_org_id = _resolve_publish_org_id(env, org_id)
        auth = Auth()
        if not auth.is_authenticated():
            console.print("[red]Not authenticated[/red]")
            sys.exit(1)

        raw_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
        _require_supported_compute(raw_manifest)
        validator = ModuleValidator(manifest_path=str(manifest_path))
        validation_result = validator.run()
        if not validation_result.valid:
            console.print("[red]Manifest validation failed[/red]")
            for issue in validation_result.issues:
                console.print(f"  {issue.code}: {issue.message}")
            sys.exit(1)

        manifest_for_api = _normalize_compute_for_api(raw_manifest)
        try:
            manifest_for_api, signing_status = _sign_manifest_for_publish(
                manifest_for_api,
                generate_keys=generate_keys,
                dry_run=True,
            )
        except FileNotFoundError:
            console.print("[red]Signing keys not found.[/red]")
            console.print("Run `hla-compass keys init` or re-run verify-image with --generate-keys.")
            sys.exit(1)

        payload = {
            "imageRef": image_ref,
            "manifest": manifest_for_api,
            "scope": scope,
            "orgId": selected_org_id,
            "dryRun": True,
        }
        client = APIClient()
        try:
            dry_run_result = client.register_container_module(payload)
        except APIError as exc:
            raise click.ClickException(f"Backend verification failed: {exc}") from exc

        trusted_signers = dry_run_result.get("trustedSigners") or []
        if not trusted_signers:
            raise click.ClickException("Backend dry-run did not return any trusted signers")

        console.print(f"[dim]Manifest signing:[/dim] {signing_status}")
        console.print(_render_trusted_signers(trusted_signers))

        try:
            resolved_image_ref = _resolve_platform_specific_image_ref(
                image_ref, DEFAULT_PLATFORM_SELECTOR, required=True
            )
            verification = verify_image_against_signers(resolved_image_ref, trusted_signers)
        except ContainerSigningError as exc:
            raise click.ClickException(str(exc)) from exc
        if not verification.get("verified"):
            raise click.ClickException(f"Image signature verification failed: {verification.get('error')}")

        console.print(
            f"[green]✓ Signature verified[/green] using {verification.get('signerType')} ({verification.get('source')})"
        )

        scan_result = scan_image_with_trivy(resolved_image_ref)
        if not scan_result.get("available"):
            raise click.ClickException("Trivy is required for verify-image but was not found in PATH")
        if scan_result.get("status") == "error":
            raise click.ClickException(str(scan_result.get("message") or "Trivy scan failed"))

        scan_table = Table(title="Security Scan", show_lines=False)
        scan_table.add_column("Field", style="cyan")
        scan_table.add_column("Value", style="green")
        scan_table.add_row("Status", str(scan_result.get("status")))
        severity_counts = scan_result.get("severityCounts") or {}
        for severity, count in severity_counts.items():
            scan_table.add_row(severity, str(count))
        console.print(scan_table)

        if scan_result.get("status") != "passed":
            failed = ", ".join(scan_result.get("failedSeverities") or [])
            raise click.ClickException(f"Image failed the platform security gate: {failed}")

        console.print("[green]✓ Image matches the current publish signer and scan policy[/green]")


# Helper functions for build
def _write_container_serve_script(dist_dir: Path):
    script = r'''#!/usr/bin/env python3
import json
import importlib
import logging
import mimetypes
import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse

MODULE_ENTRY = os.getenv("HLA_COMPASS_MODULE", None)
MANIFEST_PATH = Path("/app/manifest.json")
INDEX_HTML = """<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>HLA-Compass Module - Local Development</title>
</head>
<body>
  <div id='root'></div>
  <!-- Standalone bundle includes React, ReactDOM, and antd -->
  <script src='/bundle.standalone.js'></script>
</body>
</html>"""

def _configure_logging():
    root_logger = logging.getLogger()
    if root_logger.handlers:
        return
    level_name = os.getenv("LOG_LEVEL") or os.getenv("HLA_COMPASS_LOG_LEVEL") or "INFO"
    level = getattr(logging, level_name.upper(), logging.INFO)
    logging.basicConfig(
        level=level,
        format="%(asctime)s %(levelname)s %(name)s %(message)s",
        stream=sys.stdout,
    )

def _resolve_module(entry: str):
    if ":" not in entry: return None
    mod, cls = entry.split(":", 1)
    m = importlib.import_module(mod)
    return getattr(m, cls)()

def _load_manifest():
    try: return json.loads(MANIFEST_PATH.read_text())
    except: return {}

def _locate_ui_dist():
    candidates = [Path("/app/ui/dist"), Path("/app/frontend/dist")]
    for p in candidates:
        if p.exists(): return p
    return None

class _Handler(BaseHTTPRequestHandler):
    server_version = "hla-compass-serve/1.0"

    def log_message(self, fmt, *args):
        logging.getLogger("container-serve").info("%s - %s", self.address_string(), fmt % args)

    def _send(self, body: bytes, status: int, content_type: str):
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        if body:
            self.wfile.write(body)

    def _send_json(self, payload: dict, status: int = 200):
        body = json.dumps(payload).encode("utf-8")
        self._send(body, status, "application/json")

    def _send_text(self, text: str, status: int = 200, content_type: str = "text/html"):
        self._send(text.encode("utf-8"), status, content_type)

    def _serve_index(self, root: Path | None):
        if root:
            index_path = root / "index.html"
            if index_path.is_file():
                return self._serve_file(index_path)
        return self._send_text(INDEX_HTML)

    def _serve_file(self, path: Path):
        content_type, _ = mimetypes.guess_type(str(path))
        content_type = content_type or "application/octet-stream"
        self._send(path.read_bytes(), 200, content_type)

    def do_POST(self):
        if self.path != "/api/execute":
            return self._send_text("Not found", 404, "text/plain")
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length) if length else b""
        try:
            payload = json.loads(body.decode("utf-8")) if body else {}
        except Exception:
            payload = {}
        input_data = payload.get("input", {})
        offline_env = str(os.getenv("HLA_COMPASS_OFFLINE", "")).lower() in {"1", "true", "yes"}
        has_credentials = bool(os.getenv("HLA_API_KEY") or os.getenv("HLA_ACCESS_TOKEN"))
        context = {
            "mode": "interactive",
            "run_id": "serve-dev",
            "offline": offline_env or not has_credentials,
        }
        try:
            module = self.server.module
            if module is None:
                return self._send_json({"status": "error", "message": "Module entrypoint not found"}, 500)
            result = module.run(input_data, context)
            return self._send_json(result)
        except Exception as exc:
            return self._send_json({"status": "error", "message": str(exc)}, 500)

    def do_GET(self):
        path = urlparse(self.path).path or "/"
        if path.startswith("/api/"):
            return self._send_text("Not found", 404, "text/plain")
        root = self.server.ui_root
        if path == "/":
            return self._serve_index(root)
        if not root:
            return self._send_text("No UI")
        file_path = (root / path.lstrip("/")).resolve()
        try:
            file_path.relative_to(root)
        except Exception:
            return self._send_text("Not found", 404, "text/plain")
        if file_path.is_file():
            return self._serve_file(file_path)
        return self._serve_index(root)

class _Server(ThreadingHTTPServer):
    allow_reuse_address = True

def main():
    _configure_logging()
    manifest = _load_manifest()
    entry = MODULE_ENTRY or manifest.get("execution", {}).get("entrypoint") or "backend.main:Module"
    module = _resolve_module(entry)
    ui_root = _locate_ui_dist()
    port = int(os.getenv("PORT", 8080))
    server = _Server(("0.0.0.0", port), _Handler)
    server.module = module
    server.ui_root = ui_root
    server.serve_forever()

if __name__ == "__main__":
    main()
'''
    (dist_dir / "container-serve.py").write_text(script, encoding="utf-8")


def _write_container_runner_script(dist_dir: Path):
    script = r"""#!/usr/bin/env python3
import logging
import os
import sys

def _configure_logging():
    root_logger = logging.getLogger()
    if root_logger.handlers:
        return
    level_name = os.getenv("LOG_LEVEL") or os.getenv("HLA_COMPASS_LOG_LEVEL") or "INFO"
    level = getattr(logging, level_name.upper(), logging.INFO)
    logging.basicConfig(
        level=level,
        format="%(asctime)s %(levelname)s %(name)s %(message)s",
        stream=sys.stdout,
    )

def main():
    _configure_logging()
    from hla_compass.runtime.runner import main as runner_main
    runner_main()

if __name__ == "__main__":
    main()
"""
    (dist_dir / "container-runner.py").write_text(script, encoding="utf-8")


def _prepare_sdk_source(sdk_path: Optional[Path], dist_dir: Path) -> Optional[str]:
    if not sdk_path:
        existing = dist_dir / "sdk-src"
        if existing.exists():
            shutil.rmtree(existing)
        return None

    resolved = sdk_path.expanduser().resolve()
    if not resolved.exists():
        console.print(f"[red]SDK path not found: {resolved}[/red]")
        sys.exit(1)
    if not resolved.is_dir():
        console.print(f"[red]SDK path must be a directory: {resolved}[/red]")
        sys.exit(1)

    target = dist_dir / "sdk-src"
    if target.exists():
        shutil.rmtree(target)

    def _ignore_sdk(_path: str, names: list[str]) -> set[str]:
        ignore = {
            ".git",
            ".venv",
            "venv",
            "__pycache__",
            ".pytest_cache",
            ".mypy_cache",
            ".ruff_cache",
            ".tox",
            "dist",
            "build",
            ".idea",
            ".vscode",
        }
        return {name for name in names if name in ignore}

    shutil.copytree(resolved, target, symlinks=True, ignore=_ignore_sdk)
    return target.as_posix()


def _is_hla_compass_requirement(line: str) -> bool:
    stripped = line.strip()
    if not stripped or stripped.startswith("#"):
        return False
    lowered = stripped.lower()
    if lowered.startswith(("hla-compass", "hla_compass")):
        return True
    if "hla-compass" in lowered or "hla_compass" in lowered:
        if lowered.startswith(("-e", "--editable")):
            return True
    return False


def _prepare_backend_requirements(dist_dir: Path, sdk_source: Optional[str]) -> None:
    override_lock = dist_dir / "requirements.sdk.lock.txt"
    override_plain = dist_dir / "requirements.sdk.txt"
    for candidate in (override_lock, override_plain):
        if candidate.exists():
            candidate.unlink()

    if not sdk_source:
        return

    lock_path = Path("backend/requirements.lock.txt")
    req_path = Path("backend/requirements.txt")
    source_path = lock_path if lock_path.exists() else req_path
    if not source_path.exists():
        return

    lines = source_path.read_text(encoding="utf-8").splitlines()
    filtered = [line for line in lines if not _is_hla_compass_requirement(line)]
    target = override_lock if lock_path.exists() else override_plain
    target.write_text("\n".join(filtered) + "\n", encoding="utf-8")


def _generate_dockerfile_content(
    manifest,
    sdk_source: Optional[str] = None,
    *,
    base_image: Optional[str] = None,
):
    entry = manifest.get("execution", {}).get("entrypoint") or "backend.main:Module"
    resolved_base_image = _resolve_module_runtime_image(base_image)

    lines = [
        "# syntax=docker/dockerfile:1",
        f"ARG HLA_COMPASS_BASE_IMAGE={resolved_base_image}",
    ]

    has_frontend = Path("frontend/package.json").exists()
    if has_frontend:
        lines.extend(
            [
                "FROM node:20.19.6-alpine AS ui",
                "WORKDIR /ui",
                "COPY frontend/package*.json ./",
                "RUN npm install --legacy-peer-deps",
                "COPY frontend/ ./",
                "# Build both platform bundle (with externals) and standalone bundle (for local serve)",
                "RUN npm run build:all",
            ]
        )

    lines.extend(
        [
            "FROM ${HLA_COMPASS_BASE_IMAGE}",
            "WORKDIR /app",
        ]
    )

    if sdk_source:
        lines.append(f"COPY {sdk_source} /tmp/hla-compass-sdk")
        lines.append("RUN pip install --no-cache-dir /tmp/hla-compass-sdk")
    else:
        from .._version import __version__ as sdk_version

        lines.append(f"RUN pip install --no-cache-dir hla-compass=={sdk_version}")

    lines.append("COPY manifest.json /app/manifest.json")

    override_lock = Path("dist/requirements.sdk.lock.txt")
    override_plain = Path("dist/requirements.sdk.txt")
    if override_lock.exists():
        lines.append("COPY dist/requirements.sdk.lock.txt /tmp/reqs.lock.txt")
        if _requirements_file_has_hashes(override_lock):
            lines.append("RUN pip install --require-hashes -r /tmp/reqs.lock.txt")
        else:
            lines.append("RUN pip install -r /tmp/reqs.lock.txt")
    elif override_plain.exists():
        lines.append("COPY dist/requirements.sdk.txt /tmp/reqs.txt")
        lines.append("RUN pip install -r /tmp/reqs.txt")
    elif Path("backend/requirements.txt").exists():
        lock_path = Path("backend/requirements.lock.txt")
        if lock_path.exists():
            lines.append("COPY backend/requirements.lock.txt /tmp/reqs.lock.txt")
            if _requirements_file_has_hashes(lock_path):
                lines.append("RUN pip install --require-hashes -r /tmp/reqs.lock.txt")
            else:
                lines.append("RUN pip install -r /tmp/reqs.lock.txt")
        else:
            lines.append("COPY backend/requirements.txt /tmp/reqs.txt")
            lines.append("RUN pip install -r /tmp/reqs.txt")

    lines.append("COPY backend/ /app/backend/")

    if has_frontend:
        lines.extend(["RUN mkdir -p /app/ui", "COPY --from=ui /ui/dist /app/ui/dist"])

    lines.extend(
        [
            "ENV PYTHONPATH=/app",
            f"ENV HLA_COMPASS_MODULE={entry}",
            "COPY dist/container-serve.py /app/container-serve.py",
            "COPY dist/container-runner.py /app/container-runner.py",
            "EXPOSE 8080",
            'ENTRYPOINT ["python", "/app/container-runner.py"]',
        ]
    )

    return "\n".join(lines)


@click.command()
@verbose_option
@click.option("--env", required=True, type=click.Choice(["dev", "staging", "prod"]))
@click.option(
    "--image-ref",
    help="External image reference. The image must already be built, pushed, and GHCR/GitHub Actions signed.",
)
@click.option("--registry", help="Registry override")
@click.option(
    "--org-id",
    help="Target organization id. Defaults to the active organization for the selected environment.",
)
@click.option(
    "--platform",
    default="linux/amd64",
    show_default=True,
    help="Docker build platform(s), e.g. linux/amd64 or linux/amd64,linux/arm64",
)
@click.option(
    "--sdk-path",
    type=click.Path(path_type=Path),
    envvar="HLA_COMPASS_SDK_PATH",
    help="Install the SDK from a local path inside the image (for development)",
)
@click.option(
    "--scope",
    type=click.Choice(["org", "public"]),
    default="org",
    help="Module scope: 'org' (auto-approved, org-only) or 'public' (needs approval)",
)
@click.option("--visibility", hidden=True, help="Deprecated alias for --scope")
@click.option("--generate-keys", is_flag=True, help="Auto-generate signing keys if missing")
@click.option(
    "--dry-run",
    is_flag=True,
    help="Validate and show what would be published without making changes",
)
@click.option("--wait", is_flag=True, help="Poll until the publish job completes")
@click.option("--timeout", type=int, default=900, show_default=True, help="Max seconds to wait when --wait is set")
@click.option("--interval", type=int, default=10, show_default=True, help="Poll interval seconds when --wait is set")
@click.pass_context
def publish(
    ctx,
    env,
    image_ref,
    registry,
    org_id,
    platform,
    sdk_path,
    scope,
    visibility,
    generate_keys,
    dry_run,
    wait,
    timeout,
    interval,
):
    """Publish module to the HLA-Compass platform.

    Scope determines approval workflow:
    - org: Auto-approved, only your organization can use it
    - public: Requires superuser approval before others can use it
    """
    _ensure_verbose(ctx)
    with temporary_environment(env):
        # Handle deprecated visibility flag
        if visibility:
            console.print("[yellow]⚠️ The '--visibility' option is deprecated; please use '--scope' instead.[/yellow]")
            # If scope is default ("org") but visibility is set, map visibility to scope
            # We check if scope was explicitly provided to avoid overwriting it
            if ctx.get_parameter_source("scope").name == "DEFAULT":
                if visibility.lower() in ("private", "org"):
                    scope = "org"
                elif visibility.lower() == "public":
                    scope = "public"

        auth = Auth()
        if not auth.is_authenticated():
            console.print("[red]Not authenticated[/red]")
            sys.exit(1)

        selected_org_id = _resolve_publish_org_id(env, org_id)
        external_image_mode = bool(image_ref)

        if dry_run or ctx.obj.get("verbose"):
            mode_label = "external image" if external_image_mode else "managed build"
            console.print(f"[dim]Publish mode: {mode_label}[/dim]")
            if selected_org_id:
                console.print(f"[dim]Target organization: {selected_org_id}[/dim]")
            else:
                console.print("[dim]Target organization: default account organization[/dim]")

        # Fetch registry defaults only for managed publish mode
        if not external_image_mode and not registry:
            try:
                publish_config = get_publish_defaults(env)
                registry = publish_config.get("registry")
                if registry:
                    console.print(f"[dim]Using registry: {registry}[/dim]")
            except PublishConfigError as e:
                console.print(f"[yellow]Warning: Could not fetch publish config: {e}[/yellow]")

        client = APIClient()

        manifest = json.loads(Path("manifest.json").read_text())
        _require_supported_compute(manifest)

        # Map computeType for backend compatibility (docker -> fargate)
        # Sign the exact payload we submit to avoid signature mismatch.
        manifest_for_api = _normalize_compute_for_api(manifest)

        # Dry-run mode: validate and show what would happen
        if dry_run:
            console.print("[cyan]═══ DRY RUN MODE ═══[/cyan]")
            console.print()

            # Validate manifest
            validator = ModuleValidator(manifest_path="manifest.json")
            res = validator.run()
            if res.valid:
                console.print("[green]✓ Manifest validation passed[/green]")
            else:
                console.print("[red]✗ Manifest validation failed[/red]")
                for issue in res.issues:
                    console.print(f"  {issue.code}: {issue.message}")
                sys.exit(1)

            # Show computed values
            module_name = manifest.get("name", "unknown")
            version = manifest.get("version", "0.0.0")
            compute_type = manifest_for_api.get("computeType", "fargate")
            selected_manifest = manifest_for_api
            signing_status = None

            try:
                selected_manifest, signing_status = _sign_manifest_for_publish(
                    manifest_for_api,
                    generate_keys=generate_keys,
                    dry_run=True,
                )
            except FileNotFoundError:
                console.print("[red]Signing keys not found.[/red]")
                console.print("Run `hla-compass keys init` or re-run publish with --generate-keys.")
                sys.exit(1)

            table = Table(title="Publish Summary (Dry Run)", show_lines=False)
            table.add_column("Field", style="cyan")
            table.add_column("Value", style="green")
            table.add_row("Module", module_name)
            table.add_row("Version", version)
            table.add_row("Scope", scope)
            table.add_row("Environment", env)
            table.add_row("Organization", selected_org_id or "(default account org)")
            table.add_row("Mode", "external image" if external_image_mode else "managed build")
            table.add_row("Compute Type", compute_type)
            registry_label = registry or "(managed default)"
            if external_image_mode and image_ref and "/" in image_ref:
                registry_label = image_ref.split("/", 1)[0]
            table.add_row("Registry", registry_label)

            if image_ref:
                table.add_row("Image Ref", image_ref)
            else:
                # Compute what image tag would be
                safe_name = "".join(c if c.isalnum() or c in "-_." else "-" for c in module_name.lower()).strip("-.")
                safe_version = "".join(c if c.isalnum() or c in "-_." else "-" for c in version.lower()).strip("-.")
                computed_tag = f"{safe_name}:{safe_version}"
                if registry:
                    computed_tag = f"{registry}:{safe_name}-{safe_version}"
                table.add_row("Computed Tag", computed_tag)

            if signing_status:
                table.add_row("Signing", signing_status)

            if external_image_mode:
                payload = {
                    "imageRef": image_ref,
                    "manifest": selected_manifest,
                    "scope": scope,
                    "orgId": selected_org_id,
                    "dryRun": True,
                }
                try:
                    dry_run_result = client.register_container_module(payload)
                except APIError as e:
                    status = f"{e.status_code}" if getattr(e, "status_code", None) else "unknown"
                    console.print(table)
                    console.print()
                    console.print(f"[red]Dry-run failed ({status}): {e}[/red]")
                    console.print(
                        "[dim]Hint: External publishes require an org-approved namespace, a configured source "
                        "registry credential, and an image signed by a trusted signer for that org.[/dim]"
                    )
                    sys.exit(1)

                table.add_row("Backend Validation", dry_run_result.get("message", "Dry-run validation passed"))
                table.add_row("Backend Org", dry_run_result.get("orgId", selected_org_id or "(default account org)"))
                table.add_row(
                    "Registry Namespace",
                    dry_run_result.get("registryNamespace") or "(matched during publish)",
                )
                if dry_run_result.get("trustPolicySource"):
                    table.add_row("Trust Policy", dry_run_result["trustPolicySource"])

            console.print(table)
            console.print()
            console.print("[dim]No changes were made (--dry-run mode)[/dim]")
            return

        if not image_ref:
            # Build first
            report = ctx.invoke(build, push=True, registry=registry, platform=platform, sdk_path=sdk_path)
            image_ref = report.get("published_tag")
            try:
                signable_image_ref, image_signing_metadata, image_key_fingerprint = (
                    _managed_publish_image_signing_metadata(
                        image_ref=image_ref,
                        generate_keys_if_missing=generate_keys,
                        platform=platform,
                    )
                )
            except FileNotFoundError:
                console.print("[red]Cosign signing keys not found.[/red]")
                console.print("Re-run with --generate-keys or run `hla-compass sign-image` explicitly.")
                sys.exit(1)
            except (ContainerSigningError, subprocess.CalledProcessError) as exc:
                console.print(f"[red]Failed to sign managed publish image: {exc}[/red]")
                sys.exit(1)

            manifest_for_api = _attach_image_signing_metadata(manifest_for_api, image_signing_metadata)
            console.print(f"[dim]Signed managed publish image: {signable_image_ref}[/dim]")
            console.print(f"[dim]Image signing fingerprint: {image_key_fingerprint}[/dim]")

        try:
            manifest_for_api, _signing_status = _sign_manifest_for_publish(
                manifest_for_api,
                generate_keys=generate_keys,
                dry_run=False,
            )
        except FileNotFoundError:
            console.print("[red]Signing keys not found.[/red]")
            console.print("Run `hla-compass keys init` or re-run publish with --generate-keys.")
            sys.exit(1)

        payload = {"imageRef": image_ref, "manifest": manifest_for_api, "scope": scope, "orgId": selected_org_id}

        try:
            result = client.register_container_module(payload)
        except APIError as e:
            status = f"{e.status_code}" if getattr(e, "status_code", None) else "unknown"
            console.print(f"[red]Publish failed ({status}): {e}[/red]")
            if getattr(e, "status_code", None) == 401:
                console.print("[dim]Hint: Run `hla-compass auth login --env {env}` and retry.[/dim]".format(env=env))
            elif getattr(e, "status_code", None) == 403:
                console.print(
                    "[dim]Hint: Ensure your account/org has permission to publish modules in this environment.[/dim]"
                )
            if external_image_mode:
                console.print(
                    "[dim]Hint: External publishes require an org-approved namespace, a configured source "
                    "registry credential, and an image signed by a trusted signer for that org.[/dim]"
                )
            sys.exit(1)

        publish_id = result.get("publishId") if isinstance(result, dict) else None
        if publish_id:
            console.print(f"[dim]Publish job: {publish_id}[/dim]")

        if wait and publish_id:
            console.print(f"[dim]Waiting for publish job {publish_id}...[/dim]")
            deadline = time.time() + max(timeout, 30)
            final_status_payload: Optional[Dict[str, Any]] = None
            while True:
                status_payload = client.get_module_publish_status(publish_id)
                normalized = _normalized_publish_status(status_payload)
                console.print(_render_publish_status_table(status_payload))
                if normalized in TERMINAL_PUBLISH_STATUSES:
                    final_status_payload = status_payload
                    if normalized == "failed":
                        reason = status_payload.get("failureReason") or status_payload.get("message")
                        raise click.ClickException(
                            f"Publish job failed: {publish_id}{f' ({reason})' if reason else ''}"
                        )
                    if normalized == "cancelled":
                        raise click.ClickException(f"Publish job cancelled: {publish_id}")
                    if normalized == "rejected":
                        reason = status_payload.get("failureReason") or status_payload.get("message")
                        raise click.ClickException(
                            f"Publish job rejected: {publish_id}{f' ({reason})' if reason else ''}"
                        )
                    break
                if time.time() >= deadline:
                    raise click.ClickException(f"Timed out waiting for publish job {publish_id}")
                time.sleep(max(interval, 1))
            if final_status_payload is not None:
                result = final_status_payload

        # The intake API currently reports lifecycle via `status`, while some older
        # callers and tests still return `state`.
        state = (result.get("state") or "").upper() if isinstance(result, dict) else ""
        status = (result.get("status") or "").lower() if isinstance(result, dict) else ""

        if state == "SUBMITTED":
            console.print("[yellow]✓ Module submitted for approval (scope: public)[/yellow]")
            console.print("[dim]A superuser must approve before it's publicly available[/dim]")
        elif state == "APPROVED" or status in {"approved", "completed"}:
            console.print(f"[green]✓ Module published and approved (scope: {scope})[/green]")
        elif status in {"pending", "submitted", "in_progress"}:
            console.print("[yellow]✓ Publish job accepted[/yellow]")
            if publish_id:
                console.print(f"[dim]Track progress with: hla-compass publish-status --env {env} {publish_id}[/dim]")
        else:
            console.print(f"[green]✓ Module published to {env}[/green]")


@click.command(name="publish-status")
@verbose_option
@click.option("--env", type=click.Choice(["dev", "staging", "prod"]), help="Target environment")
@click.option("--watch", is_flag=True, help="Poll until the publish job completes")
@click.option(
    "--timeout",
    type=int,
    default=900,
    show_default=True,
    help="Max seconds to wait when --watch is set",
)
@click.option(
    "--interval",
    type=int,
    default=10,
    show_default=True,
    help="Poll interval seconds when --watch is set",
)
@click.argument("publish_id")
def publish_status(env: Optional[str], watch: bool, timeout: int, interval: int, publish_id: str):
    """Show (and optionally watch) module publish intake status."""
    with temporary_environment(env):
        client = APIClient()
        deadline = time.time() + max(timeout, 30)

        last_payload: Optional[Dict[str, Any]] = None

        def _fetch() -> Dict[str, Any]:
            return client.get_module_publish_status(publish_id)

        try:
            if not watch:
                payload = _fetch()
                console.print(_render_publish_status_table(payload))
                normalized = _normalized_publish_status(payload)
                if normalized == "failed":
                    reason = payload.get("failureReason") or payload.get("message")
                    if reason:
                        raise click.ClickException(f"Publish job failed: {publish_id} ({reason})")
                    raise click.ClickException(f"Publish job failed: {publish_id}")
                return

            with Live(console=console, refresh_per_second=4) as live:
                while True:
                    payload = _fetch()
                    last_payload = payload
                    live.update(_render_publish_status_table(payload))

                    normalized = _normalized_publish_status(payload)
                    if normalized in TERMINAL_PUBLISH_STATUSES:
                        break
                    if time.time() >= deadline:
                        raise click.ClickException(f"Timed out waiting for publish job {publish_id}")
                    time.sleep(max(interval, 1))

            normalized = _normalized_publish_status(last_payload or {})
            if normalized == "failed":
                reason = (last_payload or {}).get("failureReason") or (last_payload or {}).get("message")
                if reason:
                    raise click.ClickException(f"Publish job failed: {publish_id} ({reason})")
                raise click.ClickException(f"Publish job failed: {publish_id}")
            if normalized == "rejected":
                reason = (last_payload or {}).get("failureReason") or (last_payload or {}).get("message")
                if reason:
                    raise click.ClickException(f"Publish job rejected: {publish_id} ({reason})")
                raise click.ClickException(f"Publish job rejected: {publish_id}")
        except APIError as e:
            raise click.ClickException(str(e)) from e


@click.command()
@verbose_option
@click.option("--manifest", default="manifest.json")
@click.option(
    "--format",
    type=click.Choice(["text", "json"]),
    default="text",
    help="Output format",
)
@click.option("--strict", is_flag=True, help="Fail on warnings")
def validate(manifest, format, strict):
    """Validate manifest structure and contents."""
    validator = ModuleValidator(manifest_path=manifest)
    res = validator.run(strict=strict)
    is_valid = res.valid and (not strict or not res.issues)

    if format == "json":
        output = {
            "valid": is_valid,
            "issues": [i.to_dict() for i in res.issues],
        }
        click.echo(json.dumps(output, indent=2))
    else:
        if is_valid:
            console.print("[green]✓ Module is valid![/green]")
        else:
            console.print("[red]Module validation failed[/red]")

        for issue in res.issues:
            color = "red" if issue.severity == "error" else "yellow"
            console.print(f"[{color}]{issue.severity.upper()}: {issue.message}[/{color}]")
            if issue.pointer:
                console.print(f"  Location: {issue.pointer}")
            if issue.suggestion:
                console.print(f"  [bold]Suggestion:[/bold] {issue.suggestion}")
            console.print()

    if not is_valid:
        sys.exit(1)
