#!/usr/bin/env python3
"""PinSeek connector CLI for Muse and local AI agents.

Stdlib only. JSON envelope on stdout: {"ok": true, "body": ...} or
{"ok": false, "error": <code>, "message": <text>, "action_url"?: <url>}.
Talks only to PINSEEK_API_BASE. Never prints tokens.
"""
from __future__ import annotations

import argparse
import contextlib
import fcntl
import gzip
import hashlib
import http.client as http_client
import json
import math
import os
import secrets
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib
from pathlib import Path

DEFAULT_API_BASE = "https://app.pinseek.io"  # replaced at packaging time from PUBLIC_BASE_URL
API_BASE = os.environ.get("PINSEEK_API_BASE", DEFAULT_API_BASE).rstrip("/")
AGENT_DISTRIBUTION = Path(sys.argv[0]).name == "pinseek-agent"
_CLIENT_ID_OVERRIDE = os.environ.get("PINSEEK_CLIENT_ID")
CLIENT_ID = "pinseek-cli" if AGENT_DISTRIBUTION else (_CLIENT_ID_OVERRIDE or "muse")
SCOPES = "teetimes.read rules.write"
GRANT_DEVICE = "urn:ietf:params:oauth:grant-type:device_code"
HTTP_TIMEOUT_SECONDS = 25
USER_AGENT = "pinseek-connector/1"
IDEMPOTENCY_RETRY_WINDOW_SECONDS = 23 * 60 * 60  # API receipts are retained for 24 hours.


def credentials_path() -> Path:
    raw = os.environ.get("PINSEEK_CREDENTIALS_PATH")
    if raw:
        return Path(raw)
    try:
        filename = "agent-credentials.json" if AGENT_DISTRIBUTION else "credentials.json"
        return Path.home() / ".config" / "pinseek" / filename
    except RuntimeError as exc:
        raise CliError("credentials_unavailable", f"Could not determine the pinseek credentials path: {exc}")


def write_state_path() -> Path:
    """Keep write journals separate for Muse and packaged-agent OAuth clients."""
    path = credentials_path()
    client_tag = hashlib.sha256(CLIENT_ID.encode()).hexdigest()[:12]
    return path.with_name(f"{path.name}.{client_tag}.pending-write.json")


class CliError(Exception):
    def __init__(self, code: str, message: str, **extra):
        super().__init__(message)
        self.code = code
        self.message = message
        self.extra = extra

    def envelope(self) -> dict:
        return {"ok": False, "error": self.code, "message": self.message, **self.extra}


# ---------- credentials ----------

def _fsync_directory(path: Path) -> None:
    fd = os.open(str(path), os.O_RDONLY)
    try:
        os.fsync(fd)
    finally:
        os.close(fd)


def _ensure_credentials_directory(path: Path) -> None:
    missing = []
    parent = path
    while not parent.exists():
        missing.append(parent)
        parent = parent.parent
    path.mkdir(parents=True, exist_ok=True)
    # A first-use power loss must not erase the directory containing a
    # persisted in-flight marker. Sync each new entry in its parent.
    for created in reversed(missing):
        _fsync_directory(created.parent)

def load_credentials() -> dict:
    try:
        return json.loads(credentials_path().read_text())
    except FileNotFoundError:
        return {}
    except ValueError:
        return {}
    except OSError as exc:
        raise CliError("credentials_unavailable", f"Could not read pinseek credentials: {exc}")


def save_credentials(data: dict) -> None:
    path = credentials_path()
    try:
        _ensure_credentials_directory(path.parent)
        fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
        try:
            os.chmod(tmp_name, 0o600)
            with os.fdopen(fd, "w") as handle:
                json.dump(data, handle)
                handle.flush()
                os.fsync(handle.fileno())
            os.replace(tmp_name, path)
        except BaseException:
            with contextlib.suppress(FileNotFoundError):
                os.unlink(tmp_name)
            raise
        os.chmod(path, 0o600)
        # The in-flight markers must survive a crash once the token request
        # starts. fsync the directory so the atomic rename is durable too.
        _fsync_directory(path.parent)
    except OSError as exc:
        raise CliError("credentials_unavailable", f"Could not write pinseek credentials: {exc}")


@contextlib.contextmanager
def _write_state_lock():
    path = write_state_path()
    try:
        _ensure_credentials_directory(path.parent)
        lock_path = Path(str(path) + ".lock")
        fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT, 0o600)
    except OSError as exc:
        raise CliError("write_state_unavailable", f"Could not lock the local alert-write journal: {exc}")
    try:
        os.chmod(lock_path, 0o600)
        fcntl.flock(fd, fcntl.LOCK_EX)
    except OSError as exc:
        os.close(fd)
        raise CliError("write_state_unavailable", f"Could not lock the local alert-write journal: {exc}")
    try:
        yield
    finally:
        with contextlib.suppress(OSError):
            fcntl.flock(fd, fcntl.LOCK_UN)
        os.close(fd)


