"""Standard a3s-code AHP stdio harness bridged to ClawSentry Gateway."""

from __future__ import annotations

import argparse
import asyncio
import json
import logging
import os
import sys
from datetime import datetime
from typing import Any, Optional

try:
    from .a3s_adapter import A3SCodeAdapter
    from ..gateway.models import CanonicalDecision, DecisionVerdict
    from ..gateway.project_config import load_project_config, ProjectConfig
except ImportError:
    # Support direct script execution:
    # python src/clawsentry/adapters/a3s_gateway_harness.py
    from pathlib import Path

    _SRC_ROOT = str(Path(__file__).resolve().parent.parent.parent)
    if _SRC_ROOT not in sys.path:
        sys.path.insert(0, _SRC_ROOT)
    from clawsentry.adapters.a3s_adapter import A3SCodeAdapter  # type: ignore[no-redef]
    from clawsentry.gateway.models import CanonicalDecision, DecisionVerdict  # type: ignore[no-redef]
    from clawsentry.gateway.project_config import load_project_config, ProjectConfig  # type: ignore[no-redef]

import time as _time
from pathlib import Path as _Path

logger = logging.getLogger("a3s-gateway-harness")

# ---------------------------------------------------------------------------
# Project config cache (.clawsentry.toml) — avoid re-reading TOML on every
# tool call.  Keyed by cwd string, with a 60-second TTL.
# ---------------------------------------------------------------------------

_project_config_cache: dict[str, tuple[float, ProjectConfig]] = {}
_PROJECT_CONFIG_TTL = 60.0  # seconds


def _get_project_config(cwd: str) -> ProjectConfig:
    """Load project config with 60s TTL cache."""
    now = _time.monotonic()
    cached = _project_config_cache.get(cwd)
    if cached and (now - cached[0]) < _PROJECT_CONFIG_TTL:
        return cached[1]
    cfg = load_project_config(_Path(cwd))
    _project_config_cache[cwd] = (now, cfg)
    return cfg


_EVENT_TO_HOOK: dict[str, str] = {
    "pre_action": "PreToolUse",
    "pre_tool_use": "PreToolUse",
    "post_action": "PostToolUse",
    "post_tool_use": "PostToolUse",
    "pre_prompt": "PrePrompt",
    "generate_start": "GenerateStart",
    "session_start": "SessionStart",
    "session_end": "SessionEnd",
    "error": "OnError",
}


import re as _re

_CAMEL_RE1 = _re.compile(r"(?<=[a-z0-9])([A-Z])")
_CAMEL_RE2 = _re.compile(r"(?<=[A-Z])([A-Z][a-z])")


def _camel_to_snake(name: str) -> str:
    """Convert CamelCase to snake_case: PreToolUse -> pre_tool_use."""
    s = _CAMEL_RE1.sub(r"_\1", name)
    s = _CAMEL_RE2.sub(r"_\1", s)
    return s.lower()


def _log_stderr(msg: str) -> None:
    ts = datetime.now().strftime("%H:%M:%S")
    print(f"[{ts}] [a3s-gateway-harness] {msg}", file=sys.stderr, flush=True)


_DIAG_LOG = os.environ.get("CS_HARNESS_DIAG_LOG", "")


def _diag(msg: str) -> None:
    """Write diagnostic message to file if CS_HARNESS_DIAG_LOG is set."""
    if not _DIAG_LOG:
        return
    try:
        ts = datetime.now().strftime("%H:%M:%S.%f")
        with open(_DIAG_LOG, "a") as f:
            f.write(f"[{ts}] {msg}\n")
    except OSError:
        pass


def _resolve_payload(raw: Any) -> dict[str, Any]:
    if isinstance(raw, dict):
        payload = dict(raw)
    else:
        payload = {}

    if "arguments" not in payload and isinstance(payload.get("args"), dict):
        payload["arguments"] = payload["args"]

    if "tool" not in payload and isinstance(payload.get("tool_name"), str):
        payload["tool"] = payload["tool_name"]

    args = payload.get("arguments")
    if isinstance(args, dict):
        for key in ("command", "path", "target", "file_path"):
            if key in args and key not in payload:
                payload[key] = args[key]

    return payload


def _resolve_string(*values: Any) -> Optional[str]:
    for v in values:
        if isinstance(v, str) and v.strip():
            return v
    return None


def _decision_to_ahp_result(decision: CanonicalDecision) -> dict[str, Any]:
    action = "continue"
    if decision.decision == DecisionVerdict.BLOCK:
        action = "block"
    elif decision.decision == DecisionVerdict.MODIFY:
        action = "modify"
    elif decision.decision == DecisionVerdict.DEFER:
        action = "defer"

    result: dict[str, Any] = {
        "action": action,
        "decision": decision.decision.value,
        "reason": decision.reason,
        "metadata": {
            "source": "clawsentry-gateway-harness",
            "policy_id": decision.policy_id,
            "risk_level": decision.risk_level.value,
            "decision_source": decision.decision_source.value,
            "final": decision.final,
        },
    }
    if decision.modified_payload is not None:
        result["modified_payload"] = decision.modified_payload
    if decision.retry_after_ms is not None:
        result["retry_after_ms"] = decision.retry_after_ms

    return result


