"""Client-side machinery for the Initial Workspace Template override.

This is the analyst-facing half of the per-instance template feature
(server half: ``app/api/initial_workspace.py`` + ``src/initial_workspace.py``).
When the operator has configured a template via ``/admin/server-config``,
``agnes init`` calls :func:`probe_status` and, on a configured response,
runs :func:`apply_override` instead of the default workspace generation.

Public entry points:

- :func:`probe_status` — early CLI probe; returns ``None`` on 404 so old
  servers fall through to the default ``agnes init`` flow.
- :func:`apply_override` — orchestrates download → confirm → extract →
  audit-event. Called from ``cli/commands/init.py`` when probe came back
  ``configured: true``.

OVERRIDE MODE — intentional behavior, NOT a bug.
When this flow runs, Agnes does NOT install hooks, slash commands, the
statusLine, or write ``.claude/CLAUDE.local.md`` / ``AGNES_WORKSPACE.md``.
Admin's repo is the sole source of truth for workspace contents. See
``docs/initial-workspace-override.md`` and CHANGELOG for the full
responsibility-transfer contract.
"""

from __future__ import annotations

import logging
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

import typer

from cli.client import api_get, api_post
from cli.error_render import render_error

logger = logging.getLogger(__name__)


@dataclass
class StatusInfo:
    """Parsed payload of ``GET /api/initial-workspace``."""

    configured: bool = False
    synced: bool = False
    template_source: Optional[str] = None
    template_sha: Optional[str] = None
    synced_at: Optional[str] = None
    files: list[str] = field(default_factory=list)


@dataclass
class ExtractResult:
    """Outcome of writing the zip into the workspace."""

    overwritten: list[str] = field(default_factory=list)
    created: list[str] = field(default_factory=list)


def probe_status(server_url: str, token: str) -> Optional[StatusInfo]:
    """Probe the server for an Initial Workspace Template registration.

    Returns ``None`` on 404 — old server that doesn't know the endpoint.
    The CLI then falls through to the existing default ``agnes init``
    flow without any user-visible noise. On any other non-200 status,
    raises ``typer.Exit(1)`` with a typed error rendered to stderr.
    """
    from cli.lib.pull import _override_server_env

    with _override_server_env(server_url, token):
        resp = api_get("/api/initial-workspace")
    if resp.status_code == 404:
        # Old server — endpoint doesn't exist. Silent fall-through to
        # default flow. The CLI must NOT emit anything user-visible here:
        # any "404" string in stderr is liable to be interpreted as an
        # error by a Claude Code session running `agnes init`.
        return None
    if resp.status_code == 401:
        typer.echo(
            render_error(
                401,
                {
                    "detail": {
                        "kind": "auth_failed",
                        "hint": f"Token expired or invalid — get a fresh one at {server_url}/setup",
                    }
                },
            ),
            err=True,
        )
        raise typer.Exit(1)
    if resp.status_code != 200:
        typer.echo(
            render_error(
                resp.status_code,
                {
                    "detail": {
                        "kind": "server_unreachable",
                        "hint": f"Unexpected status {resp.status_code} from /api/initial-workspace",
                    }
                },
            ),
            err=True,
        )
        raise typer.Exit(1)

    try:
        body = resp.json()
    except Exception:
        return StatusInfo(configured=False)

    return StatusInfo(
        configured=bool(body.get("configured")),
        synced=bool(body.get("synced")),
        template_source=body.get("template_source"),
        template_sha=body.get("template_sha"),
        synced_at=body.get("synced_at"),
        files=list(body.get("files") or []),
    )


def _classify_files(
    workspace: Path, server_files: list[str]
) -> tuple[list[str], list[str]]:
    """Split server-side file list into (will-overwrite, will-create)
    based on what's already on disk in ``workspace``.
    """
    overwrite: list[str] = []
    create: list[str] = []
    for rel in server_files:
        if (workspace / rel).exists():
            overwrite.append(rel)
        else:
            create.append(rel)
    return overwrite, create


