"""
Authentication core: JWT decoding and identity extraction.
Updated to support Clerk org-scoped tokens while preserving dev behavior.
"""

import httpx
import os
from functools import lru_cache
from fastapi import Depends, HTTPException, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import jwt
from jose.exceptions import JWTError, ExpiredSignatureError
from typing import Optional, Tuple

from fmatch.shared.settings import get_settings
from . import settings as saas_settings

# Make bearer optional so SSE (which can't send headers) works in development.
bearer_scheme = HTTPBearer(auto_error=False)


def _is_truthy(value: Optional[str]) -> bool:
    return str(value or "").strip().lower() in {"1", "true", "yes", "on"}


def _is_dev_environment() -> bool:
    return (saas_settings.ENV or "").strip().lower() in {"dev", "development", "test"}


def _dev_auth_bypass_enabled() -> bool:
    # Tests keep bypass semantics to avoid breaking existing fixtures.
    if os.getenv("FM_TEST_MODE") == "1":
        return True
    if not _is_dev_environment():
        return False
    return _is_truthy(os.getenv("AUTH_DEV_BYPASS_ENABLED", "false"))


def _effective_auth() -> Tuple[str, str, str]:
    """Resolve issuer, audience, and JWKS URL for the active provider (Clerk or generic).

    Returns (issuer, audience, jwks_url).
    """
    s = get_settings()
    issuer = (getattr(s, "CLERK_ISSUER", None) or s.AUTH_ISSUER or "").rstrip("/")
    audience = getattr(s, "CLERK_AUDIENCE", None) or s.AUTH_AUDIENCE
    jwks_url = getattr(s, "CLERK_JWKS_URL", None) or f"{issuer}/.well-known/jwks.json"
    return issuer, audience, jwks_url


def _allowed_hs256_audiences() -> list[str]:
    env = os.getenv("JWT_ALLOWED_AUDIENCES") or os.getenv("JWT_AUDIENCES")
    if env:
        return [aud.strip() for aud in env.split(",") if aud.strip()]
    return ["google-sheets-addon", "g-gremlin", "gremlin-cli", "g-gremlin-cli"]


@lru_cache(maxsize=1)
def get_jwks() -> dict:
    """
    Fetch and cache JWKS from auth provider (Clerk).
    """
    _, _, jwks_url = _effective_auth()
    try:
        response = httpx.get(jwks_url, timeout=10.0)
        response.raise_for_status()
        return response.json()
    except httpx.RequestError as e:
        raise RuntimeError(f"Could not fetch JWKS from provider: {e}")


def _decode_token_rs256(token: str) -> dict:
    """Decode RS256 JWT using provider JWKS and validate iss/aud."""
    issuer, audience, _ = _effective_auth()

    # Get the unverified header to find the Key ID (kid)
    unverified_header = jwt.get_unverified_header(token)
    kid = unverified_header.get("kid")

    jwks = get_jwks()
    signing_key = next(
        (key for key in jwks.get("keys", []) if key.get("kid") == kid), None
    )
    if signing_key is None:
        raise JWTError("Signing key not found in JWKS")

    # Decode and validate
    payload = jwt.decode(
        token,
        signing_key,
        algorithms=["RS256"],
        audience=audience,
        issuer=issuer,
    )
    return payload