class A3SGatewayHarness:
    """Bridge AHP stdio requests to ClawSentry Gateway decisions."""

    def __init__(
        self,
        adapter: A3SCodeAdapter,
        *,
        protocol_version: str = "2.0",
        harness_name: str = "a3s-gateway-harness",
        harness_version: str = "1.0.0",
        default_session_id: str = "ahp-session",
        default_agent_id: str = "ahp-agent",
        async_mode: bool = False,
    ) -> None:
        self.adapter = adapter
        self.protocol_version = protocol_version
        self.harness_name = harness_name
        self.harness_version = harness_version
        self.default_session_id = default_session_id
        self.default_agent_id = default_agent_id
        self.async_mode = async_mode

    def _handshake_result(self) -> dict[str, Any]:
        return {
            "protocol_version": self.protocol_version,
            "harness_info": {
                "name": self.harness_name,
                "version": self.harness_version,
                "capabilities": [
                    "pre_action",
                    "post_action",
                    "pre_prompt",
                    "session",
                    "error",
                ],
            },
        }

    async def _handle_event(self, params: dict[str, Any]) -> dict[str, Any]:
        event_type_raw = str(params.get("event_type", "")).strip().lower()
        payload = _resolve_payload(params.get("payload"))

        # Check project config from payload cwd (covers JSON-RPC path)
        cwd = payload.get("cwd") or payload.get("working_directory", "")
        if cwd:
            project_cfg = _get_project_config(cwd)
            if not project_cfg.enabled:
                return {
                    "action": "continue",
                    "decision": "allow",
                    "reason": "project monitoring disabled via .clawsentry.toml",
                    "metadata": {"source": "clawsentry-gateway-harness"},
                }

        hook_type = _EVENT_TO_HOOK.get(event_type_raw)
        if hook_type is None:
            return {
                "action": "continue",
                "decision": "allow",
                "reason": f"Unmapped event_type: {event_type_raw or 'unknown'}",
                "metadata": {"source": "clawsentry-gateway-harness"},
            }

        session_id = _resolve_string(
            params.get("session_id"),
            params.get("sessionKey"),
            payload.get("session_id"),
            payload.get("sessionKey"),
            self.default_session_id,
        )
        agent_id = _resolve_string(
            params.get("agent_id"),
            params.get("agentId"),
            payload.get("agent_id"),
            payload.get("agentId"),
            self.default_agent_id,
        )

        # Inject project preset info into payload before normalization
        project_preset = params.get("_project_preset")
        project_overrides = params.get("_project_overrides")
        if project_preset or project_overrides:
            meta = payload.setdefault("_clawsentry_meta", {})
            if project_preset:
                meta["project_preset"] = project_preset
            if project_overrides:
                meta["project_overrides"] = project_overrides

        evt = self.adapter.normalize_hook_event(
            hook_type,
            payload,
            session_id=session_id,
            agent_id=agent_id,
        )
        if evt is None:
            return {
                "action": "continue",
                "decision": "allow",
                "reason": f"Event filtered: hook_type={hook_type}",
                "metadata": {"source": "clawsentry-gateway-harness"},
            }

        # Ensure project preset info survives normalization (adapter may
        # rebuild _clawsentry_meta, so merge it into the event payload).
        if (project_preset or project_overrides) and evt.payload is not None:
            meta = evt.payload.setdefault("_clawsentry_meta", {})
            if project_preset:
                meta["project_preset"] = project_preset
            if project_overrides:
                meta["project_overrides"] = project_overrides

        decision = await self.adapter.request_decision(evt)
        return _decision_to_ahp_result(decision)

    def _convert_native_hook(self, msg: dict[str, Any]) -> dict[str, Any]:
        """Convert native Claude Code hook JSON to harness event params.

        Claude Code sends hooks with this stdin format::

            {
                "hook_event_name": "PreToolUse",
                "tool_name": "Bash",
                "tool_input": {"command": "ls -la"},
                "session_id": "...",
                "cwd": "/workspace",
                ...
            }

        We need to map this to our internal params format::

            {
                "event_type": "pre_tool_use",
                "session_id": "...",
                "payload": {"tool": "Bash", "arguments": {"command": "ls -la"}, ...}
            }
        """
        params: dict[str, Any] = {}

        # event_type: Claude Code uses "hook_event_name", others use "event_type"/"hook_type"
        event_type = (
            msg.get("event_type")
            or msg.get("hook_event_name")
            or msg.get("hook_type", "")
        )
        # Normalize CamelCase: PreToolUse -> pre_tool_use
        if event_type and not event_type.islower():
            event_type = _camel_to_snake(event_type)
        params["event_type"] = event_type

        # payload: Claude Code sends tool_name/tool_input at top level, not nested
        payload = msg.get("payload")
        if payload is None:
            # Build payload from Claude Code's flat structure
            payload: dict[str, Any] = {}
            tool_name = msg.get("tool_name")
            if tool_name:
                payload["tool"] = tool_name
            tool_input = msg.get("tool_input")
            if isinstance(tool_input, dict):
                payload["arguments"] = tool_input
                # Lift common fields for risk assessment
                for key in ("command", "file_path", "path"):
                    if key in tool_input and key not in payload:
                        payload[key] = tool_input[key]
            # Carry over other context fields
            for key in ("cwd", "working_directory", "permission_mode", "transcript_path"):
                if key in msg:
                    payload[key] = msg[key]
        params["payload"] = payload

        # Lift session_id / agent_id to params level for _handle_event
        for key in ("session_id", "agent_id"):
            if key in msg:
                params[key] = msg[key]
            elif isinstance(payload, dict) and key in payload:
                params[key] = payload[key]

        return params

    async def dispatch_async(self, msg: dict[str, Any]) -> Optional[dict[str, Any]]:
        req_id = msg.get("id")
        method = msg.get("method")

        # --- JSON-RPC 2.0 path (a3s-code AHP protocol) ---
        if method is not None:
            params_raw = msg.get("params")
            params = params_raw if isinstance(params_raw, dict) else {}

            if method == "ahp/handshake":
                if req_id is None:
                    return None
                return {
                    "jsonrpc": "2.0",
                    "id": req_id,
                    "result": self._handshake_result(),
                }

            try:
                result = await self._handle_event(params)
            except Exception:  # noqa: BLE001
                logger.exception("Failed handling AHP event")
                if req_id is None:
                    return None
                return {
                    "jsonrpc": "2.0",
                    "id": req_id,
                    "error": {
                        "code": -32000,
                        "message": "AHP harness internal error",
                        "data": {"detail": "Internal harness error. Check server logs for details."},
                    },
                }

            if req_id is None:
                return None

            return {
                "jsonrpc": "2.0",
                "id": req_id,
                "result": result,
            }

        # --- Native hook path (Claude Code / direct hook command) ---
        params = self._convert_native_hook(msg)
        is_claude_code_hook = "hook_event_name" in msg

        # Check project config (.clawsentry.toml)
        cwd = msg.get("cwd") or msg.get("working_directory", "")
        if cwd:
            project_cfg = _get_project_config(cwd)
            if not project_cfg.enabled:
                _diag(f"project disabled via .clawsentry.toml at {cwd}")
                if is_claude_code_hook:
                    return None  # exit 0 = allow
                return {"result": {"action": "continue", "reason": "project monitoring disabled"}}
            # Attach preset info for Gateway to use
            if project_cfg.preset != "medium" or project_cfg.overrides:
                params["_project_preset"] = project_cfg.preset
                params["_project_overrides"] = project_cfg.overrides

        if self.async_mode:
            # Dispatch in background — don't block the hook
            asyncio.ensure_future(self._async_dispatch(params))
            if is_claude_code_hook:
                return None  # Claude Code: exit 0 = allow
            return {"result": {"action": "continue", "reason": "async: event dispatched"}}
        try:
            result = await self._handle_event(params)
        except Exception:  # noqa: BLE001
            logger.exception("Failed handling native hook event")
            if is_claude_code_hook:
                return None  # allow on error (fail-open for hooks)
            return {"result": {"action": "continue", "reason": "harness internal error"}}

        if is_claude_code_hook:
            return self._to_claude_code_response(result, msg.get("hook_event_name", ""))
        return {"result": result}

    def _to_claude_code_response(
        self, result: dict[str, Any], hook_event_name: str,
    ) -> dict[str, Any] | None:
        """Convert internal decision to Claude Code hook response format.

        Claude Code PreToolUse hooks control execution via:
        - Return None → exit 0 → allow
        - Return hookSpecificOutput with permissionDecision: "deny" → block
        - Exit code 2 → block (handled by run_stdio)

        We use the hookSpecificOutput approach for richer feedback.

        **Fail-open on gateway unreachable**: When the Gateway is down,
        fallback decisions (DEFER/BLOCK) would break the developer workflow
        by blocking ALL tool calls.  We fail-open and log a warning instead.
        """
        action = result.get("action", "continue")
        if action in ("continue", "allow"):
            return None  # exit 0 = allow

        metadata = result.get("metadata", {})
        policy_id = metadata.get("policy_id", "")

        # Fail-open when Gateway is unreachable — don't break developer workflow.
        # Fallback decisions have policy_id "fallback-fail-closed" or "fallback-defer".
        if policy_id.startswith("fallback-"):
            _log_stderr(
                f"Gateway unreachable — fail-open for {hook_event_name} "
                f"(would have been: {action})"
            )
            return None  # allow: monitoring is down, don't block tools

        if action in ("block", "defer"):
            reason = result.get("reason", "Blocked by ClawSentry security policy")
            risk_level = metadata.get("risk_level", "unknown")
            return {
                "hookSpecificOutput": {
                    "hookEventName": hook_event_name,
                    "permissionDecision": "deny",
                    "permissionDecisionReason": (
                        f"[ClawSentry] {reason} (risk: {risk_level})"
                    ),
                },
            }

        return None  # unknown action = allow

    async def _async_dispatch(self, params: dict[str, Any]) -> None:
        """Background dispatch to gateway. Errors are logged, not raised."""
        try:
            await self._handle_event(params)
        except Exception:  # noqa: BLE001
            logger.debug("Async dispatch failed (non-blocking)", exc_info=True)

    def run_stdio(self) -> None:
        _log_stderr("harness started")
        _diag(f"harness started (async={self.async_mode}, uds={self.adapter.uds_path})")
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        try:
            for raw_line in sys.stdin:
                line = raw_line.strip()
                if not line:
                    continue
                try:
                    msg = json.loads(line)
                except json.JSONDecodeError as exc:
                    _log_stderr(f"invalid json: {exc}")
                    _diag(f"invalid json: {exc}")
                    continue

                _diag(f"recv: hook_event={msg.get('hook_event_name', msg.get('method', '?'))} tool={msg.get('tool_name', '?')}")
                try:
                    response = loop.run_until_complete(self.dispatch_async(msg))
                except Exception as exc:
                    _diag(f"dispatch error: {exc}")
                    _log_stderr(f"dispatch error: {exc}")
                    continue
                _diag(f"response: {json.dumps(response, ensure_ascii=False) if response else 'None (allow)'}")
                if response is not None:
                    print(json.dumps(response, ensure_ascii=False), flush=True)
        except Exception as exc:
            _diag(f"run_stdio fatal: {exc}")
            raise
        finally:
            # Wait for any --async background tasks to complete
            pending = asyncio.all_tasks(loop)
            if pending:
                _diag(f"waiting for {len(pending)} async tasks")
                loop.run_until_complete(asyncio.wait(pending, timeout=5.0))
            loop.close()
            _diag("harness exited")