def load_write_state() -> dict | None:
    path = write_state_path()
    try:
        state = json.loads(path.read_text())
    except FileNotFoundError:
        return None
    except (OSError, ValueError) as exc:
        raise CliError("write_state_unavailable", f"Could not read the local alert-write journal: {exc}")
    if not isinstance(state, dict) or state.get("client_id") != CLIENT_ID or not isinstance(state.get("account_email"), str):
        raise CliError("write_state_unavailable", "The local alert-write journal is invalid or belongs to another client.")
    operation = state.get("operation")
    if not isinstance(operation, dict) or state.get("binding") != _write_binding(operation):
        raise CliError("write_state_unavailable", "The local alert-write journal failed its request-integrity check.")
    return state


def _write_binding(operation: dict) -> str:
    material = {key: operation.get(key) for key in ("method", "target", "body", "if_match")}
    return hashlib.sha256(json.dumps(material, sort_keys=True, separators=(",", ":")).encode()).hexdigest()


def save_write_state(state: dict) -> None:
    path = write_state_path()
    try:
        _ensure_credentials_directory(path.parent)
        fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
        try:
            os.chmod(tmp_name, 0o600)
            with os.fdopen(fd, "w") as handle:
                json.dump(state, handle, sort_keys=True, separators=(",", ":"))
                handle.flush()
                os.fsync(handle.fileno())
            os.replace(tmp_name, path)
        except BaseException:
            with contextlib.suppress(FileNotFoundError):
                os.unlink(tmp_name)
            raise
        os.chmod(path, 0o600)
        _fsync_directory(path.parent)
    except OSError as exc:
        raise CliError("write_state_unavailable", f"Could not persist the local alert-write journal: {exc}")


def clear_write_state() -> None:
    path = write_state_path()
    try:
        path.unlink()
        _fsync_directory(path.parent)
    except FileNotFoundError:
        pass
    except OSError as exc:
        raise CliError("write_state_unavailable", f"Could not clear the local alert-write journal: {exc}")


def clear_credentials() -> None:
    try:
        credentials_path().unlink()
    except FileNotFoundError:
        pass
    except OSError as exc:
        raise CliError("credentials_unavailable", f"Could not clear pinseek credentials: {exc}")


@contextlib.contextmanager
def _credentials_lock():
    """Serialize credential refreshes across concurrent CLI processes.

    The server revokes a token family if a rotated refresh token is
    replayed, so two processes refreshing at once must not both send the
    old refresh token. Holding an flock on a sidecar lock file while a
    refresh is in flight keeps that from happening.
    """
    path = credentials_path()
    _ensure_credentials_directory(path.parent)
    lock_path = Path(str(path) + ".lock")
    fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT, 0o600)
    try:
        os.chmod(lock_path, 0o600)
        fcntl.flock(fd, fcntl.LOCK_EX)
        yield
    finally:
        fcntl.flock(fd, fcntl.LOCK_UN)
        os.close(fd)


# ---------- http ----------

def _decode(response) -> dict:
    try:
        raw = response.read()
        if (response.headers.get("Content-Encoding") or "").lower() == "gzip":
            raw = gzip.decompress(raw)
    except http_client.IncompleteRead:
        # Do not include the exception text: it can contain the partial body.
        raise CliError("network_error", "Could not read a complete response from pinseek.")
    except (EOFError, gzip.BadGzipFile, zlib.error):
        raise CliError("network_error", "Could not decode a complete response from pinseek.")
    try:
        parsed = json.loads(raw or b"{}")
    except ValueError:
        return {"raw": raw.decode(errors="replace"), "__non_json__": True}
    return parsed if isinstance(parsed, dict) else {"raw": parsed}


class _NoRedirect(urllib.request.HTTPRedirectHandler):
    """Refuse to auto-follow redirects so we never forward a bearer token
    to a Location the server names, which could point off pinseek."""

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


_opener = urllib.request.build_opener(_NoRedirect())


def _require_secure_base() -> None:
    parsed = urllib.parse.urlparse(API_BASE)
    host = (parsed.hostname or "").lower()
    if "@" in parsed.netloc or "\\" in parsed.netloc:
        raise CliError("insecure_api_base", f"PINSEEK_API_BASE netloc must not contain '@' or '\\\\' (got {API_BASE!r}).")
    if parsed.scheme != "https" and host not in {"127.0.0.1", "localhost", "::1"}:
        raise CliError("insecure_api_base", f"PINSEEK_API_BASE must use https (got {API_BASE!r}).")