async def _fetch_email_from_clerk(user_id: str) -> str | None:
    """Fetch user email from Clerk API if it's not in the JWT token."""
    import logging

    log = logging.getLogger(__name__)

    clerk_secret = saas_settings.CLERK_SECRET_KEY
    if not clerk_secret:
        log.warning("[AUTH] CLERK_SECRET_KEY not configured, cannot fetch email")
        return None

    clerk_url = f"https://api.clerk.com/v1/users/{user_id}"

    try:
        async with httpx.AsyncClient(timeout=5.0) as client:
            resp = await client.get(
                clerk_url, headers={"Authorization": f"Bearer {clerk_secret}"}
            )

        if not resp.is_success:
            log.warning(f"[AUTH] Failed to fetch user from Clerk: {resp.status_code}")
            return None

        user_data = resp.json()

        # Try primary email first, then first email in list
        email_addresses = user_data.get("email_addresses", [])
        if email_addresses:
            # Find primary email
            for email_obj in email_addresses:
                if email_obj.get("id") == user_data.get("primary_email_address_id"):
                    return email_obj.get("email_address")
            # Fallback to first email
            return email_addresses[0].get("email_address")

        return None

    except Exception as e:
        log.warning(f"[AUTH] Error fetching email from Clerk: {e}")
        return None


def _extract_identity_from_claims(claims: dict) -> dict:
    """Extract identity fields in a Clerk-compatible way."""
    import logging

    log = logging.getLogger(__name__)

    user_id = (
        claims.get("sub")
        or claims.get("user_id")
        or claims.get("userId")
        or claims.get("uid")
    )

    # Try multiple ways to get email from Clerk token
    email = (
        claims.get("email")
        or claims.get("user_email")
        or (claims.get("email_addresses") or [{}])[0].get("email_address")
        or claims.get("primary_email")
    )

    # Debug only; avoid logging PII (e.g., email) at info level.
    log.debug(
        "[AUTH] Extracted from token: user_id_present=%s email_present=%s",
        bool(user_id),
        bool(email),
    )
    # Avoid logging full claims; they may contain PII and provider metadata.
    try:
        log.debug("[AUTH] Token claims keys: %s", sorted(list(claims.keys()))[:80])
    except Exception:
        pass

    org_id = (
        claims.get("org_id")
        or claims.get("organization_id")
        or claims.get("orgId")
        or claims.get("account_id")
        or claims.get("accountId")
    )
    if not org_id:
        org_obj = claims.get("o")
        if isinstance(org_obj, dict):
            org_id = (
                org_obj.get("id")
                or org_obj.get("org_id")
                or org_obj.get("organization_id")
            )
    if not org_id:
        orgs = claims.get("orgs") or []
        if isinstance(orgs, list) and orgs:
            # Support both [{id:..}] and string lists
            first = orgs[0]
            org_id = first.get("id") if isinstance(first, dict) else first
    if not org_id:
        memberships = claims.get("org_memberships") or []
        if isinstance(memberships, list) and memberships:
            first = memberships[0]
            if isinstance(first, dict):
                org_id = first.get("org_id") or first.get("organization_id")

    return {"user_id": user_id, "org_id": org_id, "email": email}


async def _check_seat_status(identity: dict) -> None:
    """Check if user's seat is active. Raises 403 if suspended/removed."""
    import logging
    from .db import AsyncSessionLocal
    from sqlalchemy import select
    from .models import User

    log = logging.getLogger(__name__)

    # Skip seat check in development mode
    if saas_settings.ENV == "development":
        return

    user_id = identity.get("user_id")
    email = identity.get("email")
    account_id = identity.get("org_id")

    if not user_id or not account_id:
        return  # Can't check without these

    try:
        async with AsyncSessionLocal() as db:
            # Try to find user by Clerk user ID first, then by email
            result = await db.execute(
                select(User).where(
                    User.clerk_user_id == user_id, User.account_id == account_id
                )
            )
            user = result.scalar_one_or_none()

            # Fallback to email lookup if clerk_user_id not set yet
            if not user and email:
                result = await db.execute(
                    select(User).where(
                        User.email == email, User.account_id == account_id
                    )
                )
                user = result.scalar_one_or_none()

            if user and user.seat_status not in ["active", "invited"]:
                log.warning(
                    f"[AUTH] Blocked user with seat_status={user.seat_status}: "
                    f"email={email} account={account_id}"
                )
                raise HTTPException(
                    403,
                    f"Your seat is {user.seat_status}. Contact your organization admin.",
                )

    except HTTPException:
        raise  # Re-raise our own exceptions
    except Exception as e:
        log.error(f"[AUTH] Error checking seat status: {e}")
        # Don't block auth on seat check errors in production
        # (fail open to avoid outages from DB issues)