def prompt_force_confirmation(
    workspace: Path,
    overwrite: list[str],
    create: list[str],
) -> bool:
    """Print warning + require literal ``YES`` to proceed.

    Returns True iff the operator typed ``YES`` (uppercase, stripped).
    Anything else aborts. We use uppercase-strict rather than a Y/N
    confirm so:
      (a) a fat-finger doesn't accidentally wipe a workspace
      (b) Claude Code sessions running ``agnes init`` are less likely
          to auto-acknowledge a destructive prompt
    """
    typer.echo("")
    typer.echo("⚠️  WARNING — Initial Workspace Template will be applied with --force.")
    typer.echo("")
    typer.echo(f"Workspace: {workspace}")
    typer.echo("")
    if overwrite:
        typer.echo(f"Files that will be OVERWRITTEN ({len(overwrite)}):")
        for rel in overwrite[:50]:
            typer.echo(f"  ~ {rel}")
        if len(overwrite) > 50:
            typer.echo(f"  … and {len(overwrite) - 50} more")
        typer.echo("")
    if create:
        typer.echo(f"Files that will be CREATED ({len(create)}):")
        for rel in create[:50]:
            typer.echo(f"  + {rel}")
        if len(create) > 50:
            typer.echo(f"  … and {len(create) - 50} more")
        typer.echo("")
    typer.echo("Files in your workspace that are NOT in the template will be preserved.")
    typer.echo("This action is irreversible (no backup) and will be logged on the server.")
    typer.echo("")
    response = typer.prompt(
        "Type YES to continue, anything else to abort",
        type=str,
        default="",
        show_default=False,
    )
    return response.strip() == "YES"


def download_zip(server_url: str, token: str) -> bytes:
    """Fetch ``GET /api/initial-workspace.zip`` and return the bytes.

    Raises ``typer.Exit(1)`` on any non-200 response with a typed error
    surfaced to stderr.
    """
    from cli.lib.pull import _override_server_env

    with _override_server_env(server_url, token):
        resp = api_get("/api/initial-workspace.zip")
    if resp.status_code == 503:
        typer.echo(
            render_error(
                503,
                {
                    "detail": {
                        "kind": "initial_workspace_not_synced",
                        "hint": "Admin must Sync now in /admin/server-config",
                    }
                },
            ),
            err=True,
        )
        raise typer.Exit(1)
    if resp.status_code != 200:
        typer.echo(
            render_error(
                resp.status_code,
                {
                    "detail": {
                        "kind": "initial_workspace_fetch_failed",
                        "hint": f"Unexpected status {resp.status_code} fetching zip",
                    }
                },
            ),
            err=True,
        )
        raise typer.Exit(1)
    return resp.content


def extract_zip_to_workspace(
    zip_bytes: bytes, workspace: Path
) -> ExtractResult:
    """Validate then extract the zip's entries into ``workspace``.

    Rejects entries with ``..``, absolute paths, or paths that escape
    ``workspace`` after resolution. (Server already validates on
    ``build_zip``; this is defense in depth — the bytes on the wire are
    untrusted from the CLI's perspective.)

    Returns an :class:`ExtractResult` so the caller can include real
    counts in the ``POST /api/initial-workspace/applied`` audit event.
    """
    overwritten: list[str] = []
    created: list[str] = []

    import io

    workspace = workspace.resolve()
    with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
        # Sanity-check every name before writing anything so we don't
        # end up with a half-extracted workspace if a bad entry is
        # somewhere in the middle of the archive.
        for info in zf.infolist():
            name = info.filename
            if not name or name.endswith("/"):
                continue
            if name.startswith("/") or ".." in name.split("/"):
                typer.echo(
                    render_error(
                        0,
                        {
                            "detail": {
                                "kind": "initial_workspace_unsafe_entry",
                                "hint": f"Zip entry {name!r} is unsafe — extraction aborted",
                            }
                        },
                    ),
                    err=True,
                )
                raise typer.Exit(1)
            target = (workspace / name).resolve()
            try:
                target.relative_to(workspace)
            except ValueError:
                typer.echo(
                    render_error(
                        0,
                        {
                            "detail": {
                                "kind": "initial_workspace_unsafe_entry",
                                "hint": f"Zip entry {name!r} escapes workspace — aborted",
                            }
                        },
                    ),
                    err=True,
                )
                raise typer.Exit(1)

        # All entries verified — now extract.
        for info in zf.infolist():
            name = info.filename
            if not name or name.endswith("/"):
                continue
            target = workspace / name
            if target.exists():
                overwritten.append(name)
            else:
                created.append(name)
            target.parent.mkdir(parents=True, exist_ok=True)
            with zf.open(info) as src, open(target, "wb") as dst:
                while True:
                    chunk = src.read(65536)
                    if not chunk:
                        break
                    dst.write(chunk)

    return ExtractResult(overwritten=sorted(overwritten), created=sorted(created))