def http(method: str, path: str, *, form: dict | None = None, json_body: dict | None = None, token: str | None = None, headers_extra: dict[str, str] | None = None) -> tuple[int, dict]:
    headers = {"Accept": "application/json", "Accept-Encoding": "gzip", "User-Agent": USER_AGENT}
    data = None
    if form is not None:
        data = urllib.parse.urlencode(form).encode()
        headers["Content-Type"] = "application/x-www-form-urlencoded"
    elif json_body is not None:
        # Stable serialization keeps the exact persisted operation identical
        # on its explicit idempotent retry, including object-key order.
        data = json.dumps(json_body, sort_keys=True, separators=(",", ":")).encode()
        headers["Content-Type"] = "application/json"
    if token:
        headers["Authorization"] = f"Bearer {token}"
    headers.update(headers_extra or {})
    request = urllib.request.Request(API_BASE + path, data=data, method=method, headers=headers)
    try:
        with _opener.open(request, timeout=HTTP_TIMEOUT_SECONDS) as response:
            status, body, response_headers = response.status, _decode(response), response.headers
    except urllib.error.HTTPError as exc:
        if 300 <= exc.code < 400:
            location = exc.headers.get("Location") if exc.headers else None
            raise CliError("unexpected_redirect", f"pinseek redirected to {location}; refusing to forward credentials")
        status, body, response_headers = exc.code, _decode(exc), exc.headers
    except (urllib.error.URLError, TimeoutError, OSError) as exc:
        raise CliError("network_error", f"Could not reach pinseek: {exc}")

    if status == 429 and "retry_after" not in body:
        header_value = response_headers.get("Retry-After") if response_headers else None
        if header_value is not None:
            try:
                body["retry_after"] = int(header_value)
            except ValueError:
                pass
    if status < 300 and body.get("__non_json__"):
        raise CliError("service_unavailable", "pinseek returned a non-JSON response; try again shortly.")
    return status, body


def map_error(status: int, body: dict) -> CliError:
    code = str(body.get("error") or f"http_{status}")
    message = str(body.get("message") or body.get("error_description") or f"pinseek returned HTTP {status}")
    if status == 402:
        code = "membership_required"
    elif status == 429:
        code = "rate_limited"
    elif status == 422:
        code = "invalid_request"
    elif status in (502, 503, 504) and code != "provider_unavailable":
        # Only the API's own provider_unavailable means a booking system failed;
        # any other gateway error (a proxy's HTML 502, a deploy) is pinseek itself.
        code = "service_unavailable"
        message = "pinseek is temporarily unavailable; try again shortly"
    extra = {k: body[k] for k in ("action_url", "retry_after", "fields", "limit", "rule_id", "scope") if k in body}
    return CliError(code, message, **extra)


def _terminal_auth_failure(status: int, body: dict) -> bool:
    return status in (400, 401) and str(body.get("error")) in {"invalid_grant", "invalid_client", "unauthorized_client"}


def _require_certain_refresh(creds: dict) -> None:
    if creds.get("refresh_in_flight"):
        raise CliError(
            "refresh_outcome_unknown",
            "A token refresh may have completed. Disconnect this connection, then connect again; do not retry the old token.",
        )


def refresh_access(creds: dict) -> dict:
    with _credentials_lock():
        current = load_credentials()
        _require_certain_refresh(current)
        if not current.get("refresh_token"):
            raise CliError("not_connected", "pinseek is not connected. Run authorize-url to connect.")
        prior_expiry = float(creds.get("access_expires_at") or 0)
        current_expiry = float(current.get("access_expires_at") or 0)
        if current_expiry > prior_expiry and current_expiry > time.time() + 30:
            # Another process already refreshed while we waited for the lock.
            return current
        # Persist the uncertain state before sending a rotating token. If the
        # response is lost, replaying the old token revokes the whole family.
        save_credentials({**current, "refresh_in_flight": True})
        try:
            status, body = http("POST", "/oauth/token", form={
                "grant_type": "refresh_token",
                "refresh_token": current["refresh_token"],
                "client_id": CLIENT_ID,
            })
        except CliError as exc:
            raise CliError("refresh_outcome_unknown", "A token refresh may have completed. Disconnect this connection, then connect again; do not retry the old token.") from exc
        if status == 200 and body.get("access_token") and body.get("refresh_token"):
            new_creds = {
                "access_token": body["access_token"],
                "refresh_token": body["refresh_token"],
                "access_expires_at": time.time() + float(body.get("expires_in", 3600)),
                "scope": body.get("scope", current.get("scope", creds.get("scope"))),
            }
            save_credentials(new_creds)
            return new_creds
        if _terminal_auth_failure(status, body):
            clear_credentials()
            raise CliError("reauthorization_required", "Your pinseek connection has expired. Reconnect to continue.")
        # A nonterminal response cannot prove whether rotation happened. Keep
        # the marker so a later process cannot replay this refresh token.
        raise CliError("refresh_outcome_unknown", "A token refresh may have completed. Disconnect this connection, then connect again; do not retry the old token.")