async def _infer_org_for_user(
    user_id: Optional[str], email: Optional[str]
) -> Optional[str]:
    """Best-effort inference of org/account when token lacks org_id."""
    if not user_id:
        return None

    import logging
    from sqlalchemy import or_, select

    try:
        from .db import AsyncSessionLocal
        from .models import Account, AccountMember, User
    except Exception:
        return None

    log = logging.getLogger(__name__)

    try:
        async with AsyncSessionLocal() as db:
            try:
                if AccountMember is not None:
                    result = await db.execute(
                        select(AccountMember.account_id)
                            .where(AccountMember.user_id == user_id)
                            .order_by(AccountMember.created_at.desc())
                            .limit(1)
                    )
                    account_row = result.scalar_one_or_none()
                    if account_row:
                        return str(account_row)
            except Exception as exc:  # pragma: no cover - defensive
                log.warning(
                    "[AUTH] Failed to infer org from AccountMember for %s: %s",
                    user_id,
                    exc,
                )

            try:
                if email and User is not None:
                    result = await db.execute(
                        select(User.account_id)
                            .where(User.email == email)
                            .order_by(User.created_at.desc())
                            .limit(1)
                    )
                    account_row = result.scalar_one_or_none()
                    if account_row:
                        return str(account_row)
            except Exception as exc:  # pragma: no cover - defensive
                log.warning(
                    "[AUTH] Failed to infer org from User email for %s: %s",
                    user_id,
                    exc,
                )

            try:
                if Account is not None:
                    result = await db.execute(
                        select(Account.id).where(
                            or_(Account.id == user_id, Account.clerk_org_id == user_id)
                        )
                    )
                    account_row = result.scalar_one_or_none()
                    if account_row:
                        return str(account_row)
            except Exception as exc:  # pragma: no cover - defensive
                log.warning(
                    "[AUTH] Failed to infer org from Account table for %s: %s",
                    user_id,
                    exc,
                )
    except Exception as exc:  # pragma: no cover - defensive
        log.warning("[AUTH] Error during org inference for %s: %s", user_id, exc)

    return None


async def _ensure_account_row(user_id: str, email: Optional[str] = None) -> None:
    """Auto-provision an accounts row for CLI-only trial users.

    This prevents FK violations when downstream operations (e.g. HubSpot
    token storage) write to tables that reference accounts.id.
    """
    import logging

    log = logging.getLogger(__name__)
    try:
        from .db import AsyncSessionLocal
        from .models import Account
    except Exception:
        return

    try:
        async with AsyncSessionLocal() as db:
            existing = await db.get(Account, user_id)
            if existing:
                return
            name = email or f"CLI User {user_id[:12]}"
            acct = Account(id=user_id, name=name)
            db.add(acct)
            await db.commit()
            log.info("[AUTH] Auto-provisioned account row for CLI trial user %s", user_id)
    except Exception as exc:
        log.warning("[AUTH] Failed to auto-provision account for %s: %s", user_id, exc)


