"""OpenWorker Cloud client: sign-in and managed one-click connectors.

Everything here is OPTIONAL. The app is fully functional signed out — manual
token paste stays available for every connector (and remains available after
sign-in too). Cloud sign-in only unlocks the one-click managed OAuth path and
the metadata conveniences that come with it.

Flows (ported from the proven `ocw_cli` reference in opencoworker-cloud):

- Sign-in: Auth0 Authorization Code + PKCE. The sidecar generates the PKCE
  pair, the browser signs in, Auth0 redirects to the sidecar's loopback
  `GET /auth/callback`, and the code is exchanged here. Cloud session tokens
  live in the SecretStore under `cloud:auth`.
- Managed connect: authenticated `POST /v1/oauth/{provider}/start` returns the
  provider authorize URL; the broker's callback page form-POSTs the token
  payload to the sidecar's loopback `POST /oauth/callback`; the profile is
  written locally. Connector tokens never touch cloud storage.
- Refresh: managed profiles (they have refresh_token + connection_id) renew
  through the broker just before expiry; manual profiles are never touched.
"""

from __future__ import annotations

import base64
import hashlib
import os
import secrets as _secrets
import time
import urllib.parse
from typing import Any, Optional

import httpx

from .config import Config
from .secrets import SecretStore

CLOUD_AUTH_PROFILE = "cloud:auth"
LOGIN_SCOPES = "openid profile email offline_access"

from . import __version__ as APP_VERSION  # noqa: E402

# connector id (canonical, = descriptor name) -> broker provider key
PROVIDER_FOR_CONNECTOR = {
    "gmail": "google",
    "google_calendar": "google",
    "google_drive": "google",
    "slack": "slack",
    "notion": "notion",
    "attio": "attio",
    "hubspot": "hubspot",
    "github": "github",
    "outlook": "microsoft",
}

# Pending PKCE verifiers keyed by OAuth state; in-process only. A login that
# outlives the sidecar process simply has to be restarted.
_pending_logins: dict[str, dict[str, float | str]] = {}
_PENDING_TTL = 600
_pending_managed_states: dict[str, float] = {}
_MANAGED_STATE_TTL = 600


def _b64url(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).decode().rstrip("=")


def _now() -> float:
    return time.time()


# --- sign-in -----------------------------------------------------------------


def begin_login(config: Config) -> dict[str, Any]:
    """Create a PKCE login and return the browser URL. The sidecar's
    GET /auth/callback completes it.

    The redirect goes through the BROKER's stable callback, which bounces the
    browser to our actual loopback port (carried as state's `.port` suffix —
    Auth0 echoes state untouched). Direct loopback redirects can't work in the
    packaged app: Auth0's allow-list rejects unregistered ports, and the
    desktop shell binds the sidecar to a RANDOM free port. This shipped once
    as "Firefox can't connect to 127.0.0.1:8765" right after Auth0 finished.
    """
    verifier = _b64url(_secrets.token_bytes(48))
    challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
    port = os.environ.get("COWORKER_PORT") or config.port
    state = f"{_secrets.token_urlsafe(16)}.{port}"

    for key, pending in list(_pending_logins.items()):  # expire stale attempts
        if float(pending["created"]) < _now() - _PENDING_TTL:
            _pending_logins.pop(key, None)
    _pending_logins[state] = {"verifier": verifier, "created": _now()}

    redirect_uri = config.cloud_base_url.rstrip("/") + "/v1/auth/callback"
    authorize_url = (
        f"https://{config.cloud_auth_domain}/authorize?"
        + urllib.parse.urlencode(
            {
                "response_type": "code",
                "client_id": config.cloud_client_id,
                "redirect_uri": redirect_uri,
                "scope": LOGIN_SCOPES,
                "audience": config.cloud_audience,
                "state": state,
                "code_challenge": challenge,
                "code_challenge_method": "S256",
            }
        )
    )
    return {"authorize_url": authorize_url, "state": state}


