"""Per-instance Initial Workspace Template — admin + analyst endpoints.

Config lives in ``${DATA_DIR}/state/instance.yaml`` under the
``initial_workspace:`` key. Token (PAT) lives in ``.env_overlay`` via
``app.secrets.persist_overlay_token``; YAML stores only the env-var name.

Endpoints:

  GET    /api/admin/initial-workspace          admin: read current config
  POST   /api/admin/initial-workspace          admin: register / edit
  DELETE /api/admin/initial-workspace          admin: remove
  POST   /api/admin/initial-workspace/sync     admin: manual "Sync now"

  GET    /api/initial-workspace                analyst (PAT): status + manifest
  GET    /api/initial-workspace.zip            analyst (PAT): content
  POST   /api/initial-workspace/applied        analyst (PAT): audit event

When admin registers a template, ``agnes init`` (new CLI flow) probes
``GET /api/initial-workspace``; on ``configured: true`` it downloads the
zip + extracts to the analyst's workspace, bypassing the default
Agnes-generated workspace files entirely. See ``docs/initial-workspace-override.md``
for the full responsibility-transfer contract.
"""

from __future__ import annotations

import logging
import os
from datetime import datetime, timezone
from typing import Any, Optional

import duckdb
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import RedirectResponse
from pydantic import BaseModel

from app.auth.access import require_admin
from app.auth.dependencies import _get_db, get_current_user, get_optional_user
from app.secrets import persist_overlay_token
from src.initial_workspace import (
    TemplateValidationError,
    build_zip,
    delete_template_dir,
    list_template_files,
    sync_template,
)
from src.repositories.audit import AuditRepository

logger = logging.getLogger(__name__)

# Two routers so the admin path can sit under one prefix and the
# analyst-facing path under another; both are registered in app/main.py.
router = APIRouter(tags=["initial_workspace"])

# Conventional env-var name for the singleton template PAT. Mirrors the
# marketplace pattern (`AGNES_MARKETPLACE_<SLUG>_TOKEN`) so an operator
# poking around ``.env_overlay`` can recognize the line at a glance.
_TOKEN_ENV_NAME = "AGNES_INITIAL_WORKSPACE_TOKEN"


# ---------------------------------------------------------------------------
# Pydantic shapes
# ---------------------------------------------------------------------------


class RegisterRequest(BaseModel):
    """Upsert payload — fields not provided are left untouched on update.

    ``token = None`` means "leave existing PAT alone".
    ``token = ""``   means "clear PAT".
    ``token = "ghp_..."`` means "set/rotate PAT".
    """

    url: str
    branch: Optional[str] = None
    token: Optional[str] = None


class AdminInitialWorkspaceResponse(BaseModel):
    """Admin-facing view; surfaces sync state + has_token (no secret leak)."""

    configured: bool = False
    url: Optional[str] = None
    branch: Optional[str] = None
    has_token: bool = False
    last_synced_at: Optional[str] = None
    last_commit_sha: Optional[str] = None
    last_error: Optional[str] = None
    file_count: int = 0


class AnalystInitialWorkspaceResponse(BaseModel):
    """PAT-authed analyst view; what ``agnes init`` consumes."""

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


class AppliedRequest(BaseModel):
    """CLI audit event after the analyst's workspace has been extracted."""

    mode: str  # "force_overwrite" | "fresh_install"
    template_sha: Optional[str] = None
    files_overwritten: int = 0
    files_created: int = 0


# ---------------------------------------------------------------------------
# YAML overlay read/write helpers
# ---------------------------------------------------------------------------


def _read_section() -> dict:
    """Return ``initial_workspace:`` section from the merged static + overlay
    instance.yaml, or empty dict when the section is absent.
    """
    from app.api.admin import _load_current_instance_yaml
    cfg = _load_current_instance_yaml()
    section = cfg.get("initial_workspace") if isinstance(cfg, dict) else None
    return section if isinstance(section, dict) else {}