def api(method: str, path: str, *, query: dict | None = None, json_body: dict | None = None, headers: dict[str, str] | None = None) -> dict:
    _require_secure_base()
    creds = load_credentials()
    if creds.get("refresh_in_flight") or creds.get("device_poll_in_flight"):
        with _credentials_lock():
            creds = load_credentials()
    _require_certain_refresh(creds)
    _require_certain_device(creds)
    if not creds.get("refresh_token"):
        raise CliError("not_connected", "pinseek is not connected. Run authorize-url to connect.")
    if float(creds.get("access_expires_at") or 0) - 30 < time.time():
        creds = refresh_access(creds)
    full = path
    if query:
        clean = {k: v for k, v in query.items() if v is not None and v != []}
        if clean:
            full += "?" + urllib.parse.urlencode(clean, doseq=True)

    def request_with_transport_retry(access_token: str) -> tuple[int, dict]:
        try:
            return http(method, full, json_body=json_body, token=access_token, headers_extra=headers)
        except CliError as exc:
            if method.upper() != "GET" or exc.code != "network_error":
                raise
            return http(method, full, json_body=json_body, token=access_token, headers_extra=headers)

    status, body = request_with_transport_retry(creds["access_token"])
    if status == 401:
        creds = refresh_access(creds)
        status, body = request_with_transport_retry(creds["access_token"])
        if status == 401:
            clear_credentials()
            raise CliError("reauthorization_required", "Your pinseek connection is no longer valid. Reconnect to continue.")
    if status < 300:
        return body
    raise map_error(status, body)


@contextlib.contextmanager
def _alert_write_session(expected_owner: str | None = None):
    """Pin identity and access token across the API write against local account switching."""
    _require_secure_base()
    # Let the normal API path refresh an expiring access token before taking
    # the credential lock. Then verify identity again using the exact token
    # that will accompany the write while holding that lock.
    api("GET", "/api/v1/me")
    with _credentials_lock():
        creds = load_credentials()
        _require_certain_refresh(creds)
        _require_certain_device(creds)
        token = creds.get("access_token")
        if not token or not creds.get("refresh_token"):
            raise CliError("not_connected", "pinseek is not connected. Run authorize-url to connect.")
        if float(creds.get("access_expires_at") or 0) - 30 < time.time():
            raise CliError("reauthorization_required", "Refresh the PinSeek connection, then retry the alert command.")
        status, identity = http("GET", "/api/v1/me", token=token)
        if status == 401:
            raise CliError("reauthorization_required", "The PinSeek connection changed or expired. Refresh it before retrying the alert command.")
        if status >= 300:
            raise map_error(status, identity)
        owner = identity.get("email")
        if not isinstance(owner, str) or not owner:
            raise CliError("account_identity_unavailable", "PinSeek did not provide an account identity for the alert write.")
        if expected_owner is not None and owner != expected_owner:
            raise CliError(
                "outcome_unknown",
                "This pending alert write belongs to a different PinSeek account. Reconnect to the original account before retrying it.",
            )
        yield owner, token


# ---------- connection commands ----------

_TERMINAL_DEVICE_ERRORS = {"access_denied", "expired_token", "invalid_grant"}


def _device_exchange_uncertain() -> dict:
    return {"state": "reauthorization_required", "reason": "device_exchange_uncertain"}


def _require_certain_device(creds: dict) -> None:
    if creds.get("device_poll_in_flight"):
        raise CliError(
            "device_exchange_uncertain",
            "The connection result is unknown. Check this app in PinSeek Connected Apps before abandoning the pending connection.",
        )


def cmd_status(args) -> dict:
    _require_secure_base()
    creds = load_credentials()
    if creds.get("refresh_in_flight"):
        with _credentials_lock():
            creds = load_credentials()
        if creds.get("refresh_in_flight"):
            return {"state": "reauthorization_required", "reason": "refresh_outcome_unknown"}
    if creds.get("device_poll_in_flight"):
        # A concurrent status command may be finishing the exchange. Wait for
        # its file lock before treating the persisted marker as abandoned.
        with _credentials_lock():
            creds = load_credentials()
        if creds.get("device_poll_in_flight"):
            return _device_exchange_uncertain()
        if creds.get("device_code"):
            return {"state": "pending", "connect_url": creds.get("connect_url"), "expires_at": creds.get("device_expires_at")}
    if creds.get("device_code"):
        with _credentials_lock():
            current = load_credentials()
            if current.get("device_poll_in_flight"):
                return _device_exchange_uncertain()
            if current.get("device_code"):
                # An approved code is consumed when the server issues tokens.
                # Record the in-flight poll so a lost response cannot trigger
                # another exchange with the same code.
                save_credentials({**current, "device_poll_in_flight": True})
                try:
                    status, body = http("POST", "/oauth/token", form={"grant_type": GRANT_DEVICE, "device_code": current["device_code"], "client_id": CLIENT_ID})
                except CliError:
                    return _device_exchange_uncertain()
                if status == 200 and body.get("access_token") and body.get("refresh_token"):
                    creds = {
                        "access_token": body["access_token"],
                        "refresh_token": body["refresh_token"],
                        "access_expires_at": time.time() + float(body.get("expires_in", 3600)),
                        "scope": body.get("scope"),
                    }
                    save_credentials(creds)
                else:
                    error_code = body.get("error")
                    if error_code in ("authorization_pending", "slow_down"):
                        save_credentials(current)
                        return {"state": "pending", "connect_url": current.get("connect_url"), "expires_at": current.get("device_expires_at")}
                    if error_code in _TERMINAL_DEVICE_ERRORS:
                        clear_credentials()
                        return {"state": "not_connected", "reason": error_code}
                    return _device_exchange_uncertain()
            else:
                # Another process already finished (or cleared) the device flow.
                creds = current
    if not creds.get("refresh_token"):
        return {"state": "not_connected"}
    me = api("GET", "/api/v1/me")
    return {"state": "connected", "email": me.get("email"), "plan": me.get("plan"), "limits": me.get("limits"), "checkout_url": me.get("checkout_url")}


