from __future__ import annotations

from datetime import datetime
import hashlib
import hmac
import json
import secrets
import time
from urllib.parse import quote

from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app.core.security import create_access_token
from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
from app.models.user import User
from app.schemas.sso import SsoTicketRequest
from app.services.auth_service import AuthService
from app.services.entitlement_service import EntitlementService
from app.services.redis_client import get_sync_redis_client
from app.services.security_state_service import SecurityStateService
from app.services.secret_service import SecretService


class SsoService:
    CODE_TTL_SECONDS = 60
    SIGNATURE_WINDOW_SECONDS = 300
    CODE_NAMESPACE = "auth:sso:code"
    NONCE_NAMESPACE = "auth:sso:nonce"

    @classmethod
    def issue_ticket(
        cls,
        db: Session,
        *,
        app_id: str,
        timestamp: str,
        nonce: str,
        signature: str,
        raw_body: bytes,
        payload: SsoTicketRequest,
        ip: str,
    ) -> dict:
        client = db.scalar(
            select(SsoClient).where(SsoClient.app_id == app_id).with_for_update()
        )
        if client is None or client.status != 1:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="接入应用不存在或已停用")

        try:
            cls._verify_request(client, timestamp, nonce, signature, raw_body)
            cls._reserve_nonce(client, nonce)
            SecurityStateService.enforce_limit(
                f"rate:sso:ticket:{client.id}",
                limit=600,
                window_seconds=60,
                message="该应用免登录请求过于频繁，请稍后再试",
            )
            identity, user = cls._resolve_identity(db, client, payload)
            cls._validate_return_url(client, payload.returnUrl)
            AuthService.ensure_user_can_login(user)

            now = _db_now(db)
            client.last_used_at = now
            identity.phone_snapshot = payload.verifiedPhone or identity.phone_snapshot
            identity.display_name_snapshot = payload.displayName or identity.display_name_snapshot
            db.add(
                SsoLoginAudit(
                    client_id=client.id,
                    user_id=user.id,
                    external_user_id=payload.externalUserId,
                    action="ticket",
                    status="SUCCESS",
                    ip=ip,
                )
            )
            db.commit()

            code = cls._store_ticket(
                {
                    "clientId": client.id,
                    "userId": user.id,
                    "identityId": identity.id,
                    "externalUserId": payload.externalUserId,
                    "returnUrl": payload.returnUrl,
                    "issuedAt": int(time.time()),
                }
            )
            return {
                "code": code,
                "expiresInSeconds": cls.CODE_TTL_SECONDS,
                "entryUrl": cls._entry_url(db, code),
            }
        except HTTPException as exc:
            cls.record_failure(
                db,
                client_id=client.id,
                external_user_id=payload.externalUserId,
                action="ticket",
                error_message=str(exc.detail),
                ip=ip,
            )
            raise
        except Exception as exc:
            cls.record_failure(
                db,
                client_id=client.id,
                external_user_id=payload.externalUserId,
                action="ticket",
                error_message="单点登录服务暂时不可用",
                ip=ip,
            )
            raise HTTPException(
                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                detail="单点登录服务暂时不可用",
            ) from exc

    @classmethod
    def exchange(cls, db: Session, *, code: str, ip: str) -> dict:
        try:
            ticket = cls._consume_ticket(code)
        except HTTPException as exc:
            cls.record_failure(
                db,
                client_id=None,
                action="exchange",
                error_message=str(exc.detail),
                ip=ip,
            )
            raise
        client_id = _int_value(ticket.get("clientId"))
        user_id = _int_value(ticket.get("userId"))
        identity_id = _int_value(ticket.get("identityId"))
        external_user_id = str(ticket.get("externalUserId") or "")

        try:
            client = db.get(SsoClient, client_id)
            identity = db.get(UserExternalIdentity, identity_id)
            user = db.get(User, user_id)
            if client is None or client.status != 1:
                raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="接入应用已停用")
            if (
                identity is None
                or identity.client_id != client.id
                or identity.user_id != user_id
                or identity.external_user_id != external_user_id
            ):
                raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="外部账号绑定已失效")
            if user is None or user.is_deleted:
                raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
            AuthService.ensure_user_can_login(user)

            now = _db_now(db)
            identity.last_login_at = now
            user.last_login_at = now
            db.add(
                SsoLoginAudit(
                    client_id=client.id,
                    user_id=user.id,
                    external_user_id=external_user_id,
                    action="exchange",
                    status="SUCCESS",
                    ip=ip,
                )
            )
            db.commit()
            db.refresh(user)
            token, expired_at = create_access_token(
                str(user.id),
                "user",
                extra_claims={"auth_source": "sso", "sso_client_id": client.id},
            )
            return {
                "token": token,
                "expiredAt": expired_at,
                "user": user,
                "returnUrl": ticket.get("returnUrl"),
            }
        except HTTPException as exc:
            cls.record_failure(
                db,
                client_id=client_id or None,
                user_id=user_id or None,
                external_user_id=external_user_id,
                action="exchange",
                error_message=str(exc.detail),
                ip=ip,
            )
            raise

    @classmethod
    def record_failure(
        cls,
        db: Session,
        *,
        client_id: int | None,
        action: str,
        error_message: str,
        ip: str,
        user_id: int | None = None,
        external_user_id: str | None = None,
    ) -> None:
        db.rollback()
        db.add(
            SsoLoginAudit(
                client_id=client_id,
                user_id=user_id,
                external_user_id=external_user_id,
                action=action,
                status="FAILED",
                error_message=error_message[:500],
                ip=ip,
            )
        )
        db.commit()

    @classmethod
    def _verify_request(
        cls,
        client: SsoClient,
        timestamp: str,
        nonce: str,
        signature: str,
        raw_body: bytes,
    ) -> None:
        try:
            request_time = int(timestamp)
        except (TypeError, ValueError) as exc:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="应用时间戳无效") from exc
        if abs(int(time.time()) - request_time) > cls.SIGNATURE_WINDOW_SECONDS:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="应用请求已过期")
        if not 16 <= len(nonce) <= 120:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="应用随机数无效")

        body_hash = hashlib.sha256(raw_body).hexdigest()
        canonical = f"{timestamp}\n{nonce}\n{body_hash}".encode("utf-8")
        secret = SecretService.decrypt(client.client_secret).encode("utf-8")
        expected = hmac.new(secret, canonical, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(expected, signature.lower()):
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="应用签名无效")

    @classmethod
    def _reserve_nonce(cls, client: SsoClient, nonce: str) -> None:
        redis = cls._required_redis()
        key = f"{cls.NONCE_NAMESPACE}:{client.id}:{hashlib.sha256(nonce.encode()).hexdigest()}"
        try:
            reserved = redis.set(key, "1", ex=cls.SIGNATURE_WINDOW_SECONDS, nx=True)
        except Exception as exc:
            raise HTTPException(
                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                detail="单点登录服务暂时不可用",
            ) from exc
        if not reserved:
            raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="应用请求已被使用")

    @classmethod
    def _resolve_identity(
        cls,
        db: Session,
        client: SsoClient,
        payload: SsoTicketRequest,
    ) -> tuple[UserExternalIdentity, User]:
        identity = db.scalar(
            select(UserExternalIdentity).where(
                UserExternalIdentity.client_id == client.id,
                UserExternalIdentity.external_user_id == payload.externalUserId,
            )
        )
        if identity is not None:
            user = db.get(User, identity.user_id)
            if user is None or user.is_deleted:
                raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="绑定的学员账号不存在")
            return identity, user

        if not payload.verifiedPhone:
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="首次登录必须提供已验证手机号")
        phone = payload.verifiedPhone.strip()
        user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0).with_for_update())
        created = False
        if user is None:
            if not client.allow_auto_register:
                raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中")
            display_name = (payload.displayName or "").strip()
            if not display_name:
                raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="首次自动注册必须提供姓名")
            plan = db.get(EntitlementPlan, client.default_entitlement_plan_id)
            if plan is None or plan.status != 1 or plan.plan_type == "teacher":
                raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="接入应用未配置可用的默认权益")
            candidate = User(
                phone=phone,
                name=display_name[:50],
                nickname=display_name[:50],
                registration_source="sso",
                registration_client_id=client.id,
                status=1,
                daily_chat_limit=_default_daily_limit(db),
                daily_chat_used=0,
            )
            try:
                with db.begin_nested():
                    db.add(candidate)
                    db.flush()
                user = candidate
                created = True
            except IntegrityError:
                user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0))
                if user is None:
                    raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="学员账号创建冲突，请重试")

            if created:
                EntitlementService.assign_user_plan(
                    db,
                    user=user,
                    plan_id=plan.id,
                    operated_by=None,
                    remark=f"接入应用 {client.name} 自动注册分配",
                )
        existing_user_binding = db.scalar(
            select(UserExternalIdentity).where(
                UserExternalIdentity.client_id == client.id,
                UserExternalIdentity.user_id == user.id,
            )
        )
        if existing_user_binding is not None:
            raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该学员已绑定其他外部账号")

        identity = UserExternalIdentity(
            client_id=client.id,
            user_id=user.id,
            external_user_id=payload.externalUserId,
            phone_snapshot=payload.verifiedPhone,
            display_name_snapshot=payload.displayName,
        )
        db.add(identity)
        db.flush()
        return identity, user

    @staticmethod
    def _validate_return_url(client: SsoClient, return_url: str | None) -> None:
        if not return_url:
            return
        allowed = _json_list(client.redirect_uris)
        if return_url.rstrip("/") not in {item.rstrip("/") for item in allowed}:
            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回跳地址不在应用白名单中")

    @classmethod
    def _store_ticket(cls, payload: dict) -> str:
        redis = cls._required_redis()
        for _ in range(3):
            code = secrets.token_urlsafe(32)
            key = cls._code_key(code)
            try:
                stored = redis.set(
                    key,
                    json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
                    ex=cls.CODE_TTL_SECONDS,
                    nx=True,
                )
            except Exception as exc:
                raise HTTPException(
                    status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                    detail="单点登录服务暂时不可用",
                ) from exc
            if stored:
                return code
        raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="授权码生成失败")

    @classmethod
    def _consume_ticket(cls, code: str) -> dict:
        redis = cls._required_redis()
        key = cls._code_key(code)
        try:
            raw = redis.eval(
                "local v=redis.call('GET',KEYS[1]); if v then redis.call('DEL',KEYS[1]) end; return v",
                1,
                key,
            )
        except Exception as exc:
            raise HTTPException(
                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                detail="单点登录服务暂时不可用",
            ) from exc
        if not raw:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="授权码无效、已使用或已过期")
        try:
            payload = json.loads(raw)
        except (TypeError, json.JSONDecodeError) as exc:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="授权码数据无效") from exc
        return payload

    @staticmethod
    def _required_redis():
        redis = get_sync_redis_client()
        if redis is None:
            raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="单点登录服务暂时不可用")
        return redis

    @classmethod
    def _code_key(cls, code: str) -> str:
        return f"{cls.CODE_NAMESPACE}:{hashlib.sha256(code.encode()).hexdigest()}"

    @staticmethod
    def _entry_url(db: Session, code: str) -> str:
        value = db.scalar(select(SystemConfig.config_value).where(SystemConfig.config_key == "sso_user_client_url"))
        base = (value or "").strip().rstrip("/")
        path = f"/?sso_code={quote(code)}"
        return f"{base}{path}" if base else path


def _json_list(value: str | None) -> list[str]:
    try:
        decoded = json.loads(value or "[]")
    except json.JSONDecodeError:
        return []
    return [str(item) for item in decoded if str(item).strip()] if isinstance(decoded, list) else []


def _db_now(db: Session) -> datetime:
    return db.scalar(select(func.now())) or datetime.now()


def _int_value(value) -> int:
    try:
        return int(value)
    except (TypeError, ValueError):
        return 0


def _default_daily_limit(db: Session) -> int:
    value = db.scalar(select(SystemConfig.config_value).where(SystemConfig.config_key == "daily_chat_limit"))
    if value and value.strip():
        try:
            return max(0, min(100000, int(float(value.strip()))))
        except ValueError:
            pass
    try:
        from app.core.config import get_settings

        return get_settings().default_daily_chat_limit
    except Exception:
        return 100