def _write_section(patch: dict) -> dict:
    """Deep-merge ``patch`` into the ``initial_workspace:`` overlay section
    and write the file atomically. Returns the resulting section.

    Mirrors ``app/api/admin.py::update_server_config``'s read-modify-write
    sequence but scoped to one section: invalidate cache, read overlay,
    merge, atomic write, invalidate cache again. Serialized by the same
    ``_overlay_write_lock`` so concurrent saves from this endpoint AND
    from /admin/server-config don't race.
    """
    import yaml

    from app.api.admin import _deep_merge, _overlay_write_lock
    from app.instance_config import reset_cache
    from app.secrets import _state_dir

    config_path = _state_dir() / "instance.yaml"
    config_path.parent.mkdir(parents=True, exist_ok=True)

    with _overlay_write_lock:
        reset_cache()

        # Read existing overlay payload — never the merged static + overlay
        # view. Writing the merged result back would copy static keys (and
        # resolved env-var placeholders) into the overlay, shadowing future
        # updates to the static file. Same rationale as the marketplace +
        # server-config write paths.
        overlay_payload: dict[str, Any] = {}
        if config_path.exists():
            try:
                overlay_payload = yaml.safe_load(config_path.read_text()) or {}
            except Exception as e:
                logger.exception(
                    "initial-workspace: refusing to overwrite corrupt overlay at %s",
                    config_path,
                )
                raise HTTPException(
                    status_code=500,
                    detail=(
                        f"refusing to overwrite corrupt overlay at {config_path} ({e}); "
                        "back up and remove the file, or fix it by hand"
                    ),
                ) from e

        existing = overlay_payload.get("initial_workspace")
        if not isinstance(existing, dict):
            existing = {}
        merged = _deep_merge(existing, patch)
        overlay_payload["initial_workspace"] = merged

        tmp_path = config_path.with_suffix(config_path.suffix + ".tmp")
        tmp_path.write_text(
            yaml.dump(overlay_payload, default_flow_style=False, sort_keys=False)
        )
        os.replace(tmp_path, config_path)
        logger.info(
            "initial-workspace: wrote `initial_workspace:` section to %s",
            config_path,
        )

        reset_cache()
        return merged


def _drop_section() -> bool:
    """Remove the ``initial_workspace:`` section from the overlay file.
    Returns True iff a section was present and removed.
    """
    import yaml

    from app.api.admin import _overlay_write_lock
    from app.instance_config import reset_cache
    from app.secrets import _state_dir

    config_path = _state_dir() / "instance.yaml"
    if not config_path.exists():
        return False

    with _overlay_write_lock:
        reset_cache()
        try:
            overlay_payload = yaml.safe_load(config_path.read_text()) or {}
        except Exception as e:
            logger.exception(
                "initial-workspace: refusing to overwrite corrupt overlay at %s",
                config_path,
            )
            raise HTTPException(
                status_code=500,
                detail=(
                    f"refusing to overwrite corrupt overlay at {config_path} ({e}); "
                    "back up and remove the file, or fix it by hand"
                ),
            ) from e
        if "initial_workspace" not in overlay_payload:
            return False
        overlay_payload.pop("initial_workspace", None)
        tmp_path = config_path.with_suffix(config_path.suffix + ".tmp")
        tmp_path.write_text(
            yaml.dump(overlay_payload, default_flow_style=False, sort_keys=False)
        )
        os.replace(tmp_path, config_path)
        reset_cache()
        return True


# ---------------------------------------------------------------------------
# Audit helper
# ---------------------------------------------------------------------------


def _audit(
    conn: duckdb.DuckDBPyConnection,
    actor_id: Optional[str],
    action: str,
    params: Optional[dict] = None,
) -> None:
    """Same shape as ``app/api/marketplaces.py::_audit``. Best-effort —
    audit failure must never abort the actual operation.
    """
    try:
        safe_params: Optional[dict] = None
        if params:
            safe_params = {}
            for k, v in params.items():
                if isinstance(v, datetime):
                    safe_params[k] = v.isoformat()
                else:
                    safe_params[k] = v
        AuditRepository(conn).log(
            user_id=actor_id,
            action=action,
            resource="initial_workspace",
            params=safe_params,
        )
    except Exception:
        logger.exception("audit log write failed for %s", action)


def _section_to_admin_response(
    section: dict, file_count: int = 0
) -> AdminInitialWorkspaceResponse:
    if not section.get("url"):
        return AdminInitialWorkspaceResponse(configured=False)
    token_env = section.get("token_env") or ""
    has_token = bool(token_env) and bool(os.environ.get(token_env, ""))
    return AdminInitialWorkspaceResponse(
        configured=True,
        url=section.get("url"),
        branch=section.get("branch"),
        has_token=has_token,
        last_synced_at=section.get("last_synced_at"),
        last_commit_sha=section.get("last_commit_sha"),
        last_error=section.get("last_error"),
        file_count=file_count,
    )


# ---------------------------------------------------------------------------
# Admin endpoints
# ---------------------------------------------------------------------------


@router.get(
    "/api/admin/initial-workspace",
    response_model=AdminInitialWorkspaceResponse,
)
async def admin_get(
    user: dict = Depends(require_admin),
):
    """Return the current ``initial_workspace:`` config + sync state."""
    section = _read_section()
    file_count = len(list_template_files()) if section.get("last_commit_sha") else 0
    return _section_to_admin_response(section, file_count=file_count)


