"""Single source of truth for write classification and policy guards.

Why this module exists separately from ``server.py``:

* ``server.py`` needs to classify tools (mutating vs read-only) for the confirm
  gate and the allow_writes gate.
* The same classification drives the pre/post-confirm guards.
* Hand-mirroring the tables in two places drifts. ``server.py`` IMPORTS from
  here, and identity is pinned by tests that assert both modules expose the
  same function objects.

Guard split (handoff lines 254-255): unconfirmed mutations must touch the
network **zero times**. So guards are split into:

* **pre-confirm** — local-only, 0 network calls. Runs before the user confirms.
* **post-confirm** — live reads allowed, after the user said "go". These check
  state that could have changed since the prompt was built.

Guard slots (handoff lines 257-262):

* **G3/G8 concurrent edit** — block if another user edited the bot/folder
  within N minutes. Clock basis is the response ``Date`` header (server clock).
  Same user is never blocked. → post-confirm (needs a live read).
* **G7 publish double-confirm** — bot execution / schedule creation requires
  ``confirm_environment='prod'`` ON TOP OF ``approve=true``. → pre-confirm
  (pure argument check, 0 network).
* **G9 same-name creation block** — block if an item with the same name already
  exists. → post-confirm (needs a list read).
* **G10 session identity** — block if the live session user differs from the
  declared owner. → pre-confirm (local session-summary check).

Fail mode (handoff line 263): **fail-open is the default.**
``A360_WRITE_GUARDS_FAIL=closed`` flips to fail-closed. A guard that cannot
reach a determination does not block under fail-open.

Every refusal states the next action (handoff line 265): no dead-end refusals.
"""

from __future__ import annotations

import logging
import os
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from ..auth.auth_manager import AuthManager
    from ..utils.config import ServerConfig

logger = logging.getLogger(__name__)

# --------------------------------------------------------------------------- #
# Single source of truth: write classification
# --------------------------------------------------------------------------- #
# server.py imports these. Do NOT mirror the tables in server.py — the
# identity-pin tests assert server.is_mutating_tool IS this module's function.

# A tool is mutating if its name starts with one of these prefixes. Prefixes
# keep the classification stable as new tools land without editing a name list
# every time.
MUTATING_TOOL_PREFIXES: tuple[str, ...] = (
    "deploy_",
    "publish_",
    "update_",
    "delete_",
    "create_",
    "cancel_",
    "stop_",
    "upload_",
    "rename_",
    "move_",
    "checkout_",
    "checkin_",
)

# Explicit mutating tool names that do not fit a clean prefix. Populated as
# Phase 6+ tools land (e.g., ``run_bot``, ``create_schedule``).
#
# Phase 9 browser tools: ``open_debug_window`` and ``close_debug_window`` are
# classified mutating because they have physical side effects (a Chromium
# process appears/disappears on the user's screen, and a Control Room session
# slot is claimed/released). The inspect_* browser tools stay read-only.
MUTATING_TOOL_NAMES: frozenset[str] = frozenset(
    {
        "open_debug_window",
        "close_debug_window",
    }
)

# Actions that are explicitly read-only even though they match a mutating
# prefix. ``run_diagnosis`` / ``diagnose_execution`` read execution history —
# they never execute a bot. Forward-looking: the ``run_`` prefix is NOT in
# MUTATING_TOOL_PREFIXES today, but if it is ever added these carve-outs must
# hold.
#
# Phase 9 browser inspect tools read the live window over CDP without ever
# synthesising input. They are read-only and need no confirm gate.
READ_ONLY_ACTIONS: frozenset[str] = frozenset(
    {
        "run_diagnosis",
        "diagnose_execution",
    }
)

# Publish-grade tools: the most destructive mutations (deploy to production,
# publish to repository, execute a bot, create a schedule). These require a
# SECOND confirmation field on top of ``approve=true``.
PUBLISH_GRADE_TOOL_PREFIXES: tuple[str, ...] = (
    "deploy_",
    "publish_",
)
PUBLISH_GRADE_TOOL_NAMES: frozenset[str] = frozenset(
    {
        # "run_bot", "create_schedule" — added when Phase 6 lands.
    }
)

# --------------------------------------------------------------------------- #
# Fail mode
# --------------------------------------------------------------------------- #

ENV_FAIL_MODE = "A360_WRITE_GUARDS_FAIL"
FAIL_OPEN = "open"
FAIL_CLOSED = "closed"

