"""
User Profile Service Layer — all business logic for the user_profile module.
No direct database queries in router — always route through these functions.
"""
from __future__ import annotations

import hashlib
import json
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional

from passlib.context import CryptContext
from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session

from src.apps.auth.models.auth_session import AuthSession
from src.apps.user_profile.helpers.avatar import generate_avatar_signed_url
from src.apps.user_profile.helpers.password import (
    check_password_history,
    validate_password_complexity,
)
from src.apps.user_profile.models.email_change_request import EmailChangeRequest
from src.apps.user_profile.models.notification_prefs import UserNotificationPreferences
from src.apps.user_profile.models.password_history import UserPasswordHistory
from src.apps.user_profile.models.password_policy import PasswordPolicy
from src.apps.users.models.user import User
from src.core.exceptions import APIException, ConflictError, NotFoundError, UnauthorizedError

logger = logging.getLogger(__name__)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


# ─── Profile ──────────────────────────────────────────────────────────────────

def get_user_profile(db: Session, user: User) -> dict:
    """Get user profile, enriching avatar_url with a presigned/accessible URL."""
    avatar_url: Optional[str] = None
    # Use stored avatar_url directly if no file FK (backward compat with legacy text column)
    if hasattr(user, "avatar_file_id") and user.avatar_file_id:
        try:
            from src.apps.files.models.file import File
            file_record = db.get(File, user.avatar_file_id)
            avatar_url = generate_avatar_signed_url(file_record, db)
        except Exception as e:
            logger.warning(f"Could not load avatar file for user {user.id}: {e}")
    elif getattr(user, "avatar_url", None):
        avatar_url = user.avatar_url

    return {"user": user, "avatar_url": avatar_url}


def update_user_profile(db: Session, user: User, data: dict) -> User:
    """Update mutable profile fields. Recomputes full_name from name parts."""
    allowed_fields = {
        "first_name", "last_name", "middle_name", "bio", "phone_number",
        "address_line_1", "address_line_2", "city", "state", "zip_code", "country",
    }
    for field, value in data.items():
        if field in allowed_fields:
            # Allow null to clear a field; skip only if the key is absent (exclude_none=True)
            setattr(user, field, value)

    # Recompute full_name
    parts = [
        getattr(user, "first_name", None),
        getattr(user, "middle_name", None),
        getattr(user, "last_name", None),
    ]
    user.full_name = " ".join(p for p in parts if p)
    db.flush()
    return user


# ─── Avatar ───────────────────────────────────────────────────────────────────

async def upload_user_avatar(db: Session, user: User, file_upload, merchant) -> str:
    """Upload avatar via files module. Returns presigned/accessible URL."""
    from src.apps.files.file_services import upload_single_file

    result = await upload_single_file(
        db=db,
        file=file_upload,
        file_type="profile_avatar",
        created_by=user,
        merchant=merchant,
    )
    if hasattr(user, "avatar_file_id"):
        user.avatar_file_id = result.id
    db.flush()

    from src.apps.files.models.file import File
    file_record = db.get(File, result.id)
    url = generate_avatar_signed_url(file_record, db)
    return url or ""


def remove_user_avatar(db: Session, user: User) -> None:
    """Disassociate avatar from user. Does not delete the underlying File record."""
    if hasattr(user, "avatar_file_id"):
        user.avatar_file_id = None
    user.avatar_url = None
    db.flush()


def refresh_avatar_url(db: Session, user: User) -> Optional[str]:
    """Get a fresh presigned URL for the current avatar."""
    if hasattr(user, "avatar_file_id") and user.avatar_file_id:
        from src.apps.files.models.file import File
        file_record = db.get(File, user.avatar_file_id)
        return generate_avatar_signed_url(file_record, db)
    if getattr(user, "avatar_url", None):
        return user.avatar_url
    return None


# ─── Password ─────────────────────────────────────────────────────────────────