def cmd_authorize_url(args) -> dict:
    _require_secure_base()
    with _credentials_lock():
        current = load_credentials()
        _require_certain_refresh(current)
        _require_certain_device(current)
        if current.get("refresh_token"):
            raise CliError("already_connected", "pinseek is already connected. Disconnect it before starting another connection.")
        if current.get("device_code"):
            try:
                expires_at = float(current["device_expires_at"])
            except (KeyError, TypeError, ValueError):
                expires_at = float("inf")
            if not math.isfinite(expires_at) or expires_at > time.time():
                raise CliError("authorization_pending", "A pinseek connection is already pending. Run status to check it.")
        status, body = http("POST", "/oauth/device/code", form={"client_id": CLIENT_ID, "scope": SCOPES})
        if status != 200 or "device_code" not in body:
            raise map_error(status, body)
        save_credentials({
            "device_code": body["device_code"],
            "connect_url": body.get("verification_uri_complete") or body.get("verification_uri"),
            "device_expires_at": time.time() + float(body.get("expires_in", 600)),
        })
        return {"connect_url": body.get("verification_uri_complete") or body.get("verification_uri"), "user_code": body.get("user_code"), "expires_in": body.get("expires_in")}


def cmd_refresh(args) -> dict:
    _require_secure_base()
    creds = load_credentials()
    _require_certain_device(creds)
    if not creds.get("refresh_token"):
        raise CliError("not_connected", "pinseek is not connected. Run authorize-url to connect.")
    refresh_access(creds)
    return {"refreshed": True}


def cmd_disconnect(args) -> dict:
    _require_secure_base()
    with _credentials_lock():
        creds = load_credentials()
        if creds.get("device_poll_in_flight"):
            if creds.get("refresh_token") or creds.get("access_token"):
                raise CliError("credentials_unavailable", "The pending connection state is inconsistent; no credentials were cleared.")
            if not args.abandon_uncertain_device:
                _require_certain_device(creds)
            # No issued token is available locally for revocation. The user
            # must inspect/remove any orphaned authorization in Connected Apps.
            clear_credentials()
            return {"disconnected": False, "manual_revocation_required": True}
        if args.abandon_uncertain_device:
            raise CliError("invalid_request", "There is no uncertain device exchange to abandon.")
        token = creds.get("refresh_token") or creds.get("access_token")
        if token:
            try:
                status, body = http("POST", "/oauth/revoke", form={"token": token})
                if status != 200 or body.get("ok") is not True:
                    raise map_error(status, body)
            except CliError:
                raise CliError("disconnect_failed", "Could not confirm pinseek disconnected; local credentials were kept.")
        clear_credentials()
        return {"disconnected": True}


# ---------- read and write commands ----------

def _rule_ref(args) -> str:
    return urllib.parse.quote(str(args.rule).strip().upper(), safe="")


def cmd_courses(args) -> dict:
    query: dict = {"limit": args.limit}
    if args.query:
        query["q"] = args.query
    if args.state:
        query["state"] = args.state
    if args.near:
        try:
            lat, lng = (float(part) for part in args.near.split(","))
        except ValueError:
            raise CliError("invalid_request", "--near must be LAT,LNG", fields=["near"])
        query.update({"lat": lat, "lng": lng, "radius_miles": float(args.radius)})
    if not any(k in query for k in ("q", "state", "lat")):
        raise CliError("invalid_request", "Give --query, --state, or --near.", fields=["query"])
    return api("GET", "/api/v1/courses", query=query)


_UNAVAILABLE_REASONS = {"timeout", "provider_error", "provider_backoff", "busy", "rate_limited", "daily_cap", "provider_budget"}


def cmd_search(args) -> dict:
    body = api("GET", "/api/v1/teetimes", query={
        "course": args.courses, "date": args.date, "players": args.players, "holes": args.holes,
        "time_start": args.after, "time_end": args.before, "max_price": args.max_price, "budget_ms": args.budget_ms,
    })
    providers = body.get("providers") or []
    body["cached_only"] = bool(providers) and all(p.get("mode") == "cached" for p in providers)
    if not body.get("results") and providers and all(p.get("mode") == "cached" and p.get("reason") in _UNAVAILABLE_REASONS for p in providers):
        raise CliError("provider_unavailable", "The booking systems for these courses did not answer and nothing is cached. Try again in a minute.", providers=providers)
    return body