def complete_login(
    secrets: SecretStore, config: Config, code: str, state: str
) -> dict[str, Any]:
    pending = _pending_logins.pop(state, None)
    if pending is None or float(pending["created"]) < _now() - _PENDING_TTL:
        return {"ok": False, "error": "unknown or expired sign-in attempt"}

    resp = httpx.post(
        f"https://{config.cloud_auth_domain}/oauth/token",
        data={
            "grant_type": "authorization_code",
            "client_id": config.cloud_client_id,
            "code": code,
            "code_verifier": pending["verifier"],
            # MUST byte-match begin_login's authorize redirect_uri (RFC 6749 §4.1.3) — the
            # broker bounce, not the loopback. The bounce change (eda23c9) updated only the
            # authorize leg; the stale loopback here made Auth0 reject every exchange
            # ("token exchange failed" on all sign-ins from 07-09 to 07-11).
            "redirect_uri": config.cloud_base_url.rstrip("/") + "/v1/auth/callback",
        },
        timeout=15,
    )
    if resp.status_code != 200:
        return {"ok": False, "error": "token exchange failed"}
    _store_cloud_tokens(secrets, resp.json())

    # Best-effort profile fetch so the GUI can show who is signed in.
    me = fetch_me(secrets, config)
    if me:
        profile = secrets.get(CLOUD_AUTH_PROFILE) or {}
        profile["account"] = me.get("user", {}).get("email") or ""
        profile["user_id"] = me.get("user", {}).get("user_id") or ""
        secrets.put(CLOUD_AUTH_PROFILE, profile)
    # Connection restore (sync_connections) deliberately does NOT run here: it is
    # best-effort metadata work, and doing it inline held the browser's "Signed in"
    # page + the GUI's signed-in flip hostage to an extra broker round trip (slow
    # sign-in complaint, 2026-07-16). The /auth/callback route kicks it off in the
    # background after responding.
    return {"ok": True, **status(secrets)}


def sync_connections(secrets: SecretStore, config: Config) -> dict[str, Any]:
    """Rebuild local managed-connection state from the broker's metadata rows
    (GET /v1/connections) after a cloud sign-in.

    Only GitHub restores fully on a fresh install: its rows are routing metadata
    (installation ids + logins) and installation tokens mint on demand — nothing
    secret ever needs to live here. Every other connector's tokens are local-only
    by design, so those need a one-click re-consent instead."""
    token = fresh_access_token(secrets, config)
    if not token:
        return {"ok": False, "error": "not signed in"}
    try:
        resp = httpx.get(
            config.cloud_base_url.rstrip("/") + "/v1/connections",
            headers={"Authorization": f"Bearer {token}"},
            timeout=15,
        )
    except httpx.HTTPError:
        return {"ok": False, "error": "cloud unreachable"}
    if resp.status_code != 200:
        return {"ok": False, "error": f"connections fetch failed ({resp.status_code})"}

    from .connectors.github_installs import managed_connect_install

    restored: list[str] = []
    for row in resp.json().get("connections", []):
        if row.get("connector") != "github" or row.get("status") != "connected":
            continue
        meta = row.get("tenant_metadata") or {}
        installs = meta.get("installations") or []
        if not installs and meta.get("installation_id"):
            installs = [meta]  # pre-restore-era rows carry only the primary install
        for inst in installs:
            out = managed_connect_install(
                secrets,
                {
                    "installation_id": str(inst.get("installation_id") or ""),
                    "account_login": inst.get("account_login", ""),
                    "account_type": inst.get("account_type", ""),
                    "repo_selection": inst.get("repo_selection", ""),
                    "github_login": meta.get("github_login", ""),
                    "connection_id": row.get("connection_id", ""),
                },
            )
            if out.get("ok"):
                restored.append(out["installation_id"])
    return {"ok": True, "restored": restored}


def _store_cloud_tokens(secrets: SecretStore, token: dict) -> None:
    profile = secrets.get(CLOUD_AUTH_PROFILE) or {"type": "oauth", "enabled": True}
    profile["access_token"] = token.get("access_token", "")
    if token.get("refresh_token"):  # rotating refresh tokens: keep the newest
        profile["refresh_token"] = token["refresh_token"]
    profile["expires"] = _now() + int(token.get("expires_in") or 3600) - 60
    secrets.put(CLOUD_AUTH_PROFILE, profile)


