# src/fmatch/saas/security.py
"""
Centralized security module for authentication and authorization.
Single source of truth for auth logic across the application.
"""

import logging
import os
from dataclasses import dataclass
from typing import Optional

from fastapi import Depends, HTTPException, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, APIKeyHeader
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select

from . import settings as saas_settings
from .db import get_session
from .auth_core import (
    get_current_account,
    get_current_identity,
)
from .observability.context import set_account_id

# Security schemes
http_bearer = HTTPBearer(auto_error=False)
api_key_hdr = APIKeyHeader(name="X-API-Key", auto_error=False)

# Dev account ID from settings (single source of truth)
DEV_ACCOUNT_ID = saas_settings.DEV_ACCOUNT_ID

# Admin email allowlist (optional)
ADMIN_EMAILS = {
    email.strip().lower()
    for email in os.getenv("ADMIN_EMAILS", "").split(",")
    if email and email.strip()
}


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
    # Explicit opt-in for local development only.
    return _is_truthy(os.getenv("AUTH_DEV_BYPASS_ENABLED", "false"))


@dataclass
class AdminContext:
    """Lightweight context returned by require_admin."""

    user_id: str
    email: Optional[str]
    account_id: str
    is_admin: bool = True


async def require_account(
    bearer: Optional[HTTPAuthorizationCredentials] = Security(http_bearer),
    api_key: Optional[str] = Security(api_key_hdr),
    db: AsyncSession = Depends(get_session),
) -> str:
    """
    Require authenticated account for protected endpoints.

    In development: allows bare requests (returns DEV_ACCOUNT_ID)
    In production: requires valid Bearer token or API key

    Sets account_id in observability context so all downstream logs
    automatically include the tenant identifier.
    """
    import logging
    import os

    logger = logging.getLogger("fmatch.auth")

    # Debug logging - use info level to ensure it shows
    env_from_settings = saas_settings.ENV
    env_from_os = os.getenv("ENV", "not_set")
    logger.info(
        f"require_account called: ENV from settings={env_from_settings}, from os={env_from_os}"
    )
    logger.info(
        "require_account: bearer=%s",
        "set" if (bearer and bearer.credentials) else "missing",
    )
    logger.info(
        "require_account: api_key=%s",
        "set" if api_key else "missing",
    )

    # Resolve account_id — all successful paths assign to this variable.
    # Exceptions (HTTPException) raise before reaching the return.
    resolved: Optional[str] = None

    # IMMEDIATE DEV TOKEN CHECK - before any other logic
    if bearer and bearer.credentials == "dev-token":
        logger.info(f"require_account: Dev token detected, ENV={saas_settings.ENV}")
        if _dev_auth_bypass_enabled():
            logger.info(
                f"require_account: Accepting dev-token, returning {DEV_ACCOUNT_ID}"
            )
            resolved = DEV_ACCOUNT_ID
        else:
            logger.warning("require_account: Dev token not allowed (bypass disabled)")
            raise HTTPException(401, "Dev token not allowed")

    # Dev: allow bare requests
    elif _dev_auth_bypass_enabled() and not (bearer or api_key):
        logger.info("require_account: Returning DEV_ACCOUNT_ID (no auth provided)")
        resolved = DEV_ACCOUNT_ID

    elif bearer:
        # Delegate JWT validation to auth_core for other tokens
        logger.debug("require_account: Delegating to get_current_account")
        resolved = await get_current_account(bearer)

    # IMMEDIATE API KEY CHECK
    elif api_key == "local-dev":
        logger.info(
            f"require_account: local-dev API key detected, ENV={saas_settings.ENV}"
        )
        if _dev_auth_bypass_enabled():
            logger.info(
                f"require_account: Accepting local-dev API key, returning {DEV_ACCOUNT_ID}"
            )
            resolved = DEV_ACCOUNT_ID
        else:
            logger.warning("require_account: Dev API key not allowed (bypass disabled)")
            raise HTTPException(401, "Dev API key not allowed")

    elif api_key:
        # Validate real API keys against database
        logger.debug("require_account: Validating API key against database")
        from .models import APIKey

        import bcrypt
        from datetime import datetime

        if len(api_key) < 8:
            logger.warning("require_account: API key too short to contain prefix")
        else:
            key_prefix = api_key[:8]
            result = await db.execute(
                select(APIKey).where(
                    APIKey.key_prefix == key_prefix, APIKey.is_active == True
                )
            )
            api_key_obj = result.scalar_one_or_none()
            if (
                api_key_obj
                and api_key_obj.key_hash
                and bcrypt.checkpw(api_key.encode(), api_key_obj.key_hash.encode())
            ):
                logger.debug(
                    f"require_account: Valid API key found for account {api_key_obj.account_id}"
                )
                api_key_obj.last_used_at = datetime.utcnow()
                db.add(api_key_obj)
                await db.commit()
                resolved = str(api_key_obj.account_id)

        if resolved is None:
            logger.warning(f"require_account: Invalid API key: {api_key[:8]}...")

    # FINAL FALLBACK FOR DEVELOPMENT (explicit opt-in only)
    if resolved is None:
        insecure_fallback = _is_truthy(os.getenv("ALLOW_INSECURE_API_KEY_FALLBACK", "false"))
        if _dev_auth_bypass_enabled() or (insecure_fallback and _is_dev_environment()):
            logger.warning(
                "require_account: No valid auth but allowing explicit dev fallback, returning %s",
                DEV_ACCOUNT_ID,
            )
            resolved = DEV_ACCOUNT_ID

    if resolved is None:
        logger.error("require_account: Authentication failed - no valid auth provided")
        raise HTTPException(401, "Authentication required")

    # Single exit: set observability context for all downstream logs
    set_account_id(resolved)
    return resolved