def get_effective_password_policy(
    db: Session, merchant_id: int, redis_client=None
) -> PasswordPolicy:
    """
    Returns effective PasswordPolicy for a merchant.
    Check order: Redis cache → merchant-specific DB row → global (merchant_id IS NULL).
    Falls back to an in-memory default if no DB rows exist.
    """
    cache_key = f"password_policy:{merchant_id}"

    if redis_client:
        try:
            cached = redis_client.get(cache_key)
            if cached:
                data = json.loads(cached)
                policy = PasswordPolicy(**data)
                return policy
        except Exception as e:
            logger.debug(f"Redis cache miss for password_policy: {e}")

    stmt = (
        select(PasswordPolicy)
        .where(
            ((PasswordPolicy.merchant_id == merchant_id) | (PasswordPolicy.merchant_id.is_(None))),
            PasswordPolicy.is_active == True,
            PasswordPolicy.deleted_at.is_(None),
        )
        .order_by(PasswordPolicy.merchant_id.nullslast())
        .limit(1)
    )
    policy = db.execute(stmt).scalar_one_or_none()

    if policy is None:
        # In-memory fallback — safe defaults
        policy = PasswordPolicy(
            min_length=8,
            require_uppercase=True,
            require_lowercase=True,
            require_digit=True,
            require_special=False,
            max_age_days=None,
            grace_period_days=7,
            history_count=5,
        )

    if redis_client and getattr(policy, "id", None):
        try:
            redis_client.setex(
                cache_key,
                3600,
                json.dumps({
                    "id": policy.id,
                    "merchant_id": policy.merchant_id,
                    "min_length": policy.min_length,
                    "require_uppercase": policy.require_uppercase,
                    "require_lowercase": policy.require_lowercase,
                    "require_digit": policy.require_digit,
                    "require_special": policy.require_special,
                    "max_age_days": policy.max_age_days,
                    "grace_period_days": policy.grace_period_days,
                    "history_count": policy.history_count,
                    "is_active": policy.is_active,
                }),
            )
        except Exception as e:
            logger.debug(f"Failed to cache password policy: {e}")

    return policy


def change_password(
    db: Session,
    user: User,
    current_password: str,
    new_password: str,
    current_session_token: str,
    merchant_id: int,
    redis_client=None,
) -> dict:
    """
    Full password change flow:
    1. Verify current password
    2. Load policy + validate complexity
    3. Check password history
    4. Hash and update user record
    5. Record hash in history (prune excess)
    6. Revoke all other active sessions

    SECURITY (MEDIUM): Password changes are not audit-logged to a persistent security
    event table. PCI DSS Requirement 10.2.1 requires logging all individual user access
    to cardholder data and all actions taken by any individual with root/administrative
    privileges. Password changes are a high-value security event. Add structured audit
    log entries (user_id, event_type="password_changed", ip_address, timestamp) to a
    security_audit_log table or SIEM pipeline for all of: password change, email change,
    2FA toggle, session revocation, and avatar upload.
    """
    # 1. Verify current password
    if not pwd_context.verify(current_password, user.hashed_password):
        raise UnauthorizedError("Current password is incorrect.")

    # 2. Load policy + validate complexity
    policy = get_effective_password_policy(db, merchant_id, redis_client)
    violations = validate_password_complexity(new_password, policy)
    if violations:
        raise APIException(
            status_code=400,
            message="Password does not meet policy requirements.",
            error={"error_code": "PASSWORD_POLICY_VIOLATION", "violations": violations},
        )

    # 3. Check history
    if policy.history_count > 0:
        if check_password_history(db, user.id, new_password, policy.history_count):
            raise APIException(
                status_code=400,
                message="Password was used recently. Please choose a different password.",
                error={"error_code": "PASSWORD_HISTORY_VIOLATION"},
            )

    # 4. Hash + update user
    new_hash = pwd_context.hash(new_password)
    user.hashed_password = new_hash
    user.last_password_changed_at = datetime.now(timezone.utc)

    # 5. Record in history
    history_entry = UserPasswordHistory(user_id=user.id, hashed_password=new_hash)
    db.add(history_entry)
    db.flush()

    # Prune excess history rows beyond policy.history_count
    if policy.history_count > 0:
        subq = (
            select(UserPasswordHistory.id)
            .where(UserPasswordHistory.user_id == user.id)
            .order_by(UserPasswordHistory.created_at.desc())
            .limit(policy.history_count)
            .scalar_subquery()
        )
        db.execute(
            delete(UserPasswordHistory)
            .where(UserPasswordHistory.user_id == user.id)
            .where(UserPasswordHistory.id.not_in(subq))
        )

    # 6. Revoke other sessions — set both is_revoked and is_active to be consistent
    # with AuthSession.revoke() and the auth validator which checks both flags.
    stmt = (
        update(AuthSession)
        .where(
            AuthSession.user_id == user.id,
            AuthSession.access_token != current_session_token,
            AuthSession.is_revoked == False,
        )
        .values(is_revoked=True, is_active=False, revoked_at=datetime.now(timezone.utc))
    )
    result = db.execute(stmt)
    db.flush()

    return {"message": "Password changed successfully.", "sessions_revoked": result.rowcount}


