"""
CA Automator — shared AI provider dispatch.

Used by both site_chat.py (the public landing-page chatbot) and
ca_assistant.py (the internal, authenticated CA reasoning assistant). This
is the ONLY place in the app that knows which AI provider is configured and
how to call it — each caller keeps its own system prompt, context-building
and error framing; this module only ever sees messages in, text out.

Key resolution, in priority order:
  1. Admin > Site Settings > AI Providers (ai_provider_key table) — the row
     with is_active = 1, if any. Encrypted at rest via db.encrypt_secret(),
     same posture as smtp_config/payment_gateway_config. Settable, viewable
     (masked) and deletable live from the admin UI, no restart required.
  2. Environment variables (GROQ_API_KEY / ANTHROPIC_API_KEY / a future
     provider's own var) — the original deployment path, still fully
     supported for a firm that would rather configure this at the process
     level. CA_AI_PROVIDER forces which one wins when more than one env var
     is set.
A DB row, if active, wins over environment variables entirely (never merged
field-by-field) — same precedent as smtp_config vs CA_SMTP_*.

Adding a new provider means adding one entry to _PROVIDERS/_KEY_ENV/
_DEFAULT_MODELS and one _call_<name>() function here — the admin UI's
provider list is driven by _PROVIDERS, so a newly-added provider is
immediately configurable from Admin > Site Settings > AI Providers with no
frontend change.
"""
import json
import os
import urllib.error
import urllib.request

import db

_PROVIDERS = ["groq", "anthropic"]
_KEY_ENV = {"groq": "GROQ_API_KEY", "anthropic": "ANTHROPIC_API_KEY"}
_DEFAULT_MODELS = {"groq": "openai/gpt-oss-120b", "anthropic": "claude-opus-5"}


class ProviderError(Exception):
    """message is a short machine-readable code (not_configured,
    invalid_api_key, rate_limited, unreachable, empty_response,
    unexpected_response) or 'provider_error: <detail>' — callers map these
    to their own visitor/user-facing wording."""
    pass


# ------------------------------------------------------------- key resolution

def _db_active_row(firm_id=1):
    row = db.query("SELECT * FROM ai_provider_key WHERE firm_id = ? AND is_active = 1 LIMIT 1",
                   (firm_id,), one=True)
    if not row or not row["api_key_enc"]:
        return None
    return {"provider": row["provider"], "api_key": db.decrypt_secret(row["api_key_enc"]),
            "model": row["model"]}


def _env_row():
    forced = os.environ.get("CA_AI_PROVIDER")
    candidates = [forced] if forced else _PROVIDERS
    for name in candidates:
        key = os.environ.get(_KEY_ENV.get(name, ""))
        if key:
            return {"provider": name, "api_key": key,
                    "model": os.environ.get(f"CA_AI_{name.upper()}_MODEL")}
    return None


def _resolve(firm_id=1):
    """Returns {"provider", "api_key", "model"} (model may be None, meaning
    "use that provider's default"), or None if nothing is configured."""
    return _db_active_row(firm_id) or _env_row()


def active_provider(firm_id=1):
    row = _resolve(firm_id)
    return row["provider"] if row else None


def status(firm_id=1):
    row = _resolve(firm_id)
    if not row:
        return {"configured": False, "provider": None, "model": None}
    return {"configured": True, "provider": row["provider"],
            "model": row["model"] or _DEFAULT_MODELS.get(row["provider"])}


# -------------------------------------------------------- admin management
# Everything Admin > Site Settings > AI Providers needs — list what's
# configured (masked), which provider is live, add/replace a key, delete
# one, and switch which configured key is active. All firm-scoped, all
# audited by the caller (app.py routes call db.audit() themselves, same as
# every other admin CRUD route in this app).

def available_providers():
    """Providers this build of the app actually knows how to call — drives
    the admin UI's provider dropdown so nobody can save a key for a
    provider with no _call_<name>() implementation."""
    return list(_PROVIDERS)


def list_keys(firm_id=1):
    rows = db.query("SELECT id, provider, model, is_active, created_at, updated_at FROM ai_provider_key"
                    " WHERE firm_id = ? ORDER BY provider", (firm_id,))
    return [dict(r, has_key=True, default_model=_DEFAULT_MODELS.get(r["provider"])) for r in rows]