def cmd_course_forecast(args) -> dict:
    return api("GET", f"/api/v1/courses/{urllib.parse.quote(args.course, safe=':')}/forecast")


def cmd_deeplink(args) -> dict:
    return api("GET", "/api/v1/deeplink", query={"course": args.course, "date": args.date, "players": args.players})


def cmd_rules(args) -> dict:
    return api("GET", "/api/v1/rules")


def cmd_rule_matches(args) -> dict:
    return api("GET", f"/api/v1/rules/{_rule_ref(args)}/matches")


def _rule_body(args, *, include_defaults: bool) -> dict:
    body: dict = {}
    if args.courses:
        body["course_keys"] = args.courses
    for attr, key in (("date_start", "date_start"), ("date_end", "date_end"), ("time_start", "time_start"), ("time_end", "time_end"),
                      ("players", "players"), ("holes", "holes"), ("max_price", "max_price"), ("name", "name")):
        value = getattr(args, attr)
        if value is not None:
            body[key] = value
    if args.channels:
        body["alert_channels"] = args.channels
    if include_defaults:
        body.setdefault("holes", 0)
        body.setdefault("alert_channels", ["email"])
    return body


def _pending_write_error(state: dict) -> CliError:
    return CliError(
        "outcome_unknown",
        f"An alert write may still be unresolved ({state['operation']['method']} {state['operation']['target']}). Run rule-retry to repeat that exact request, or rule-reconcile and rule-resolve after reviewing the current rules.",
    )


def _assert_no_pending_write() -> None:
    state = load_write_state()
    if state:
        raise _pending_write_error(state)


def _send_saved_write(state: dict, token: str) -> dict:
    operation = state["operation"]
    headers = {"Idempotency-Key": state["idempotency_key"]}
    if operation.get("if_match") is not None:
        headers["If-Match"] = operation["if_match"]
    try:
        status, body = http(
            operation["method"], operation["target"], json_body=operation.get("body"),
            token=token, headers_extra=headers,
        )
    except CliError as exc:
        # The journal stays durable. The first request was never automatically
        # replayed; a later rule-retry will use the same saved request and key.
        raise CliError(
            "outcome_unknown",
            "PinSeek did not confirm the alert write. Do not issue another alert write; use rule-retry for this exact request or reconcile the current rules.",
        ) from exc
    if status >= 500:
        raise CliError(
            "outcome_unknown",
            "PinSeek could not confirm the alert write. Do not issue another alert write; use rule-retry for this exact request or reconcile the current rules.",
        )
    # A complete non-5xx response is a definite API result. Successful writes
    # are replay-safe if a crash happens before this durable journal removal.
    clear_write_state()
    if status < 300:
        return body
    raise map_error(status, body)


def _persist_and_send_write(method: str, path: str, *, owner: str, token: str,
                            json_body: dict | None = None, if_match: str | None = None) -> dict:
    operation = {"method": method.upper(), "target": path, "body": json_body, "if_match": if_match}
    state = {
        "client_id": CLIENT_ID,
        "account_email": owner,
        "idempotency_key": secrets.token_urlsafe(24),
        "created_at": time.time(),
        "operation": operation,
        "binding": _write_binding(operation),
        "reconciled_at": None,
        "reconciled_account_email": None,
    }
    # Atomic replacement and fsync happen before the write request starts.
    save_write_state(state)
    return _send_saved_write(state, token)


def _new_write(method: str, path: str, *, json_body: dict | None = None, if_match: str | None = None) -> dict:
    with _write_state_lock():
        _assert_no_pending_write()
        with _alert_write_session() as (owner, token):
            return _persist_and_send_write(method, path, owner=owner, token=token,
                                           json_body=json_body, if_match=if_match)


def cmd_rule_retry(args) -> dict:
    with _write_state_lock():
        state = load_write_state()
        if not state:
            raise CliError("no_pending_write", "There is no unresolved alert write to retry.")
        age = time.time() - float(state.get("created_at", 0))
        if age < 0 or age >= IDEMPOTENCY_RETRY_WINDOW_SECONDS:
            raise CliError(
                "outcome_unknown",
                "The safe API idempotency retry window has elapsed. Run rule-reconcile and review the current rules before rule-resolve.",
            )
        with _alert_write_session(expected_owner=state["account_email"]) as (_, token):
            state["reconciled_at"] = None
            state["reconciled_account_email"] = None
            save_write_state(state)
            return _send_saved_write(state, token)


def cmd_rule_reconcile(args) -> dict:
    with _write_state_lock():
        state = load_write_state()
        if not state:
            raise CliError("no_pending_write", "There is no unresolved alert write to reconcile.")
        with _alert_write_session() as (owner, token):
            status, body = http("GET", "/api/v1/rules", token=token)
            if status >= 300:
                raise map_error(status, body)
            current = body.get("rules") or []
            matches_account = owner == state["account_email"]
            state["reconciled_at"] = time.time()
            state["reconciled_account_email"] = owner
            save_write_state(state)
        operation = state["operation"]
        return {
            "pending_write": {
                "method": operation["method"], "target": operation["target"],
                "body": operation.get("body"), "if_match": operation.get("if_match"),
                "created_at": state["created_at"],
            },
            "rules": current,
            "account_matches_pending": matches_account,
            "resolution_required": True,
        }