# Concurrent-edit window: block if another user modified within this many
# seconds. Used by G3/G8 once live reads are wired.
CONCURRENT_EDIT_WINDOW_SECONDS = 300  # 5 minutes

# The second confirmation field for publish-grade tools (handoff line 260).
ENVIRONMENT_CONFIRM_FIELD = "confirm_environment"
PROD_ENVIRONMENT_VALUE = "prod"

# Meta fields consumed by the guard layer; stripped before Pydantic validation
# because they are not part of any tool's params model. ``CONFIRM_FIELD`` is
# defined in server.py (the generic approve gate); we pair it with the
# environment field here so server.py can strip both via a single set.
GUARD_META_FIELDS: frozenset[str] = frozenset({ENVIRONMENT_CONFIRM_FIELD})


def get_fail_mode() -> str:
    """Read the fail-mode env var. Default is fail-open."""
    val = os.environ.get(ENV_FAIL_MODE, "").strip().lower()
    return FAIL_CLOSED if val == FAIL_CLOSED else FAIL_OPEN


def is_fail_open() -> bool:
    """True when guards should allow on uncertain (the default)."""
    return get_fail_mode() == FAIL_OPEN


def is_fail_closed() -> bool:
    """True when guards should block on uncertain (opt-in hardening)."""
    return get_fail_mode() == FAIL_CLOSED


# --------------------------------------------------------------------------- #
# Classification functions (imported by server.py)
# --------------------------------------------------------------------------- #


def is_mutating_tool(name: str) -> bool:
    """True if the tool can mutate Control Room state.

    READ_ONLY_ACTIONS is checked first so explicit carve-outs always win,
    even if a future prefix addition would otherwise match.
    """
    if name in READ_ONLY_ACTIONS:
        return False
    if name in MUTATING_TOOL_NAMES:
        return True
    return any(name.startswith(prefix) for prefix in MUTATING_TOOL_PREFIXES)


def is_read_only_call(name: str, arguments: dict[str, Any] | None = None) -> bool:
    """True when the call cannot mutate Control Room state."""
    return not is_mutating_tool(name)


def is_publish_grade_tool(name: str) -> bool:
    """True if the tool requires ``confirm_environment='prod'`` on top of approve."""
    if name in PUBLISH_GRADE_TOOL_NAMES:
        return True
    return any(name.startswith(prefix) for prefix in PUBLISH_GRADE_TOOL_PREFIXES)


# --------------------------------------------------------------------------- #
# Guard framework: pre-confirm (local) and post-confirm (live read)
# --------------------------------------------------------------------------- #


def run_pre_confirm_guards(
    name: str,
    arguments: dict[str, Any],
    *,
    config: ServerConfig,
    auth_manager: AuthManager,
) -> str | None:
    """Local-only checks run before the user confirms. **0 network calls.**

    Returns an error message (with a concrete next action) if blocked,
    ``None`` if the call may proceed.
    """
    # G7: publish-grade tools need confirm_environment='prod' in addition to
    # approve=true. This is a pure argument check — no network.
    error = _check_g7_publish_double_confirm(name, arguments)
    if error is not None:
        return error

    # G10: session identity verification (local).
    error = _check_g10_session_identity(name, arguments, config=config, auth_manager=auth_manager)
    if error is not None:
        return error

    return None


def run_post_confirm_guards(
    name: str,
    arguments: dict[str, Any],
    *,
    config: ServerConfig,
    auth_manager: AuthManager,
) -> str | None:
    """Live-read checks run after the user confirmed. May touch the network.

    Returns an error message (with a concrete next action) if blocked,
    ``None`` if the call may proceed.
    """
    # G3/G8: concurrent edit detection (live read of bot/folder metadata).
    error = _check_g3_g8_concurrent_edit(name, arguments, config=config, auth_manager=auth_manager)
    if error is not None:
        return error

    # G9: same-name creation block (live list read).
    error = _check_g9_same_name_block(name, arguments, config=config, auth_manager=auth_manager)
    if error is not None:
        return error

    return None


# --------------------------------------------------------------------------- #
# G7: publish double-confirm (FULLY IMPLEMENTED — pure argument check)
# --------------------------------------------------------------------------- #


def _check_g7_publish_double_confirm(name: str, arguments: dict[str, Any]) -> str | None:
    """Block publish-grade tools unless ``confirm_environment='prod'`` is set.

    This fires before the generic ``approve`` gate so the user sees BOTH
    required fields in a single error message.
    """
    if not is_publish_grade_tool(name):
        return None
    env_value = arguments.get(ENVIRONMENT_CONFIRM_FIELD)
    if env_value == PROD_ENVIRONMENT_VALUE:
        return None
    return (
        f"Tool '{name}' is publish-grade (deploys or publishes to production). "
        f"Set BOTH '{ENVIRONMENT_CONFIRM_FIELD}={PROD_ENVIRONMENT_VALUE}' AND "
        f"'approve=true' to confirm."
    )