def set_key(firm_id, provider, api_key, model=None, make_active=True, updated_by=None):
    if provider not in _PROVIDERS:
        raise ProviderError(f"unknown_provider: {provider}")
    if not api_key or not api_key.strip():
        raise ProviderError("api_key_required")
    api_key_enc = db.encrypt_secret(api_key.strip())
    if make_active:
        db.execute("UPDATE ai_provider_key SET is_active = 0 WHERE firm_id = ?", (firm_id,))
    db.execute(
        "INSERT INTO ai_provider_key (firm_id, provider, api_key_enc, model, is_active, updated_by)"
        " VALUES (?,?,?,?,?,?)"
        " ON CONFLICT(firm_id, provider) DO UPDATE SET api_key_enc=excluded.api_key_enc,"
        " model=excluded.model, is_active=excluded.is_active, updated_at=datetime('now'),"
        " updated_by=excluded.updated_by",
        (firm_id, provider, api_key_enc, (model or "").strip() or None, 1 if make_active else 0, updated_by))


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


def set_active(firm_id, provider):
    row = db.query("SELECT id FROM ai_provider_key WHERE firm_id = ? AND provider = ?", (firm_id, provider), one=True)
    if not row:
        raise ProviderError(f"not_found: {provider}")
    db.execute("UPDATE ai_provider_key SET is_active = 0 WHERE firm_id = ?", (firm_id,))
    db.execute("UPDATE ai_provider_key SET is_active = 1, updated_at = datetime('now') WHERE id = ?", (row["id"],))


# ------------------------------------------------------------------ calling

def _post_json(url, payload, headers, timeout=30):
    """Stdlib-only HTTP POST — no HTTP client dependency for a provider that
    speaks plain JSON, same convention as payments.py's Khalti/ConnectIPS
    calls. A real User-Agent is required, not cosmetic: Python's default
    "Python-urllib/3.x" gets 403'd by Cloudflare (the CDN in front of
    Groq's API) — confirmed live against api.groq.com."""
    req = urllib.request.Request(
        url, data=json.dumps(payload).encode("utf-8"),
        headers=dict(headers, **{"Content-Type": "application/json", "User-Agent": "CA-Automator/1.0"}),
        method="POST")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "replace")
        if e.code == 401:
            raise ProviderError("invalid_api_key")
        if e.code == 429:
            raise ProviderError("rate_limited")
        raise ProviderError(f"provider_error: {body[:300]}")
    except urllib.error.URLError as e:
        raise ProviderError(f"unreachable: {e.reason}")


def _call_groq(messages, system, max_tokens, api_key, model):
    model = model or _DEFAULT_MODELS["groq"]
    data = _post_json(
        "https://api.groq.com/openai/v1/chat/completions",
        {"model": model, "max_tokens": max_tokens, "temperature": 0.4,
         "messages": [{"role": "system", "content": system}]
         + [{"role": m["role"], "content": m["content"]} for m in messages]},
        {"Authorization": f"Bearer {api_key}"})
    try:
        content = data["choices"][0]["message"]["content"]
    except (KeyError, IndexError):
        raise ProviderError("unexpected_response")
    if not content:
        # gpt-oss models do hidden chain-of-thought reasoning that counts
        # against max_tokens — on a very long conversation this can (rarely)
        # consume the whole budget before any visible reply is written.
        raise ProviderError("empty_response")
    usage = data.get("usage") or {}
    return {"text": content, "model": model,
            "usage": {"input_tokens": usage.get("prompt_tokens", 0),
                      "output_tokens": usage.get("completion_tokens", 0)}}


def _call_anthropic(messages, system, max_tokens, api_key, model):
    try:
        import anthropic
    except ImportError:
        raise ProviderError("provider_not_installed")
    model = model or _DEFAULT_MODELS["anthropic"]
    client = anthropic.Anthropic(api_key=api_key)
    try:
        response = client.messages.create(
            model=model, max_tokens=max_tokens, system=system,
            messages=[{"role": m["role"], "content": m["content"]} for m in messages])
    except anthropic.AuthenticationError:
        raise ProviderError("invalid_api_key")
    except anthropic.RateLimitError:
        raise ProviderError("rate_limited")
    except anthropic.APIConnectionError:
        raise ProviderError("unreachable")
    except anthropic.APIStatusError as e:
        raise ProviderError(f"provider_error: {e.message}")
    text = "".join(b.text for b in response.content if b.type == "text")
    return {"text": text, "model": model,
            "usage": {"input_tokens": response.usage.input_tokens,
                      "output_tokens": response.usage.output_tokens}}


_CALLERS = {"groq": _call_groq, "anthropic": _call_anthropic}


def call(messages, system, max_tokens, firm_id=1):
    """messages: [{"role": "user"|"assistant", "content": str}, ...] — full
    turn history, last entry the new user message. Returns
    {"text", "model", "usage": {"input_tokens", "output_tokens"}}.
    Raises ProviderError("not_configured") if no provider key is present."""
    row = _resolve(firm_id)
    if not row:
        raise ProviderError("not_configured")
    return _CALLERS[row["provider"]](messages, system, max_tokens, row["api_key"], row["model"])