def status(secrets: SecretStore) -> dict[str, Any]:
    profile = secrets.get(CLOUD_AUTH_PROFILE) or {}
    return {
        "signed_in": bool(profile.get("access_token")),
        "account": profile.get("account") or "",
        "user_id": profile.get("user_id") or "",
    }


def logout(secrets: SecretStore) -> dict[str, Any]:
    secrets.delete(CLOUD_AUTH_PROFILE)
    return {"ok": True, "signed_in": False}


def fresh_access_token(secrets: SecretStore, config: Config) -> Optional[str]:
    """Valid cloud session token, silently refreshed near expiry; None when
    signed out or the session can't be renewed (GUI shows "sign in again")."""
    profile = secrets.get(CLOUD_AUTH_PROFILE) or {}
    if not profile.get("access_token"):
        return None
    if float(profile.get("expires") or 0) > _now():
        return profile["access_token"]
    if not profile.get("refresh_token"):
        return None
    resp = httpx.post(
        f"https://{config.cloud_auth_domain}/oauth/token",
        data={
            "grant_type": "refresh_token",
            "client_id": config.cloud_client_id,
            "refresh_token": profile["refresh_token"],
        },
        timeout=15,
    )
    if resp.status_code != 200:
        return None
    _store_cloud_tokens(secrets, resp.json())
    return (secrets.get(CLOUD_AUTH_PROFILE) or {}).get("access_token")


def fetch_me(secrets: SecretStore, config: Config) -> Optional[dict]:
    token = fresh_access_token(secrets, config)
    if not token:
        return None
    try:
        resp = httpx.get(
            config.cloud_base_url.rstrip("/") + "/v1/me",
            headers={"Authorization": f"Bearer {token}"},
            timeout=15,
        )
    except httpx.HTTPError:
        return None
    return resp.json() if resp.status_code == 200 else None


# --- telemetry (Phase 5) ---------------------------------------------------------
# One sentence: which coworker type was started and when — nothing else. Signed-in
# users only, default-on with an opt-out; signed out (or opted out) sends NOTHING.
# Never sent: titles, prompts, outputs, tool args, file paths, connector content.

TELEMETRY_PROFILE = "cloud:telemetry"


def install_id(secrets: SecretStore) -> str:
    """Stable random per-install id, minted on first use (spec Phase 5)."""
    profile = secrets.get(TELEMETRY_PROFILE) or {}
    if not profile.get("install_id"):
        profile["install_id"] = "ins_" + _secrets.token_hex(12)
        secrets.put(TELEMETRY_PROFILE, profile)
    return profile["install_id"]


def telemetry_enabled(secrets: SecretStore) -> bool:
    profile = secrets.get(TELEMETRY_PROFILE) or {}
    return bool(profile.get("enabled", True))  # default-on (only matters signed in)


def set_telemetry_enabled(secrets: SecretStore, enabled: bool) -> dict[str, Any]:
    profile = secrets.get(TELEMETRY_PROFILE) or {}
    profile["enabled"] = bool(enabled)
    secrets.put(TELEMETRY_PROFILE, profile)
    return {"ok": True, "telemetry_enabled": bool(enabled)}


def emit_session_created(
    secrets: SecretStore,
    config: Config,
    *,
    session_id: str,
    persona_id: str,
    persona_family: str,
    workspace_kind: str,
) -> bool:
    """Best-effort, content-free session event. Hard no-op unless signed in AND
    the toggle is on; failures are swallowed (telemetry must never break a session)."""
    import platform as _platform
    import sys

    if not telemetry_enabled(secrets):
        return False
    token = fresh_access_token(secrets, config)
    if not token:
        return False  # signed out: local-only users send nothing, by design
    body = {
        "event": "coworker_session_created",
        "install_id": install_id(secrets),
        "app_version": APP_VERSION,
        "platform": {"darwin": "macos", "win32": "windows"}.get(
            sys.platform, _platform.system().lower() or "unknown"
        ),
        "session": {
            "session_id_hash": "sha256:"
            + hashlib.sha256(session_id.encode()).hexdigest(),
            "persona_id": persona_id,
            "persona_family": persona_family,
            "workspace_kind": workspace_kind,
        },
    }
    try:
        resp = httpx.post(
            config.cloud_base_url.rstrip("/") + "/v1/telemetry/events",
            json=body,
            headers={"Authorization": f"Bearer {token}"},
            timeout=10,
        )
        return resp.status_code == 200
    except httpx.HTTPError:
        return False