def report_applied(
    server_url: str,
    token: str,
    *,
    mode: str,
    template_sha: Optional[str],
    overwritten_count: int,
    created_count: int,
) -> None:
    """Best-effort audit event. Failure logged but does NOT block init.

    The authoritative anchor is the server-side
    ``initial_workspace.fetch_started`` event written by ``GET .../zip``
    (PAT-holder cannot spoof). This call adds a confirmation row so
    operators can correlate "downloaded" with "actually applied".
    """
    from cli.lib.pull import _override_server_env

    payload = {
        "mode": mode,
        "template_sha": template_sha,
        "files_overwritten": overwritten_count,
        "files_created": created_count,
    }
    try:
        with _override_server_env(server_url, token):
            resp = api_post("/api/initial-workspace/applied", json=payload)
        if resp.status_code != 200:
            logger.warning(
                "audit event /applied returned %s: %s",
                resp.status_code,
                resp.text[:200],
            )
    except Exception:
        # Non-fatal — the workspace is on disk and the analyst can use it.
        logger.exception("audit event /applied failed")


def write_override_sentinel(
    workspace: Path,
    *,
    agnes_version: str,
    server_url: str,
    template_source: Optional[str],
    template_sha: Optional[str],
) -> None:
    """Write the extended sentinel that flags this workspace as an
    override workspace. Read by ``cli.lib.override.is_override_workspace``
    on every subsequent CLI invocation to short-circuit Agnes writers
    that would otherwise clobber admin's content.

    Path: ``<workspace>/.claude/init-complete``.
    """
    from datetime import datetime, timezone

    sentinel = workspace / ".claude" / "init-complete"
    sentinel.parent.mkdir(parents=True, exist_ok=True)
    sentinel.write_text(
        f"completed_at: {datetime.now(timezone.utc).isoformat()}\n"
        f"agnes_version: {agnes_version}\n"
        f"server_url: {server_url}\n"
        f"override: true\n"
        f"template_source: {template_source or ''}\n"
        f"template_sha: {template_sha or ''}\n",
        encoding="utf-8",
    )


def apply_override(
    workspace: Path,
    status: StatusInfo,
    server_url: str,
    token: str,
    *,
    force: bool,
    agnes_version: str,
) -> ExtractResult:
    """Top-level override flow.

    Pre-conditions enforced by the caller (``cli/commands/init.py``):
      * ``status.configured`` is True
      * The existing-workspace gate has been evaluated using
        :func:`cli.lib.override.is_override_workspace` — if the sentinel
        already says ``override: true`` and ``--force`` was NOT passed,
        the caller exits ``partial_state`` BEFORE invoking us.

    Steps:
      1. Download zip
      2. If ``force`` AND the workspace already has the override sentinel:
         classify files vs local FS, prompt for literal YES, abort on
         anything else.
      3. Extract zip into workspace.
      4. Write extended sentinel with ``override: true``.
      5. POST audit event (best-effort).

    Returns the :class:`ExtractResult` so the caller can include counts
    in its final summary.
    """
    from cli.lib.override import is_override_workspace

    if not status.synced:
        typer.echo(
            render_error(
                0,
                {
                    "detail": {
                        "kind": "initial_workspace_not_synced",
                        "hint": "Admin must Sync now in /admin/server-config",
                    }
                },
            ),
            err=True,
        )
        raise typer.Exit(1)

    # Confirmation gate — fires only when --force was used to overwrite
    # an existing override workspace. Fresh installs (no prior sentinel)
    # skip the confirmation; nothing to wipe.
    is_force_overwrite = force and is_override_workspace(workspace)
    if is_force_overwrite:
        overwrite, create = _classify_files(workspace, status.files)
        if not prompt_force_confirmation(workspace, overwrite, create):
            typer.echo("Aborted by user; workspace unchanged.", err=True)
            raise typer.Exit(1)

    zip_bytes = download_zip(server_url, token)
    result = extract_zip_to_workspace(zip_bytes, workspace)

    write_override_sentinel(
        workspace,
        agnes_version=agnes_version,
        server_url=server_url,
        template_source=status.template_source,
        template_sha=status.template_sha,
    )

    report_applied(
        server_url,
        token,
        mode="force_overwrite" if is_force_overwrite else "fresh_install",
        template_sha=status.template_sha,
        overwritten_count=len(result.overwritten),
        created_count=len(result.created),
    )

    return result
