"""Link a Thermograph account to a Discord account via OAuth2 (identify scope).

The Discord user id we store here is the durable key a later feature (DM alerts)
uses to reach the person. The flow is the standard authorization-code grant:

  /link/start    -> redirect the logged-in user to Discord's consent screen
  /link/callback -> exchange the code, read the Discord user id, store it
  /unlink        -> forget it

`state` is signed with the app's auth secret (stdlib hmac, no new dependency) and
carries the Thermograph user id, so the callback can't be replayed or bound to a
different account. Everything requires an active session, so linking always acts on
the person who is actually logged in.
"""
from __future__ import annotations

import base64
import hashlib
import hmac
import json
import os
import time
import urllib.parse

import httpx
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession

from accounts.db import get_async_session
from accounts.models import User
from accounts.users import SECRET, current_active_user

router = APIRouter(tags=["discord"])

CLIENT_ID = os.environ.get("THERMOGRAPH_DISCORD_APP_ID", "").strip()
CLIENT_SECRET = os.environ.get("THERMOGRAPH_DISCORD_CLIENT_SECRET", "").strip()
BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").rstrip("/")

_AUTHORIZE = "https://discord.com/api/oauth2/authorize"
_TOKEN = "https://discord.com/api/oauth2/token"
_ME = "https://discord.com/api/users/@me"

# How long a start->callback round trip may take before the signed state expires.
_STATE_TTL = 600
_TIMEOUT = httpx.Timeout(10.0)


def enabled() -> bool:
    return bool(CLIENT_ID and CLIENT_SECRET)


# --- signed state ------------------------------------------------------------
def _b64(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).decode().rstrip("=")


def _unb64(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))


def _sign_state(uid: str) -> str:
    payload = _b64(json.dumps({"u": uid, "t": int(time.time())}).encode())
    mac = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
    return f"{payload}.{mac}"


def _verify_state(state: str) -> str | None:
    """The Thermograph user id the state was minted for, or None if it's missing,
    tampered, or older than the TTL."""
    try:
        payload, mac = state.split(".", 1)
    except (ValueError, AttributeError):
        return None
    expect = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
    if not hmac.compare_digest(mac, expect):
        return None
    try:
        data = json.loads(_unb64(payload))
    except (ValueError, json.JSONDecodeError):
        return None
    if time.time() - float(data.get("t", 0)) > _STATE_TTL:
        return None
    return data.get("u")


def _redirect_uri(request: Request) -> str:
    """The callback URL, matched to how the app is actually reached (scheme/host +
    base path). Must equal the redirect registered in the Discord portal."""
    proto = request.headers.get("x-forwarded-proto") or request.url.scheme
    host = request.headers.get("host") or request.url.netloc
    return f"{proto}://{host}{BASE}/api/v2/discord/link/callback"


def _account_redirect(status: str) -> RedirectResponse:
    # Back to the alerts page, which surfaces the link state; 303 so the browser
    # issues a GET after the callback.
    return RedirectResponse(url=f"{BASE}/subscriptions?discord={status}", status_code=303)


# --- routes ------------------------------------------------------------------
@router.get("/discord/config")
async def discord_config():
    """Whether Discord account-linking is configured on this server. The account
    menu uses it to show the "Link Discord" entry only when linking will actually
    work — so an unconfigured server surfaces no dead-end Discord UI, and enabling
    it later (setting the OAuth env vars) makes the entry appear on its own."""
    return {"enabled": enabled()}


@router.get("/discord/link/start")
async def link_start(request: Request, user=Depends(current_active_user)):
    if not enabled():
        return _account_redirect("unavailable")
    params = {
        "client_id": CLIENT_ID,
        "redirect_uri": _redirect_uri(request),
        "response_type": "code",
        "scope": "identify",
        "state": _sign_state(str(user.id)),
        "prompt": "consent",
    }
    return RedirectResponse(url=f"{_AUTHORIZE}?{urllib.parse.urlencode(params)}", status_code=303)


@router.get("/discord/link/callback")
async def link_callback(
    request: Request,
    user=Depends(current_active_user),
    session: AsyncSession = Depends(get_async_session),
):
    if not enabled():
        return _account_redirect("unavailable")
    # User declined on Discord's screen, or the state doesn't check out.
    code = request.query_params.get("code")
    state = request.query_params.get("state", "")
    if not code:
        return _account_redirect("cancelled")
    if _verify_state(state) != str(user.id):
        return _account_redirect("error")
    try:
        async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
            tok = await client.post(_TOKEN, data={
                "client_id": CLIENT_ID,
                "client_secret": CLIENT_SECRET,
                "grant_type": "authorization_code",
                "code": code,
                "redirect_uri": _redirect_uri(request),
            }, headers={"Content-Type": "application/x-www-form-urlencoded"})
            if tok.status_code >= 300:
                return _account_redirect("error")
            access = tok.json().get("access_token")
            me = await client.get(_ME, headers={"Authorization": f"Bearer {access}"})
            if me.status_code >= 300:
                return _account_redirect("error")
            discord_id = str(me.json().get("id") or "")
    except Exception:  # noqa: BLE001 - any network/parse failure is a graceful error
        return _account_redirect("error")
    if not discord_id:
        return _account_redirect("error")
    # Linking is an active opt-in, so turn DM alerts on; the user can mute them
    # (POST /discord/dm) while staying linked.
    await session.execute(
        update(User).where(User.id == user.id).values(discord_id=discord_id, discord_dm=True)
    )
    await session.commit()
    return _account_redirect("linked")


@router.post("/discord/unlink", status_code=204)
async def unlink(
    user=Depends(current_active_user),
    session: AsyncSession = Depends(get_async_session),
):
    await session.execute(
        update(User).where(User.id == user.id).values(discord_id=None, discord_dm=False)
    )
    await session.commit()


class DmToggle(BaseModel):
    enabled: bool


@router.post("/discord/dm", status_code=204)
async def set_dm(
    payload: DmToggle,
    user=Depends(current_active_user),
    session: AsyncSession = Depends(get_async_session),
):
    """Turn Discord DM alerts on or off without unlinking. A no-op unless a Discord
    account is actually linked."""
    await session.execute(
        update(User).where(User.id == user.id, User.discord_id.is_not(None))
        .values(discord_dm=payload.enabled)
    )
    await session.commit()