# --- managed connectors --------------------------------------------------------


def begin_managed_connect(
    secrets: SecretStore,
    config: Config,
    connector: str,
    *,
    access: str = "",
    flow: str = "",
) -> dict[str, Any]:
    """Authenticated start: returns the provider consent URL for the browser.
    Requires sign-in — the manual token path stays available regardless.
    `access` names a broker-defined consent tier (hubspot read | write); the
    desktop never sends scopes. `flow` is GitHub-only: "" = the App install
    page; "authorize" links a teammate to an existing installation."""
    provider = PROVIDER_FOR_CONNECTOR.get(connector)
    if provider is None:
        return {"ok": False, "error": f"{connector} has no managed OAuth path"}
    token = fresh_access_token(secrets, config)
    if not token:
        return {"ok": False, "error": "not signed in", "signed_in": False}

    app_state = _secrets.token_urlsafe(16)
    # The broker form-POSTs the tokens back to THIS process's loopback. Use the
    # actually-bound port (published by run.py), falling back to config.port —
    # the packaged app runs the sidecar on a random port, not 8765.
    port = os.environ.get("COWORKER_PORT") or config.port
    try:
        resp = httpx.post(
            config.cloud_base_url.rstrip("/") + f"/v1/oauth/{provider}/start",
            json={
                "connector": connector,
                "redirect": f"http://127.0.0.1:{port}/oauth/callback",
                "app_state": app_state,
                **({"access": access} if access else {}),
                **({"flow": flow} if flow else {}),
            },
            headers={"Authorization": f"Bearer {token}"},
            timeout=15,
        )
    except httpx.HTTPError as exc:
        return {"ok": False, "error": f"cloud unreachable: {type(exc).__name__}"}
    if resp.status_code != 200:
        return {"ok": False, "error": f"start failed ({resp.status_code})"}
    _pending_managed_states[app_state] = _now()
    return {
        "ok": True,
        "authorize_url": resp.json()["authorize_url"],
        "app_state": app_state,
    }


def consume_managed_state(state: str) -> bool:
    """Consume one recent managed-OAuth callback state exactly once."""
    if not state:
        return False
    created = _pending_managed_states.pop(state, None)
    return created is not None and created >= _now() - _MANAGED_STATE_TTL


def managed_profile_from_callback(form: dict[str, str]) -> dict[str, Any]:
    """Local connector profile from the broker's form-POST payload.

    Field-compatible with a manual paste (`access_token` etc.) so tools and
    gating treat both paths identically; the managed extras (refresh_token,
    connection_id) are what enable broker refresh and cloud disconnect.
    """
    profile = {
        "type": "oauth",
        "enabled": True,
        "managed": True,
        "access_token": form.get("access_token", ""),
        "refresh_token": form.get("refresh_token", ""),
        "scope": form.get("scope", ""),
        "connection_id": form.get("connection_id", ""),
        "provider": form.get("provider", ""),
        "account": form.get("account", ""),
    }
    if form.get("account_id"):
        # The stable id behind the display name (workspace/portal id) — what
        # the generic accounts layer keys multi-account profiles by.
        profile["account_id"] = form["account_id"]
    if form.get("expires_in"):  # absent ⇒ non-expiring token (e.g. Slack bot tokens)
        profile["expires"] = _now() + int(form["expires_in"]) - 60
    return profile