def _build_arg_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Run a3s-code AHP stdio harness bridged to ClawSentry Gateway."
    )
    parser.add_argument(
        "--uds-path",
        default=os.getenv("CS_UDS_PATH", "/tmp/clawsentry.sock"),
    )
    parser.add_argument(
        "--default-deadline-ms",
        type=int,
        default=int(os.getenv("A3S_GATEWAY_DEFAULT_DEADLINE_MS", "4500")),
    )
    parser.add_argument(
        "--max-rpc-retries",
        type=int,
        default=int(os.getenv("A3S_GATEWAY_MAX_RPC_RETRIES", "1")),
    )
    parser.add_argument(
        "--retry-backoff-ms",
        type=int,
        default=int(os.getenv("A3S_GATEWAY_RETRY_BACKOFF_MS", "50")),
    )
    parser.add_argument(
        "--framework",
        default=os.getenv("CS_FRAMEWORK", "a3s-code"),
        help="Source framework identifier (default: a3s-code).",
    )
    parser.add_argument(
        "--default-session-id",
        default=os.getenv("A3S_GATEWAY_DEFAULT_SESSION_ID", "ahp-session"),
    )
    parser.add_argument(
        "--default-agent-id",
        default=os.getenv("A3S_GATEWAY_DEFAULT_AGENT_ID", "ahp-agent"),
    )
    parser.add_argument(
        "--async",
        dest="async_mode",
        action="store_true",
        default=False,
        help="Return immediately for native hook events (fire-and-forget).",
    )
    return parser


def main() -> None:
    parser = _build_arg_parser()
    args = parser.parse_args()

    adapter = A3SCodeAdapter(
        uds_path=args.uds_path,
        default_deadline_ms=args.default_deadline_ms,
        max_rpc_retries=args.max_rpc_retries,
        retry_backoff_ms=args.retry_backoff_ms,
        source_framework=args.framework,
    )
    harness = A3SGatewayHarness(
        adapter,
        default_session_id=args.default_session_id,
        default_agent_id=args.default_agent_id,
        async_mode=args.async_mode,
    )
    harness.run_stdio()


if __name__ == "__main__":
    main()
