"""
CA Automator — client-portal social sign-in (OAuth2 authorization-code flow).

Deliberately scoped to the client portal only — never staff login. A firm's
own team keeps signing in with a password; a client's portal account is
where "Sign in with Google" convenience belongs.

Security posture, stated up front because it's the one thing worth getting
right: social login here is an alternative AUTHENTICATION method for an
ALREADY-INVITED client_portal_user, never a self-service signup path. The
callback matches the verified email Google returns against an existing,
active client_portal_user row — if no such row exists, the visitor is
turned away with a clear message, never auto-enrolled into a client's
portal. Anything else would mean a random Google account could talk its
way into a client's private practice data.

Blank until configured (same posture as SMTP/payment gateways/AI
providers): Admin > Site Settings > Social Login starts with an empty
Client ID/Secret and the portal shows no social buttons at all until a
real one is saved — see oauth_provider_key.

Adding a new provider (Facebook, Microsoft, ...) means one more entry in
_PROVIDERS below with its three endpoints and how to read its profile
response — nothing else in this file, app.py's routes, or portal.js's
button rendering needs to change.
"""
import json
import secrets
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone

import db

_PROVIDERS = {
    "google": {
        "label": "Google",
        "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth",
        "token_url": "https://oauth2.googleapis.com/token",
        "userinfo_url": "https://www.googleapis.com/oauth2/v3/userinfo",
        "scope": "openid email profile",
        "profile": lambda data: {"email": (data.get("email") or "").lower(), "name": data.get("name") or ""},
    },
}

_STATE_TTL_SECONDS = 600  # 10 minutes is generous for "click button, sign in at Google, come back"


class OAuthError(Exception):
    pass


def available_providers():
    return list(_PROVIDERS.keys())


def _config_row(firm_id, provider):
    return db.query("SELECT * FROM oauth_provider_key WHERE firm_id = ? AND provider = ? AND is_active = 1",
                    (firm_id, provider), one=True)


def configured_providers(firm_id=1):
    """Providers with real, active credentials saved — what the portal's
    login screen actually offers as buttons."""
    rows = db.query("SELECT provider FROM oauth_provider_key WHERE firm_id = ? AND is_active = 1", (firm_id,))
    return [{"provider": r["provider"], "label": _PROVIDERS[r["provider"]]["label"]}
            for r in rows if r["provider"] in _PROVIDERS]


# -------------------------------------------------------------- admin CRUD

def list_keys(firm_id=1):
    rows = db.query("SELECT id, provider, client_id, is_active, created_at, updated_at FROM oauth_provider_key"
                    " WHERE firm_id = ? ORDER BY provider", (firm_id,))
    return [dict(r, has_secret=True, label=_PROVIDERS.get(r["provider"], {}).get("label", r["provider"])) for r in rows]


def set_key(firm_id, provider, client_id, client_secret, is_active=True, updated_by=None):
    if provider not in _PROVIDERS:
        raise OAuthError(f"unknown_provider: {provider}")
    client_id = (client_id or "").strip()
    if not client_id:
        raise OAuthError("client_id_required")
    existing = db.query("SELECT client_secret_enc FROM oauth_provider_key WHERE firm_id = ? AND provider = ?",
                        (firm_id, provider), one=True)
    secret_enc = (existing or {}).get("client_secret_enc")
    if client_secret and client_secret.strip():
        secret_enc = db.encrypt_secret(client_secret.strip())
    if not secret_enc:
        raise OAuthError("client_secret_required")
    db.execute(
        "INSERT INTO oauth_provider_key (firm_id, provider, client_id, client_secret_enc, is_active, updated_by)"
        " VALUES (?,?,?,?,?,?)"
        " ON CONFLICT(firm_id, provider) DO UPDATE SET client_id=excluded.client_id,"
        " client_secret_enc=excluded.client_secret_enc, is_active=excluded.is_active,"
        " updated_at=datetime('now'), updated_by=excluded.updated_by",
        (firm_id, provider, client_id, secret_enc, 1 if is_active else 0, updated_by))


def delete_key(firm_id, provider):
    db.execute("DELETE FROM oauth_provider_key WHERE firm_id = ? AND provider = ?", (firm_id, provider))


# ---------------------------------------------------------------- the flow

def _redirect_uri(base_url, provider):
    return f"{base_url}/api/portal/oauth/{provider}/callback"