# ─── Sessions ─────────────────────────────────────────────────────────────────

def get_session_history(
    db: Session,
    user_id: int,
    current_token: str,
    page: int = 1,
    per_page: int = 20,
) -> dict:
    """
    Returns paginated session history for user (last 90 days).
    Marks current session with is_current=True.
    """
    from sqlalchemy import func as sqlfunc

    cutoff = datetime.now(timezone.utc) - timedelta(days=90)
    base_q = (
        select(AuthSession)
        .where(AuthSession.user_id == user_id, AuthSession.created_at >= cutoff)
        .order_by(AuthSession.created_at.desc())
    )
    total = db.execute(
        select(sqlfunc.count()).select_from(base_q.subquery())
    ).scalar() or 0

    sessions = (
        db.execute(base_q.offset((page - 1) * per_page).limit(per_page))
        .scalars()
        .all()
    )

    items = []
    for s in sessions:
        items.append({
            "id": s.id,
            "ip_address": s.ip_address,
            "geo": {
                "country": getattr(s, "geo_country", None),
                "region": getattr(s, "geo_region", None),
                "city": getattr(s, "geo_city", None),
                "latitude": getattr(s, "geo_latitude", None),
                "longitude": getattr(s, "geo_longitude", None),
            },
            "device": {
                "browser": getattr(s, "device_browser", None),
                "browser_version": getattr(s, "device_browser_version", None),
                "os": getattr(s, "device_os", None),
                "os_version": getattr(s, "device_os_version", None),
                "device_type": getattr(s, "device_type", None),
                "brand": getattr(s, "device_brand", None),
            },
            "is_active": s.is_active,
            "is_revoked": s.is_revoked,
            # SECURITY (MEDIUM): access_token is stored in plaintext in the DB and compared
            # here by equality. If the auth_sessions table is compromised, all active JWT
            # tokens are exposed. Recommended fix: store HMAC-SHA256(token) or SHA-256(token)
            # hash in the DB and compare hashes. The raw token is only needed client-side.
            "is_current": s.access_token == current_token,
            "created_at": s.created_at,
            "last_activity_at": s.last_activity_at,
        })

    return {
        "items": items,
        "total": total,
        "page": page,
        "per_page": per_page,
        "pages": max(1, (total + per_page - 1) // per_page),
    }


def revoke_session(
    db: Session, user_id: int, session_id: int, current_token: str
) -> None:
    """Revoke a specific session with IDOR guard (session must belong to user)."""
    stmt = select(AuthSession).where(
        AuthSession.id == session_id, AuthSession.user_id == user_id
    )
    session = db.execute(stmt).scalar_one_or_none()
    if not session:
        raise NotFoundError("Session not found.")
    if session.access_token == current_token:
        raise APIException(
            status_code=400,
            message="Cannot revoke your current session. Use logout instead.",
            error={"error_code": "CANNOT_REVOKE_CURRENT_SESSION"},
        )
    session.is_revoked = True
    session.is_active = False
    session.revoked_at = datetime.now(timezone.utc)
    db.flush()


# ─── 2FA ──────────────────────────────────────────────────────────────────────

def toggle_twofa(db: Session, user: User, enabled: bool) -> User:
    """Enable or disable 2FA for the user.

    SECURITY (MEDIUM): Disabling 2FA is a security-sensitive operation. Currently it
    requires only a valid JWT (get_current_active_user) with no additional verification
    step (e.g., current password confirmation or OTP). An attacker with a stolen session
    token could disable 2FA silently. Recommended: require current_password confirmation
    before allowing 2FA to be disabled. Enabling 2FA is lower risk but should also
    trigger an audit log event.
    """
    user.twofa_enabled = enabled
    db.flush()
    return user


# ─── Notification Preferences ─────────────────────────────────────────────────

def get_notification_preferences(
    db: Session, user_id: int
) -> UserNotificationPreferences:
    """Returns preferences for user. Returns defaults if no row exists (no DB write)."""
    stmt = select(UserNotificationPreferences).where(
        UserNotificationPreferences.user_id == user_id
    )
    prefs = db.execute(stmt).scalar_one_or_none()
    if not prefs:
        # Return in-memory defaults without persisting
        return UserNotificationPreferences(
            user_id=user_id,
            notify_on_new_login=True,
            notify_on_password_change=True,
            notify_on_new_device=True,
        )
    return prefs


def update_notification_preferences(
    db: Session, user_id: int, data: dict
) -> UserNotificationPreferences:
    """Upsert notification preferences row."""
    stmt = select(UserNotificationPreferences).where(
        UserNotificationPreferences.user_id == user_id
    )
    prefs = db.execute(stmt).scalar_one_or_none()
    if not prefs:
        prefs = UserNotificationPreferences(user_id=user_id)
        db.add(prefs)

    for key, val in data.items():
        if val is not None and hasattr(prefs, key):
            setattr(prefs, key, val)

    prefs.updated_at = datetime.now(timezone.utc)
    db.flush()
    return prefs


# ─── Email Change ─────────────────────────────────────────────────────────────

def initiate_email_change(db: Session, user: User, new_email: str) -> None:
    """
    1. Ensure new_email is not already taken.
    2. Cancel any pending requests for this user.
    3. Generate a token, store its SHA-256 hash.
    4. Queue Celery task to send confirmation email (non-blocking).
    """
    # Check uniqueness
    # SECURITY (LOW): The 409 ConflictError when an email is already taken leaks whether
    # a given email exists in the system. Since this endpoint requires authentication
    # (an authenticated user is making the request), this is an accepted low-risk design
    # choice — the user must already hold a valid session to probe emails. However, if
    # admin impersonation or shared-session scenarios are introduced, reconsider using
    # a uniform "verification email sent" response regardless of uniqueness, and enforce
    # uniqueness only at the confirmation step.
    existing = db.execute(
        select(User).where(
            User.email == new_email,
            User.deleted_at.is_(None),
            User.id != user.id,
        )
    ).scalar_one_or_none()
    if existing:
        raise ConflictError("Email address is already in use.")

    # Cancel existing pending requests for this user
    db.execute(
        delete(EmailChangeRequest).where(
            EmailChangeRequest.user_id == user.id,
            EmailChangeRequest.confirmed_at.is_(None),
        )
    )

    # Generate token
    raw_token = secrets.token_urlsafe(32)
    token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
    expires_at = datetime.now(timezone.utc) + timedelta(hours=24)

    change_request = EmailChangeRequest(
        user_id=user.id,
        new_email=new_email,
        token_hash=token_hash,
        expires_at=expires_at,
    )
    db.add(change_request)
    db.flush()

    # Queue email confirmation task (fire-and-forget)
    try:
        from src.worker.tasks import send_email_change_confirmation
        send_email_change_confirmation.delay(
            user_id=user.id,
            new_email=new_email,
            raw_token=raw_token,
            user_name=user.full_name or user.email,
        )
    except Exception as e:
        logger.error(
            f"Failed to queue email change confirmation task for user {user.id}: {e}"
        )
        # Don't raise — token is stored and user can retry sending


def confirm_email_change(db: Session, user: User, raw_token: str) -> User:
    """
    Validates token hash, confirms the request, updates user email,
    and revokes all active sessions (user must re-login with new email).
    """
    token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
    stmt = select(EmailChangeRequest).where(
        EmailChangeRequest.token_hash == token_hash,
        EmailChangeRequest.user_id == user.id,
        EmailChangeRequest.confirmed_at.is_(None),
        EmailChangeRequest.expires_at > datetime.now(timezone.utc),
    )
    change_request = db.execute(stmt).scalar_one_or_none()
    if not change_request:
        raise APIException(
            status_code=400,
            message="Invalid or expired email change token.",
            error={"error_code": "INVALID_TOKEN"},
        )

    # Update user email and mark request confirmed
    user.email = change_request.new_email
    change_request.confirmed_at = datetime.now(timezone.utc)

    # Revoke ALL sessions — user must re-login with new email.
    # Set both is_revoked and is_active for consistency with AuthSession.revoke()
    # and the auth validator which checks both flags.
    db.execute(
        update(AuthSession)
        .where(AuthSession.user_id == user.id, AuthSession.is_revoked == False)
        .values(is_revoked=True, is_active=False, revoked_at=datetime.now(timezone.utc))
    )
    db.flush()
    return user