def refresh_managed_token(
    secrets: SecretStore,
    config: Config,
    connector: str,
    *,
    profile_key: Optional[str] = None,
) -> Optional[dict[str, Any]]:
    """Renew a managed connector token through the broker. Returns the updated
    profile, or None if this profile can't be (or doesn't need to be) renewed
    that way. Manual profiles are never touched. `profile_key` targets an
    account-keyed profile (`gmail:account:<email>`); default = `<name>:default`."""
    key = profile_key or f"{connector}:default"
    profile = secrets.get(key) or {}
    if not (profile.get("managed") and profile.get("refresh_token")):
        return None
    provider = profile.get("provider") or PROVIDER_FOR_CONNECTOR.get(connector)
    token = fresh_access_token(secrets, config)
    if not provider or not token:
        return None
    try:
        resp = httpx.post(
            config.cloud_base_url.rstrip("/") + f"/v1/oauth/{provider}/refresh",
            json={
                "refresh_token": profile["refresh_token"],
                "connection_id": profile.get("connection_id", ""),
                "connector": connector,
            },
            headers={"Authorization": f"Bearer {token}"},
            timeout=20,
        )
    except httpx.HTTPError:
        return None
    if resp.status_code != 200:
        return None
    fresh = resp.json()
    profile["access_token"] = fresh.get("access_token", "")
    if fresh.get("refresh_token"):
        profile["refresh_token"] = fresh["refresh_token"]
    profile["expires"] = _now() + int(fresh.get("expires_in") or 3600) - 60
    secrets.put(key, profile)
    return profile


def ensure_fresh_connector_token(
    secrets: SecretStore,
    config: Config,
    connector: str,
    *,
    profile_key: Optional[str] = None,
    leeway: int = 120,
) -> None:
    """Refresh-on-expiry hook for connector tools: if this is a managed profile
    about to expire, renew it in place. No-op for manual profiles."""
    key = profile_key or f"{connector}:default"
    profile = secrets.get(key) or {}
    if not profile.get("managed"):
        return
    expires = float(profile.get("expires") or 0)
    if expires and expires > _now() + leeway:
        return
    refresh_managed_token(secrets, config, connector, profile_key=profile_key)


def cloud_disconnect(
    secrets: SecretStore,
    config: Config,
    connector: str,
    *,
    profile_key: Optional[str] = None,
) -> None:
    """Best-effort: tell the cloud a managed connection is gone so its metadata
    flips to disconnected. Local deletion always proceeds regardless."""
    profile = secrets.get(profile_key or f"{connector}:default") or {}
    connection_id = profile.get("connection_id")
    if not (profile.get("managed") and connection_id):
        return
    token = fresh_access_token(secrets, config)
    if not token:
        return
    try:
        httpx.post(
            config.cloud_base_url.rstrip("/")
            + f"/v1/connections/{connection_id}/disconnect",
            headers={"Authorization": f"Bearer {token}"},
            timeout=10,
        )
    except httpx.HTTPError:
        pass


# installation_id -> (token, expires_epoch). MEMORY ONLY by design: GitHub
# installation tokens live ~1 h and are re-minted from the broker; they must
# never touch the secret store (github-relay-spec §4).
_GITHUB_TOKEN_CACHE: dict[str, tuple[str, float]] = {}
_GITHUB_TOKEN_LEEWAY = 600  # re-mint when < 10 min of life remains


def github_installation_token(
    secrets: SecretStore, config: Config, installation_id: str, *, force: bool = False
) -> str:
    """A live installation access token for GitHub API calls, minted via the
    authenticated broker route and cached in memory (~50 min). `force` skips
    the cache — the 401 retry path. Empty string when unavailable (signed
    out / revoked installation / cloud unreachable)."""
    installation_id = str(installation_id or "").strip()
    if not installation_id:
        return ""
    if not force:
        cached = _GITHUB_TOKEN_CACHE.get(installation_id)
        if cached and cached[1] > _now() + _GITHUB_TOKEN_LEEWAY:
            return cached[0]
    token = fresh_access_token(secrets, config)
    if not token:
        return ""
    try:
        resp = httpx.post(
            config.cloud_base_url.rstrip("/") + "/v1/github/token",
            json={"installation_id": installation_id},
            headers={"Authorization": f"Bearer {token}"},
            timeout=20,
        )
    except httpx.HTTPError:
        return ""
    if resp.status_code != 200:
        return ""
    body = resp.json()
    minted = body.get("token", "")
    # expires_at is ISO-8601 from GitHub; parse defensively, default 1 h.
    expires = _now() + 3600
    try:
        from datetime import datetime

        raw = str(body.get("expires_at", ""))
        if raw:
            expires = datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
    except ValueError:
        pass
    if minted:
        _GITHUB_TOKEN_CACHE[installation_id] = (minted, expires)
    return minted