@router.post(
    "/api/admin/initial-workspace",
    response_model=AdminInitialWorkspaceResponse,
)
async def admin_post(
    body: RegisterRequest,
    user: dict = Depends(require_admin),
    conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
    """Register / update the template repo.

    Only ``url``, ``branch``, and ``token_env`` land in the YAML overlay.
    Sync state (``last_synced_at`` / ``last_commit_sha`` / ``last_error``)
    is written by ``POST .../sync``, not here — saving a config change
    should NOT silently invalidate the existing sync state.
    """
    url = (body.url or "").strip()
    if not url:
        raise HTTPException(status_code=422, detail="url is required")
    if not url.startswith("https://"):
        # Match the marketplace contract: HTTPS only. file://, ssh://, http://
        # are rejected outright at this layer rather than letting `git clone`
        # surface a less-clear failure later.
        raise HTTPException(
            status_code=422,
            detail="url must be https://",
        )

    patch: dict[str, Any] = {
        "url": url,
        "branch": (body.branch or "").strip() or None,
    }

    # Token routing — same three-state semantics as the marketplace POST:
    # None = leave alone, "" = clear, non-empty = rotate.
    token_changed: Optional[str] = None
    if body.token is not None:
        if body.token == "":
            persist_overlay_token(_TOKEN_ENV_NAME, None)
            patch["token_env"] = None
            token_changed = "cleared"
        else:
            persist_overlay_token(_TOKEN_ENV_NAME, body.token)
            patch["token_env"] = _TOKEN_ENV_NAME
            token_changed = "rotated"

    merged = _write_section(patch)

    _audit(
        conn,
        actor_id=user.get("id"),
        action="initial_workspace.register",
        params={
            "url": url,
            "branch": patch.get("branch"),
            "token": token_changed,
        },
    )

    file_count = (
        len(list_template_files()) if merged.get("last_commit_sha") else 0
    )
    return _section_to_admin_response(merged, file_count=file_count)


@router.delete("/api/admin/initial-workspace", status_code=204)
async def admin_delete(
    purge: bool = False,
    user: dict = Depends(require_admin),
    conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
    """Remove the ``initial_workspace:`` config + PAT.

    Optional ``?purge=true`` also wipes ``${DATA_DIR}/initial-workspace/``
    from disk. Default leaves the working copy in place so an admin can
    inspect / re-register the same URL without re-cloning.
    """
    section = _read_section()
    had_section = _drop_section()
    if section.get("token_env"):
        persist_overlay_token(section["token_env"], None)
    purged = False
    if purge:
        purged = delete_template_dir()

    _audit(
        conn,
        actor_id=user.get("id"),
        action="initial_workspace.delete",
        params={"purge": purge, "purged": purged, "had_section": had_section},
    )
    return Response(status_code=204)


@router.post("/api/admin/initial-workspace/sync")
async def admin_sync(
    user: dict = Depends(require_admin),
    conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
    """Manual "Sync now" — clone or fast-forward the template repo, then
    persist ``last_synced_at`` + ``last_commit_sha`` (or ``last_error``)
    back to the YAML overlay.

    Returns ``{action, commit_sha, file_count, path}`` on success or
    surfaces a ``400`` with the validation / git error so the admin sees
    it in the Sync-now modal. The error payload uses the typed-``kind``
    shape the CLI's error renderer already understands.
    """
    section = _read_section()
    if not section.get("url"):
        raise HTTPException(
            status_code=400,
            detail={"kind": "not_configured", "hint": "Register a repo first"},
        )

    try:
        result = sync_template(
            url=section["url"],
            branch=section.get("branch"),
            token_env=section.get("token_env"),
        )
    except TemplateValidationError as e:
        # Persist the error so the admin UI can render it on next page
        # load (the modal-result path also displays it inline). The repo
        # on disk stays as cloned so the admin can inspect what's there.
        _write_section({"last_error": str(e)})
        _audit(
            conn,
            actor_id=user.get("id"),
            action="initial_workspace.sync_failed",
            params={"error": str(e), "kind": "validation"},
        )
        raise HTTPException(
            status_code=400,
            detail={"kind": "template_invalid", "message": str(e)},
        ) from None
    except (RuntimeError, ValueError) as e:
        _write_section({"last_error": str(e)})
        _audit(
            conn,
            actor_id=user.get("id"),
            action="initial_workspace.sync_failed",
            params={"error": str(e), "kind": "git"},
        )
        raise HTTPException(
            status_code=400,
            detail={"kind": "git_failed", "message": str(e)},
        ) from None

    now_iso = datetime.now(timezone.utc).isoformat()
    _write_section({
        "last_synced_at": now_iso,
        "last_commit_sha": result["commit_sha"],
        "last_error": None,
    })

    _audit(
        conn,
        actor_id=user.get("id"),
        action="initial_workspace.sync",
        params={
            "commit_sha": result["commit_sha"],
            "file_count": result["file_count"],
        },
    )

    return {
        "action": "sync_ok",
        "commit_sha": result["commit_sha"],
        "file_count": result["file_count"],
        "path": result["path"],
        "synced_at": now_iso,
    }


# ---------------------------------------------------------------------------
# Analyst (PAT-authed) endpoints
# ---------------------------------------------------------------------------


@router.get(
    "/api/initial-workspace",
    response_model=AnalystInitialWorkspaceResponse,
)
async def analyst_status(
    user: dict = Depends(get_current_user),
):
    """Status probe consumed by ``agnes init``. Always 200.

    Returns ``configured: false`` when no template is registered (CLI then
    falls through to the existing default flow). Returns ``configured:
    true, synced: false`` when registered but never synced (or last sync
    failed); CLI shows a typed error pointing at /admin/server-config.
    Returns full metadata + manifest when configured + synced.
    """
    section = _read_section()
    if not section.get("url"):
        return AnalystInitialWorkspaceResponse(configured=False)
    synced = bool(section.get("last_commit_sha"))
    return AnalystInitialWorkspaceResponse(
        configured=True,
        synced=synced,
        template_source=section.get("url"),
        template_sha=section.get("last_commit_sha"),
        synced_at=section.get("last_synced_at"),
        files=list_template_files() if synced else [],
    )


@router.get("/api/initial-workspace.zip")
async def analyst_zip(
    request: Request,
    user: Optional[dict] = Depends(get_optional_user),
    conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
    """Return the zip of the cloned template tree (sans ``.git/``).

    Writes a server-side ``initial_workspace.fetch_started`` audit row so
    we have an authoritative event the analyst's PAT-holder cannot spoof
    (the matching ``initial_workspace.applied`` event from
    ``POST /applied`` is best-effort).

    404 when not configured (the CLI status probe should have caught
    this; defense in depth). 503 when configured but never synced — the
    CLI then surfaces a typed error pointing at "Sync now".
    """
    if user is None:
        # Browser → redirect to /login (target preserved via ?next=).
        # CLI / curl / API client → raw 401 they can handle.
        # This endpoint is the one `/api/*` URL designed to be hit directly
        # from a browser bookmark (analyst clean-install zip), so it
        # intentionally opts out of the global `_API_PATH_PREFIXES`
        # "never redirect /api/*" contract in `app/main.py`. Matching only
        # `text/html` — NOT `*/*` — mirrors `_wants_html()` in `app/main.py`:
        # `*/*` is curl's default and must keep getting the raw 401 so
        # tooling that parses `{"detail": "..."}` doesn't silently break.
        if "text/html" in request.headers.get("accept", ""):
            return RedirectResponse(
                url="/login?next=/api/initial-workspace.zip", status_code=302
            )
        raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")

    section = _read_section()
    if not section.get("url"):
        raise HTTPException(status_code=404, detail={"kind": "not_configured"})
    if not section.get("last_commit_sha"):
        raise HTTPException(
            status_code=503,
            detail={
                "kind": "initial_workspace_not_synced",
                "hint": "Admin must Sync now in /admin/server-config",
            },
        )

    try:
        data = build_zip()
    except TemplateValidationError as e:
        # Defense in depth — sync_template already validates, but a
        # manual edit on disk between sync and zip-fetch should fail
        # closed rather than serve invalid content.
        logger.warning("initial-workspace: build_zip validation failed: %s", e)
        raise HTTPException(
            status_code=500,
            detail={"kind": "template_invalid", "message": str(e)},
        ) from None

    sha = section["last_commit_sha"]
    _audit(
        conn,
        actor_id=user.get("id"),
        action="initial_workspace.fetch_started",
        params={"template_sha": sha, "byte_count": len(data)},
    )

    return Response(
        content=data,
        media_type="application/zip",
        headers={
            "ETag": f'"{sha}"',
            "Content-Disposition": 'attachment; filename="initial-workspace.zip"',
        },
    )


@router.post("/api/initial-workspace/applied")
async def analyst_applied(
    body: AppliedRequest,
    user: dict = Depends(get_current_user),
    conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
    """Best-effort audit event from ``agnes init`` confirming the
    analyst's workspace has been extracted + sentinel written.

    The authoritative anchor is the server-side
    ``initial_workspace.fetch_started`` event written by ``GET .../zip`` —
    a fetch_started without a matching applied = the analyst downloaded
    but never confirmed extraction (useful signal for operators).
    """
    if body.mode not in ("force_overwrite", "fresh_install"):
        raise HTTPException(
            status_code=422,
            detail=f"mode must be one of: force_overwrite, fresh_install (got {body.mode!r})",
        )
    _audit(
        conn,
        actor_id=user.get("id"),
        action="initial_workspace.applied",
        params={
            "mode": body.mode,
            "template_sha": body.template_sha,
            "files_overwritten": body.files_overwritten,
            "files_created": body.files_created,
        },
    )
    return {"status": "ok"}