def cmd_rule_resolve(args) -> dict:
    with _write_state_lock():
        state = load_write_state()
        if not state:
            raise CliError("no_pending_write", "There is no unresolved alert write to resolve.")
        if float(state.get("reconciled_at") or 0) < float(state.get("created_at") or 0):
            raise CliError("reconciliation_required", "Run rule-reconcile and review its current rules before resolving this write.")
        if state.get("reconciled_account_email") != state["account_email"] and not args.confirm_account_mismatch:
            raise CliError("reconciliation_required", "The current PinSeek account differs from the pending write. Inspect the original account and pass --confirm-account-mismatch only after the user confirms its state.")
        clear_write_state()
        return {"resolved": True, "operator_assessment": args.outcome,
                "account_mismatch_confirmed": bool(args.confirm_account_mismatch)}


def _write_rule(method: str, path: str, rule_ref: str, *, json_body: dict | None = None, expected_revision: int | None = None) -> dict:
    with _write_state_lock():
        _assert_no_pending_write()
        with _alert_write_session() as (owner, token):
            if expected_revision is None:
                status, body = http("GET", "/api/v1/rules", token=token)
                if status >= 300:
                    raise map_error(status, body)
                rules = body.get("rules") or []
                match = next((rule for rule in rules if str(rule.get("id", "")).upper() == rule_ref.upper()), None)
                if not match:
                    raise CliError("not_found", "No alert with that id.")
                revision = int(match.get("revision") or 1)
            else:
                if expected_revision < 1:
                    raise CliError("invalid_request", "Expected revision must be a positive integer.")
                revision = expected_revision
            return _persist_and_send_write(method, path, owner=owner, token=token,
                                           json_body=json_body, if_match=f'"{revision}"')


def cmd_rule_create(args) -> dict:
    return _new_write("POST", "/api/v1/rules", json_body=_rule_body(args, include_defaults=True))


def cmd_rule_update(args) -> dict:
    body = _rule_body(args, include_defaults=False)
    if not body:
        raise CliError("invalid_request", "Give at least one field to change.", fields=[])
    rule_ref = _rule_ref(args)
    return _write_rule("PATCH", f"/api/v1/rules/{rule_ref}", rule_ref, json_body=body, expected_revision=args.expected_revision)


def cmd_rule_pause(args) -> dict:
    rule_ref = _rule_ref(args)
    return _write_rule("POST", f"/api/v1/rules/{rule_ref}/pause", rule_ref, expected_revision=args.expected_revision)


def cmd_rule_resume(args) -> dict:
    rule_ref = _rule_ref(args)
    return _write_rule("POST", f"/api/v1/rules/{rule_ref}/resume", rule_ref, expected_revision=args.expected_revision)


def cmd_rule_delete(args) -> dict:
    rule_ref = _rule_ref(args)
    return _write_rule("DELETE", f"/api/v1/rules/{rule_ref}", rule_ref, expected_revision=args.expected_revision)


# ---------- parser ----------

class _EnvelopeArgumentParser(argparse.ArgumentParser):
    """Turn argparse usage errors into CliError envelopes instead of a bare
    stderr message plus SystemExit. --help/--version are untouched: those
    call parser.exit() directly, not this error() hook."""

    def error(self, message: str) -> None:
        raise CliError("usage", message)


def _add_rule_fields(parser: argparse.ArgumentParser, *, required: bool) -> None:
    parser.add_argument("--course", action="append", dest="courses", default=[], help="course key; repeat for several")
    parser.add_argument("--date-start", required=required)
    parser.add_argument("--date-end", required=required)
    parser.add_argument("--time-start", required=required, help="HH:MM, course-local")
    parser.add_argument("--time-end", required=required, help="HH:MM, course-local")
    parser.add_argument("--players", type=int, required=required)
    parser.add_argument("--holes", type=int, choices=(0, 9, 18))
    parser.add_argument("--max-price", type=float)
    parser.add_argument("--name")
    parser.add_argument("--channel", action="append", dest="channels", default=[], choices=("email", "browser", "mobile", "sms", "whatsapp"))


COMMANDS = {
    "status": cmd_status,
    "authorize-url": cmd_authorize_url,
    "refresh": cmd_refresh,
    "disconnect": cmd_disconnect,
    "courses": cmd_courses,
    "search": cmd_search,
    "course-forecast": cmd_course_forecast,
    "deeplink": cmd_deeplink,
    "rules": cmd_rules,
    "rule-matches": cmd_rule_matches,
    "rule-create": cmd_rule_create,
    "rule-update": cmd_rule_update,
    "rule-pause": cmd_rule_pause,
    "rule-resume": cmd_rule_resume,
    "rule-delete": cmd_rule_delete,
    "rule-retry": cmd_rule_retry,
    "rule-reconcile": cmd_rule_reconcile,
    "rule-resolve": cmd_rule_resolve,
}