async def get_current_identity(
    credentials: HTTPAuthorizationCredentials = Security(bearer_scheme),
) -> dict:
    """Validate Bearer token and return {user_id, org_id, email}.

    - No credentials: returns dev identity only in development mode
    - "dev-token": returns dev identity only in development mode
    - Real Clerk JWT: validates against Clerk JWKS in all environments
    """
    import logging

    _auth_log = logging.getLogger(__name__)

    # No header
    if credentials is None:
        if _dev_auth_bypass_enabled():
            return {
                "user_id": "dev-user",
                "org_id": saas_settings.DEV_ACCOUNT_ID,
                "email": "dev@example.com",
            }
        raise HTTPException(401, "Missing authentication token")

    token = credentials.credentials

    # Dev token bypass (only for literal "dev-token" string)
    if token == "dev-token":
        if _dev_auth_bypass_enabled():
            return {
                "user_id": "dev-user",
                "org_id": saas_settings.DEV_ACCOUNT_ID,
                "email": "dev@example.com",
            }
        raise HTTPException(401, "Dev token not allowed in production")

    # Try to decode as our own long-lived token first (HS256)
    try:
        # Check if it's our long-lived token by looking at the header
        unverified_header = jwt.get_unverified_header(token)
        alg = unverified_header.get("alg", "")

        # If it's HS256, it's our long-lived token (or agent license JWT)
        if alg == "HS256":
            # Try primary secret first, then agent secret as fallback
            secrets_to_try: list[str] = []
            if saas_settings.JWT_SECRET_KEY:
                secrets_to_try.append(saas_settings.JWT_SECRET_KEY)
            agent_secret = getattr(saas_settings, "AGENT_JWT_SECRET", None)
            if agent_secret and agent_secret not in secrets_to_try:
                secrets_to_try.append(agent_secret)
            if not secrets_to_try:
                _auth_log.error(
                    "[AUTH] No HS256 secrets configured. "
                    "JWT_SECRET_KEY=%s, AGENT_JWT_SECRET=%s",
                    "set" if saas_settings.JWT_SECRET_KEY else "EMPTY",
                    "set(%d chars)" % len(agent_secret) if agent_secret else "EMPTY",
                )
                raise JWTError("No JWT secret configured")

            _auth_log.info(
                "[AUTH] HS256 decode attempt: %d secret(s) available "
                "(JWT_SECRET_KEY=%s, AGENT_JWT_SECRET=%s)",
                len(secrets_to_try),
                "set(%d)" % len(saas_settings.JWT_SECRET_KEY) if saas_settings.JWT_SECRET_KEY else "EMPTY",
                "set(%d)" % len(agent_secret) if agent_secret else "EMPTY",
            )

            # Accept both issuer variants (legacy + agent-jwt)
            allowed_issuers = ["foundryops", "foundryops.io"]
            allowed_audiences = _allowed_hs256_audiences()

            claims = None
            last_err: Exception | None = None
            for secret in secrets_to_try:
                for issuer in allowed_issuers:
                    try:
                        claims = jwt.decode(
                            token,
                            secret,
                            algorithms=["HS256"],
                            options={"verify_aud": False},
                            issuer=issuer,
                        )
                        _auth_log.info("[AUTH] HS256 decode OK with issuer=%s", issuer)
                        break
                    except JWTError as e:
                        _auth_log.debug(
                            "[AUTH] HS256 attempt failed: secret_len=%d issuer=%s err=%s",
                            len(secret), issuer, e,
                        )
                        last_err = e
                        continue
                if claims is not None:
                    break

            if claims is None:
                _auth_log.warning(
                    "[AUTH] All HS256 decode attempts failed. last_err=%s", last_err
                )
                raise last_err or JWTError("HS256 decode failed")

            aud_claim = claims.get("aud")
            if aud_claim:
                aud_list = aud_claim if isinstance(aud_claim, list) else [aud_claim]
                if not any(aud in allowed_audiences for aud in aud_list):
                    raise JWTError("Invalid audience")

            # Extract identity from our token
            ident = _extract_identity_from_claims(claims)
            license_tier = claims.get("tier")
            if license_tier is not None:
                ident["license_tier"] = str(license_tier)
            trial_ends_at = claims.get("trial_ends_at")
            if trial_ends_at is not None:
                ident["license_trial_ends_at"] = str(trial_ends_at)

            # Agent license tokens have 'tier' but no org_id — infer it
            if not ident.get("org_id") and claims.get("tier"):
                inferred_org = await _infer_org_for_user(
                    ident.get("user_id"), ident.get("email")
                )
                if inferred_org:
                    ident["org_id"] = inferred_org
                elif ident.get("user_id"):
                    # CLI-only trial users don't exist in the FastAPI DB yet.
                    # Use their Clerk user_id as a synthetic org_id and
                    # auto-provision an accounts row so downstream FK writes
                    # (e.g. HubSpot token storage) don't violate constraints.
                    uid = ident["user_id"]
                    _auth_log.debug(
                        "[AUTH] Using user_id as fallback org_id for agent trial user %s",
                        uid,
                    )
                    ident["org_id"] = uid
                    await _ensure_account_row(uid, ident.get("email"))

            if not ident.get("org_id"):
                raise HTTPException(401, "No organization on token")
            if not ident.get("user_id"):
                raise HTTPException(401, "Invalid token: missing user_id")

            return ident

        # Otherwise, it's a Clerk RS256 token
        else:
            claims = _decode_token_rs256(token)
            ident = _extract_identity_from_claims(claims)
            if not ident.get("user_id"):
                raise HTTPException(401, "Invalid token: missing sub")

            # If email is missing from token, fetch it from Clerk API
            if not ident.get("email"):
                fetched_email = await _fetch_email_from_clerk(ident["user_id"])
                if fetched_email:
                    ident["email"] = fetched_email

            if not ident.get("org_id"):
                inferred_org = await _infer_org_for_user(
                    ident.get("user_id"), ident.get("email")
                )
                if inferred_org:
                    ident["org_id"] = inferred_org
                elif ident.get("user_id"):
                    # Allow users without org (e.g., admin users) by using user_id as fallback
                    uid = ident["user_id"]
                    _auth_log.debug(
                        "[AUTH] Using user_id as fallback org_id for user %s", uid
                    )
                    ident["org_id"] = uid
                    await _ensure_account_row(uid, ident.get("email"))
                else:
                    raise HTTPException(401, "No organization selected on token")

            # Check seat status (only for Clerk users, not dev mode)
            await _check_seat_status(ident)

            return ident

    except ExpiredSignatureError:
        raise HTTPException(401, "Token has expired")
    except JWTError as e:
        raise HTTPException(401, f"Invalid token: {e}")