# --------------------------------------------------------------------------- #
# G10: session identity (FRAMEWORK SLOT — partial local check, fail-open)
# --------------------------------------------------------------------------- #


def _check_g10_session_identity(
    name: str,
    arguments: dict[str, Any],
    *,
    config: ServerConfig,
    auth_manager: AuthManager,
) -> str | None:
    """Block if the live session user differs from the declared owner.

    Phase 5 scope: the token's username is set from ``config.auth.username``
    during authentication, so a local comparison is circular by construction.
    A verified ``/v2/userinfo``-style endpoint is needed to independently
    confirm the live session user. Until then this is a structural sanity
    check that fails open (the default) — it catches a misconfigured auth
    manager, not a session hijack.

    When fail-closed is set and the guard cannot verify identity, it blocks
    with a clear next action.
    """
    declared = getattr(config.auth, "username", "") or ""
    if not declared:
        # No declared owner at all is a misconfiguration, not an uncertain
        # check — block regardless of fail mode.
        return (
            "Session identity cannot be verified: config.auth.username is empty. "
            "Set a valid Control Room username in the server config."
        )

    # Try a local consistency check via session_summary (no network).
    try:
        summary = auth_manager.session_summary()
    except Exception:  # noqa: BLE001 - defensive: any failure is "uncertain"
        summary = None

    if not isinstance(summary, dict):
        if is_fail_closed():
            return (
                "Session identity cannot be verified (session summary unavailable) "
                "and fail-closed mode is active. Re-authenticate and retry."
            )
        logger.debug("G10 session identity: summary unavailable, failing open for '%s'", name)
        return None

    # The session budget tracks process owners, not usernames. We cannot
    # independently verify the live user locally. Fail open by design.
    logger.debug(
        "G10 session identity: declared='%s', summary owners=%s; "
        "live-user verification deferred (no userinfo endpoint), failing open",
        declared,
        summary.get("owners"),
    )
    return None


# --------------------------------------------------------------------------- #
# G3/G8: concurrent edit detection (FRAMEWORK SLOT — needs Phase 6 tools)
# --------------------------------------------------------------------------- #


def _check_g3_g8_concurrent_edit(
    name: str,
    arguments: dict[str, Any],
    *,
    config: ServerConfig,
    auth_manager: AuthManager,
) -> str | None:
    """Block if another user modified the target bot/folder within N minutes.

    Phase 5 scope: the guard framework is in place but the live read (fetch
    bot/folder metadata, inspect ``modifiedBy`` / ``modifiedOn`` against the
    response ``Date`` header) depends on Phase 6 repository tools. Until then
    the guard passes — there is nothing to check yet.

    When Phase 6 lands, this function will:
    1. Extract the target fileId/folderId from arguments.
    2. GET the metadata via auth_manager.make_request.
    3. Compare ``modifiedOn`` to the response ``Date`` header (server clock).
    4. Block if the delta is within CONCURRENT_EDIT_WINDOW_SECONDS AND
       ``modifiedBy`` differs from config.auth.username.
    """
    logger.debug(
        "G3/G8 concurrent edit: no repository target to check for '%s' "
        "(Phase 6 tools not yet wired), passing",
        name,
    )
    return None


# --------------------------------------------------------------------------- #
# G9: same-name creation block (FRAMEWORK SLOT — needs Phase 6 tools)
# --------------------------------------------------------------------------- #


def _check_g9_same_name_block(
    name: str,
    arguments: dict[str, Any],
    *,
    config: ServerConfig,
    auth_manager: AuthManager,
) -> str | None:
    """Block creation if an item with the same name already exists.

    Phase 5 scope: the guard framework is in place but the live list read
    (search the repository for a matching name) depends on Phase 6 tools.
    Until then the guard passes.

    When Phase 6 lands, this function will:
    1. Extract the proposed name from arguments.
    2. List existing items via auth_manager.make_request (POST .../list).
    3. Block if any item's name matches (case-insensitive on A360).
    """
    logger.debug(
        "G9 same-name block: no creation target to check for '%s' "
        "(Phase 6 tools not yet wired), passing",
        name,
    )
    return None