async def require_user(
    account_id: str = Depends(require_account),
    bearer: Optional[HTTPAuthorizationCredentials] = Security(http_bearer),
    db: AsyncSession = Depends(get_session),
) -> str:
    """
    Require authenticated user for endpoints that need user-level tracking.
    Used for audit trails (apply/reject operations).
    """
    import logging

    logger = logging.getLogger("fmatch.auth")

    # In explicit dev-bypass mode, return a stable synthetic user for simplicity.
    if _dev_auth_bypass_enabled():
        logger.info("require_user: Returning dev-user in development mode")
        return "dev-user"

    # If we have a bearer token, extract identity and upsert user/account
    if bearer:
        ident = await get_current_identity(bearer)
        user_id = ident.get("user_id") or "user-unknown"
        email = ident.get("email")
        org_id = ident.get("org_id") or account_id

        # Upsert Account and User (minimal fields)
        try:
            from .models import Account, User
        except Exception:
            Account = None
            User = None

        if Account is not None and User is not None:
            # Ensure account exists
            acct = await db.get(Account, org_id)
            if not acct:
                acct = Account(id=org_id, name=f"Clerk Org {org_id}")
                db.add(acct)
                await db.flush()

            # Ensure user exists
            existing = await db.execute(select(User).where(User.id == user_id))
            u = existing.scalar_one_or_none()
            if not u:
                u = User(
                    id=user_id, email=email or f"{user_id}@unknown", account_id=org_id
                )
                db.add(u)
            else:
                # Update email/account if changed
                dirty = False
                if email and u.email != email:
                    u.email = email
                    dirty = True
                if org_id and u.account_id != org_id:
                    u.account_id = org_id
                    dirty = True
                if dirty:
                    db.add(u)
            await db.commit()

        return user_id

    # Fallback to account ID if no user info available
    return f"user-{account_id}"


async def require_admin(
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
    db: AsyncSession = Depends(get_session),
) -> AdminContext:
    """
    Require admin role for protected admin endpoints.
    Validates against Clerk-authenticated user records.
    """
    logger = logging.getLogger("fmatch.auth")

    # Allow everything only when explicit dev bypass is enabled.
    if _dev_auth_bypass_enabled():
        logger.info("require_admin: Allowing admin access in development mode")
        return AdminContext(
            user_id="dev-admin",
            email="dev-admin@localhost",
            account_id=account_id,
            is_admin=True,
        )

    from .models import User, UserRole

    user = await db.get(User, user_id)
    if not user:
        logger.warning(
            "require_admin: User %s not found (account=%s)", user_id, account_id
        )
        raise HTTPException(403, "Admin access required")

    role_value = user.role
    is_admin_role = UserRole.is_admin_value(role_value)

    email = (user.email or "").lower()
    is_admin_email = bool(email and email in ADMIN_EMAILS)

    if is_admin_role or is_admin_email:
        logger.info(
            "require_admin: Admin access granted for user %s (account=%s)",
            user_id,
            account_id,
        )
        return AdminContext(
            user_id=user.id,
            email=user.email,
            account_id=account_id,
            is_admin=True,
        )

    logger.warning(
        "require_admin: Access denied for user %s (account=%s, role=%s, email=%s)",
        user_id,
        account_id,
        role_value,
        user.email,
    )
    raise HTTPException(403, "Admin access required")


async def require_role(
    *roles: str,
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
    db: AsyncSession = Depends(get_session),
):
    """Require one of the specified roles for access.

    Accepts role names like 'admin', 'manager', 'steward'. In development,
    always allows access.
    """
    import logging
    from .models import User, UserRole

    logger = logging.getLogger("fmatch.auth")

    # Dev bypass: allow everything only when explicit bypass is enabled.
    if _dev_auth_bypass_enabled():
        return True

    # Normalize roles
    normalized = {str(r).strip().lower() for r in roles or []}
    if not normalized:
        normalized = {"admin"}

    # Load user
    me = (
        await db.execute(
            select(User).where(
                User.id == str(user_id), User.account_id == str(account_id)
            )
        )
    ).scalar_one_or_none()
    if not me:
        logger.warning("require_role: user not found for role check")
        raise HTTPException(403, "Insufficient role")

    r = me.role
    resolved_role = UserRole.from_raw(r)
    raw_role_value = str(r).strip().lower() if r is not None else ""
    admin_values = UserRole.admin_values()
    steward_values = admin_values | {"steward"}
    role_ok = False
    try:
        if resolved_role:
            role_ok = (
                ("admin" in normalized and resolved_role in UserRole.admin_roles())
                or (
                    "manager" in normalized
                    and resolved_role
                    in ({UserRole.STEWARD} | set(UserRole.admin_roles()))
                )
                or (
                    "steward" in normalized
                    and resolved_role
                    in ({UserRole.STEWARD} | set(UserRole.admin_roles()))
                )
            )
        else:
            role_ok = (
                ("admin" in normalized and raw_role_value in admin_values)
                or (
                    "manager" in normalized
                    and raw_role_value in ({"manager"} | steward_values)
                )
                or ("steward" in normalized and raw_role_value in steward_values)
            )
    except Exception:
        role_ok = False

    if not role_ok:
        raise HTTPException(403, "Insufficient role")
    return True