def build_parser() -> argparse.ArgumentParser:
    parser = _EnvelopeArgumentParser(prog="pinseek-agent" if AGENT_DISTRIBUTION else "pinseek", description="pinseek tee-time connector")
    sub = parser.add_subparsers(dest="command", required=True)
    sub.add_parser("status", help="Connection state; run this every turn")
    sub.add_parser("authorize-url", help="Start the Connect flow and print the link to share")
    sub.add_parser("refresh", help="Force a token refresh")
    disconnect = sub.add_parser("disconnect", help="Revoke the connection")
    disconnect.add_argument("--abandon-uncertain-device", action="store_true", help="Clear a pending device exchange after checking Connected Apps; cannot confirm server revocation")

    courses = sub.add_parser("courses", help="Find courses by name, state, or location")
    courses.add_argument("--query")
    courses.add_argument("--state", help="two-letter state code")
    courses.add_argument("--near", help="LAT,LNG")
    courses.add_argument("--radius", type=float, default=25, help="miles, with --near")
    courses.add_argument("--limit", type=int, default=20)

    search = sub.add_parser("search", help="Search open tee times")
    search.add_argument("--course", action="append", dest="courses", required=True, help="course key; repeat up to 10")
    search.add_argument("--date", required=True, help="YYYY-MM-DD, today to 8 days out")
    search.add_argument("--players", type=int, required=True)
    search.add_argument("--holes", type=int, choices=(0, 9, 18), default=0)
    search.add_argument("--after", default="00:00", help="HH:MM course-local")
    search.add_argument("--before", default="23:59", help="HH:MM course-local")
    search.add_argument("--max-price", type=float)
    search.add_argument("--budget-ms", type=int, default=8000)

    forecast = sub.add_parser("course-forecast", help="Availability patterns for one course")
    forecast.add_argument("--course", required=True)

    deeplink = sub.add_parser("deeplink", help="Booking link for a course and date")
    deeplink.add_argument("--course", required=True)
    deeplink.add_argument("--date", required=True)
    deeplink.add_argument("--players", type=int, default=4)

    sub.add_parser("rules", help="List the user's alerts")
    matches = sub.add_parser("rule-matches", help="Current matches for one alert")
    matches.add_argument("--rule", required=True, help="alert id such as ALRT-ABC123")

    create = sub.add_parser("rule-create", help="Create a tee-time alert")
    _add_rule_fields(create, required=True)
    update = sub.add_parser("rule-update", help="Change an alert")
    update.add_argument("--rule", required=True)
    update.add_argument("--expected-revision", type=int, help="Only write if the alert still has this revision (from `pinseek rules`).")
    _add_rule_fields(update, required=False)
    for name in ("rule-pause", "rule-resume", "rule-delete"):
        p = sub.add_parser(name)
        p.add_argument("--rule", required=True)
        p.add_argument("--expected-revision", type=int, help="Only write if the alert still has this revision (from `pinseek rules`).")
    sub.add_parser("rule-retry", help="Explicitly retry the exact saved alert request with its original idempotency key")
    sub.add_parser("rule-reconcile", help="Read current rules for an unresolved alert write")
    resolve = sub.add_parser("rule-resolve", help="Clear an unresolved write after reviewing rule-reconcile")
    resolve.add_argument("--outcome", choices=("applied", "not-applied"), required=True, help="Your assessment after inspecting the current rules")
    resolve.add_argument("--confirm-account-mismatch", action="store_true", help="Confirm you independently checked the original account when currently connected to a different one")
    return parser


def run(argv: list[str]) -> tuple[int, dict]:
    # SystemExit (from --help/--version, which call parser.exit() directly)
    # is intentionally not caught here: it should propagate so argparse's own
    # printed output and its own zero exit status stand, with no envelope.
    try:
        args = build_parser().parse_args(argv)
        if AGENT_DISTRIBUTION and _CLIENT_ID_OVERRIDE not in (None, "pinseek-cli"):
            raise CliError("invalid_client", "pinseek-agent must use the PinSeek CLI OAuth client.")
        return 0, {"ok": True, "body": COMMANDS[args.command](args)}
    except CliError as exc:
        return 1, exc.envelope()
    except (KeyboardInterrupt, BrokenPipeError):
        return 130, {"ok": False, "error": "interrupted", "message": "Interrupted."}
    except Exception as exc:  # never leak a traceback to the agent
        return 1, {"ok": False, "error": "cli_error", "message": f"{type(exc).__name__}: {exc}"}


def main(argv: list[str] | None = None) -> int:
    code, envelope = run(sys.argv[1:] if argv is None else argv)
    print(json.dumps(envelope, separators=(",", ":")))
    return code


if __name__ == "__main__":
    sys.exit(main())