def start(provider, firm_id, base_url):
    """Returns the URL to send the visitor's browser to. Raises OAuthError
    if the provider isn't recognized or has no active credentials."""
    if provider not in _PROVIDERS:
        raise OAuthError("unknown_provider")
    cfg = _config_row(firm_id, provider)
    if not cfg:
        raise OAuthError("not_configured")
    # Sweep expired state rows opportunistically — no separate cron needed
    # for a table this small and self-limiting. Computed in Python (rather
    # than SQLite's datetime('now', '-N seconds') modifier syntax, which
    # MySQL has no equivalent for) so the query itself is a plain, portable
    # comparison against an ordinary string parameter.
    cutoff = (datetime.now(timezone.utc) - timedelta(seconds=_STATE_TTL_SECONDS)).strftime("%Y-%m-%d %H:%M:%S")
    db.execute("DELETE FROM oauth_state WHERE created_at < ?", (cutoff,))
    state = secrets.token_urlsafe(24)
    db.execute("INSERT INTO oauth_state (state, provider, firm_id) VALUES (?,?,?)", (state, provider, firm_id))
    p = _PROVIDERS[provider]
    params = {
        "client_id": cfg["client_id"], "redirect_uri": _redirect_uri(base_url, provider),
        "response_type": "code", "scope": p["scope"], "state": state,
        "access_type": "online", "prompt": "select_account",
    }
    return p["authorize_url"] + "?" + urllib.parse.urlencode(params)


def _post_form(url, fields):
    req = urllib.request.Request(
        url, data=urllib.parse.urlencode(fields).encode("utf-8"),
        headers={"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "CA-Automator/1.0"},
        method="POST")
    try:
        with urllib.request.urlopen(req, timeout=20) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        raise OAuthError(f"provider_rejected_exchange: {e.read().decode('utf-8', 'replace')[:300]}")
    except urllib.error.URLError as e:
        raise OAuthError(f"unreachable: {e.reason}")


def _get_json(url, access_token):
    req = urllib.request.Request(url, headers={"Authorization": f"Bearer {access_token}", "User-Agent": "CA-Automator/1.0"})
    try:
        with urllib.request.urlopen(req, timeout=20) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        raise OAuthError(f"profile_fetch_failed: {e.read().decode('utf-8', 'replace')[:300]}")
    except urllib.error.URLError as e:
        raise OAuthError(f"unreachable: {e.reason}")


def complete(provider, code, state, base_url):
    """Verifies state, exchanges the code, fetches the verified profile, and
    matches it against an existing, active client_portal_user by email.
    Returns that portal_user row (as a dict) on success. Raises OAuthError
    with a short machine-readable code on any failure — callers map these
    to visitor-facing wording."""
    if provider not in _PROVIDERS:
        raise OAuthError("unknown_provider")
    row = db.query("SELECT * FROM oauth_state WHERE state = ? AND provider = ?", (state, provider), one=True)
    if not row:
        raise OAuthError("invalid_state")
    db.execute("DELETE FROM oauth_state WHERE state = ?", (state,))
    # Computed in Python (rather than SQLite's julianday()) directly from
    # the row already fetched above, saving a round trip and avoiding
    # another SQLite-only function with no MySQL equivalent.
    created = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
    age_seconds = (datetime.now(timezone.utc) - created).total_seconds()
    if age_seconds > _STATE_TTL_SECONDS:
        raise OAuthError("state_expired")

    cfg = _config_row(row["firm_id"], provider)
    if not cfg:
        raise OAuthError("not_configured")
    p = _PROVIDERS[provider]
    token_data = _post_form(p["token_url"], {
        "code": code, "client_id": cfg["client_id"], "client_secret": db.decrypt_secret(cfg["client_secret_enc"]),
        "redirect_uri": _redirect_uri(base_url, provider), "grant_type": "authorization_code",
    })
    access_token = token_data.get("access_token")
    if not access_token:
        raise OAuthError("no_access_token")
    profile = p["profile"](_get_json(p["userinfo_url"], access_token))
    if not profile.get("email"):
        raise OAuthError("no_verified_email")

    pu = db.query("SELECT * FROM client_portal_user WHERE email = ? AND is_active = 1", (profile["email"],), one=True)
    if not pu:
        raise OAuthError("no_portal_account")
    return dict(pu)