def clear_github_token(installation_id: str) -> None:
    """Drop a cached installation token (disconnect / revocation)."""
    _GITHUB_TOKEN_CACHE.pop(str(installation_id or "").strip(), None)


def github_disconnect_installation(
    secrets: SecretStore, config: Config, installation_id: str
) -> None:
    """Best-effort: delete this user's relay routing rows for one installation
    so the cloud stops pushing its events. Local profile deletion always
    proceeds regardless (the row only routes)."""
    clear_github_token(installation_id)
    token = fresh_access_token(secrets, config)
    if not token:
        return
    try:
        httpx.post(
            config.cloud_base_url.rstrip("/") + "/v1/relay/github/disconnect",
            json={"installation_id": installation_id},
            headers={"Authorization": f"Bearer {token}"},
            timeout=10,
        )
    except httpx.HTTPError:
        pass


def slack_disconnect_workspace(
    secrets: SecretStore, config: Config, team_id: str
) -> None:
    """Best-effort: delete this user's relay routing row for one workspace so the
    cloud stops pushing its events. Local token deletion always proceeds regardless
    (the row only routes; without the desktop token nothing can be sent anyway)."""
    token = fresh_access_token(secrets, config)
    if not token:
        return
    try:
        httpx.post(
            config.cloud_base_url.rstrip("/") + "/v1/relay/slack/uninstall",
            json={"team_id": team_id},
            headers={"Authorization": f"Bearer {token}"},
            timeout=10,
        )
    except httpx.HTTPError:
        pass


# --- persona gallery -----------------------------------------------------------


def _gallery_get(secrets: SecretStore, config: Config, path: str) -> Optional[dict]:
    token = fresh_access_token(secrets, config)
    if not token:
        return None
    try:
        resp = httpx.get(
            config.cloud_base_url.rstrip("/") + path,
            headers={"Authorization": f"Bearer {token}"},
            timeout=15,
        )
    except httpx.HTTPError:
        return None
    return resp.json() if resp.status_code == 200 else None


def gallery_list(secrets: SecretStore, config: Config) -> Optional[dict]:
    """Curated persona cards visible to this user's tenant; None when signed
    out or the cloud is unreachable (gallery requires sign-in by design)."""
    return _gallery_get(secrets, config, "/v1/personas/gallery")


def gallery_manifest(secrets: SecretStore, config: Config, slug: str) -> Optional[dict]:
    return _gallery_get(secrets, config, f"/v1/personas/gallery/{slug}/manifest")


def gallery_install_event(secrets: SecretStore, config: Config, slug: str) -> None:
    """Best-effort product telemetry (slug/version only, no content)."""
    token = fresh_access_token(secrets, config)
    if not token:
        return
    try:
        httpx.post(
            config.cloud_base_url.rstrip("/")
            + f"/v1/personas/gallery/{slug}/install-events",
            json={"platform": __import__("sys").platform},
            headers={"Authorization": f"Bearer {token}"},
            timeout=10,
        )
    except httpx.HTTPError:
        pass


def gallery_detail(secrets: SecretStore, config: Config, slug: str) -> Optional[dict]:
    """Solo-page payload: the cloud card + publisher pitch, with capability
    facts derived LOCALLY from the manifest via the desktop's own strict
    parser — the pitch can never advertise what install-time consent wouldn't
    show, because both views come from the same parsed manifest."""
    card = _gallery_get(secrets, config, f"/v1/personas/gallery/{slug}")
    manifest = gallery_manifest(secrets, config, slug)
    if card is None or manifest is None:
        return None
    try:
        from .personas.loading import consent_summary
        from .personas.manifest import parse_manifest

        m = parse_manifest(manifest.get("manifest_markdown", ""), fallback_id=slug)
        capabilities = consent_summary(m)
        recommends = [
            {"kind": r.kind, "ref": r.ref, "reason": r.reason, "tier": r.tier}
            for r in m.recommends
        ]
    except Exception as exc:  # malformed manifest: surface, don't crash
        return {"ok": False, "error": f"manifest failed local validation: {exc}"}
    return {
        "ok": True,
        "card": card,
        "capabilities": capabilities,
        "recommends": recommends,
    }