async def get_current_account(
    credentials: HTTPAuthorizationCredentials = Security(bearer_scheme),
) -> str:
    """Return current account (organization) ID from the Bearer token.

    Maps Clerk `org_id` to our `account_id`.
    """
    ident = await get_current_identity(credentials)
    return ident["org_id"]


async def require_admin(account_id: str = Depends(get_current_account)) -> str:
    """
    Require admin role for accessing admin endpoints.

    In development mode, always allows access with dev-token.
    In production, would check actual account role from database.
    """
    # Development mode - allow admin access only when explicit bypass is enabled.
    if _dev_auth_bypass_enabled():
        # Dev account is admin by default
        if account_id == saas_settings.DEV_ACCOUNT_ID:
            return account_id

    raise HTTPException(status_code=403, detail="Admin access required")


async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Security(bearer_scheme),
) -> str:
    """FastAPI dependency to get current user ID from the token."""
    ident = await get_current_identity(credentials)
    return ident["user_id"]


class OrgContext:
    """Organization context for authenticated requests."""

    def __init__(self, account_id: str, user_id: str, email: Optional[str] = None):
        self.account_id = account_id
        self.user_id = user_id
        self.email = email


async def require_org_context(
    credentials: HTTPAuthorizationCredentials = Security(bearer_scheme),
) -> OrgContext:
    """
    FastAPI dependency to get organization context from the token.

    Returns an OrgContext object with account_id, user_id, and email.
    Raises 401 if token is invalid or organization is not selected.
    """
    ident = await get_current_identity(credentials)

    if not ident.get("org_id"):
        raise HTTPException(401, "No organization selected")

    return OrgContext(
        account_id=ident["org_id"], user_id=ident["user_id"], email=ident.get("email")
    )
