"""
Auth service — business logic for Phase 1.3.1 endpoints (signup, get_me, logout).

All operations go through the singleton admin Supabase client (SECRET_KEY, bypasses RLS).
HTTP concerns (status codes, request parsing) are NOT handled here — they live in routes/auth.py.

Custom exceptions raised by this module:
- EmailAlreadyExists: signup rejected because email is already taken
- ProfileNotFound: get_me called but no profile row exists for this user_id
- SupabaseAuthError: generic wrapper for unexpected Supabase failures
"""
import logging
from dataclasses import dataclass
from typing import Optional

from app.core.supabase_client import supabase
from app.core.supabase_anon_client import supabase_anon
from app.core.config import DEEPLINK_EMAIL_CONFIRM, DEEPLINK_RESET_PASSWORD

logger = logging.getLogger(__name__)


# ----------------------------------------------------------------------------
# Custom exceptions (mapped to HTTP codes by routes/auth.py)
# ----------------------------------------------------------------------------

class EmailAlreadyExists(Exception):
    """Raised when signup is attempted with an email that already exists in auth.users."""
    pass


class ProfileNotFound(Exception):
    """Raised when get_me cannot find a profile row for the given user_id."""
    pass


class SupabaseAuthError(Exception):
    """Wrapper for unexpected Supabase auth failures (network, internal, etc.)."""
    pass


class InvalidCredentials(Exception):
    """Raised when login is attempted with wrong email/password (or non-existent email)."""
    pass


class EmailNotConfirmed(Exception):
    """Raised when login is attempted with valid credentials but email is not yet confirmed."""
    pass


class InvalidRefreshToken(Exception):
    """Raised when refresh is attempted with an expired/invalid/revoked refresh_token."""
    pass


class InvalidConfirmationToken(Exception):
    """Raised when email confirmation is attempted with an expired/invalid/already-used token_hash."""
    pass


class ConfirmationError(Exception):
    """Raised for unexpected errors during email confirmation."""
    pass


class InvalidResetToken(Exception):
    """Raised when password reset is attempted with an expired/invalid recovery token_hash."""
    pass


class ResetPasswordError(Exception):
    """Raised for unexpected errors during password reset."""
    pass


class InvalidGoogleToken(Exception):
    """Raised when Google OAuth is attempted with a malformed/expired ID token (user-side error)."""
    pass


class GoogleOAuthError(Exception):
    """Raised when Supabase OAuth config is broken (provider not enabled, missing client_id, etc.)."""
    pass


class InvalidCurrentPassword(Exception):
    """Raised when change-password is attempted with an incorrect current password."""
    pass


# ----------------------------------------------------------------------------
# Result dataclasses (returned to routes/auth.py)
# ----------------------------------------------------------------------------

@dataclass
class SignupResult:
    user_id: str
    email: str
    email_sent: bool


@dataclass
class MeResult:
    user_id: str
    email: str
    role: str
    firstname: str
    lastname: str
    phone: Optional[str]
    profession: Optional[str]
    city: Optional[str]


@dataclass
class LoginResult:
    access_token: str
    refresh_token: str
    expires_in: int
    token_type: str
    user: MeResult


@dataclass
class RefreshResult:
    access_token: str
    refresh_token: str
    expires_in: int
    token_type: str


@dataclass
class ConfirmResult:
    access_token: str
    refresh_token: str
    expires_in: int
    token_type: str
    user: MeResult


@dataclass
class ForgotPasswordResult:
    email_sent: bool
    code: str | None = None  # only "GOOGLE_USER" when applicable


@dataclass
class ResetPasswordResult:
    access_token: str
    refresh_token: str
    expires_in: int
    token_type: str
    user: MeResult


@dataclass
class ChangePasswordResult:
    success: bool


@dataclass
class GoogleSignInResult:
    access_token: str
    refresh_token: str
    expires_in: int
    token_type: str
    user: MeResult


# ----------------------------------------------------------------------------
# signup
# ----------------------------------------------------------------------------

def signup(
    email: str,
    password: str,
    firstname: str,
    lastname: str,
    phone: str,
    profession: str,
    city: str,
) -> SignupResult:
    """
    Classic email/password signup.

    Flow:
    1. Create user in auth.users via auth.sign_up()
       -> Supabase sends confirmation email automatically
    2. Insert row in public.profiles with all 7 fields
    3. If profile insert fails, rollback by deleting the auth.users row

    Raises:
        EmailAlreadyExists if Supabase rejects for duplicate email
        SupabaseAuthError on unexpected failures
    """
    # 1. Create user in auth.users
    try:
        auth_response = supabase.auth.sign_up({
            "email": email,
            "password": password,
            "options": {
                "data": {"firstname": firstname, "lastname": lastname},
                "email_redirect_to": DEEPLINK_EMAIL_CONFIRM,
            },
        })
    except Exception as e:
        error_msg = str(e).lower()
        if "already" in error_msg and ("registered" in error_msg or "exists" in error_msg):
            raise EmailAlreadyExists() from e
        logger.error("Unexpected error during create_user: %s", e)
        raise SupabaseAuthError(f"Failed to create user: {e}") from e

    user_id = auth_response.user.id

    # 2. Insert profile row (transactional best-effort)
    try:
        supabase.table("profiles").insert({
            "id": user_id,
            "firstname": firstname,
            "lastname": lastname,
            "phone": phone,
            "profession": profession,
            "city": city,
            # role defaults to 'candidate' via DB default
        }).execute()
    except Exception as e:
        # Rollback: delete the orphan auth.users row
        logger.error("Profile insert failed for user_id=%s, rolling back: %s", user_id, e)
        try:
            supabase.auth.admin.delete_user(user_id)
        except Exception as rollback_err:
            logger.error("Rollback delete_user FAILED for user_id=%s: %s", user_id, rollback_err)
        raise SupabaseAuthError(f"Failed to create profile: {e}") from e

    return SignupResult(
        user_id=user_id,
        email=email,
        email_sent=True,
    )


# ----------------------------------------------------------------------------
# get_me
# ----------------------------------------------------------------------------

def get_me(user_id: str) -> MeResult:
    """
    Fetch current user info: email from auth.users + all fields from profiles.

    Raises:
        ProfileNotFound if no profile row exists for this user_id (data inconsistency)
        SupabaseAuthError if user not found in auth.users
    """
    # 1. Fetch from auth.users (for email)
    try:
        auth_response = supabase.auth.admin.get_user_by_id(user_id)
    except Exception as e:
        logger.error("Failed to fetch auth.users for user_id=%s: %s", user_id, e)
        raise SupabaseAuthError(f"User not found in auth: {e}") from e

    user_email = auth_response.user.email

    # 2. Fetch from public.profiles
    profile_response = (
        supabase.table("profiles")
        .select("firstname, lastname, phone, profession, city, role")
        .eq("id", user_id)
        .maybe_single()
        .execute()
    )

    # supabase-py quirk: maybe_single() returns None (not a response object)
    # when zero rows match. Normalize before checking .data.
    profile_data = profile_response.data if profile_response is not None else None

    if profile_data is None:
        raise ProfileNotFound(f"No profile found for user_id={user_id}")

    p = profile_data
    return MeResult(
        user_id=user_id,
        email=user_email,
        role=p["role"],
        firstname=p["firstname"],
        lastname=p["lastname"],
        phone=p.get("phone"),
        profession=p.get("profession"),
        city=p.get("city"),
    )


# ----------------------------------------------------------------------------
# logout
# ----------------------------------------------------------------------------

def logout(access_token: str) -> None:
    """
    Revoke the refresh_token associated with this access JWT.
    The access_token itself remains technically valid until its natural expiration (1h max),
    a known JWT limitation.

    Raises:
        SupabaseAuthError on unexpected failures (best-effort: logout should rarely fail visibly)
    """
    try:
        supabase.auth.admin.sign_out(access_token)
    except Exception as e:
        logger.error("Failed to sign out: %s", e)
        raise SupabaseAuthError(f"Logout failed: {e}") from e


# ----------------------------------------------------------------------------
# login
# ----------------------------------------------------------------------------

def login(email: str, password: str) -> LoginResult:
    """
    Authenticate user with email/password.
    Returns tokens + user data in a single payload.

    Raises:
        InvalidCredentials: wrong email/password (or non-existent email — Supabase anti-enumeration)
        EmailNotConfirmed: valid credentials but email_confirmed_at IS NULL
        SupabaseAuthError: unexpected failures
    """
    # 1. Sign in with Supabase
    try:
        auth_response = supabase_anon.auth.sign_in_with_password({
            "email": email,
            "password": password,
        })
    except Exception as e:
        error_msg = str(e).lower()
        if "email not confirmed" in error_msg:
            raise EmailNotConfirmed() from e
        if "invalid login credentials" in error_msg or "invalid credentials" in error_msg:
            raise InvalidCredentials() from e
        logger.error("Unexpected error during sign_in_with_password: %s", e)
        raise SupabaseAuthError(f"Login failed: {e}") from e

    session = auth_response.session
    user_id = auth_response.user.id
    user_email = auth_response.user.email

    # 2. Fetch profile directly (avoid get_me which uses admin ops that fail after sign_in)
    profile_response = (
        supabase.table("profiles")
        .select("firstname, lastname, phone, profession, city, role")
        .eq("id", user_id)
        .maybe_single()
        .execute()
    )
    # supabase-py quirk: maybe_single() returns None (not a response object)
    # when zero rows match. Normalize before checking .data.
    profile_data = profile_response.data if profile_response is not None else None

    if profile_data is None:
        raise ProfileNotFound(f"No profile found for user_id={user_id}")
    p = profile_data

    me_result = MeResult(
        user_id=user_id,
        email=user_email,
        role=p["role"],
        firstname=p["firstname"],
        lastname=p["lastname"],
        phone=p.get("phone"),
        profession=p.get("profession"),
        city=p.get("city"),
    )

    return LoginResult(
        access_token=session.access_token,
        refresh_token=session.refresh_token,
        expires_in=session.expires_in,
        token_type=session.token_type,
        user=me_result,
    )


# ----------------------------------------------------------------------------
# refresh
# ----------------------------------------------------------------------------

def refresh(refresh_token: str) -> RefreshResult:
    """
    Exchange a refreshToken for a new session (new access + new refresh tokens).
    Supabase rotates the refreshToken on every call: the old one is invalidated.

    Raises:
        InvalidRefreshToken: refresh_token expired, invalid, or already used
        SupabaseAuthError: unexpected failures
    """
    try:
        auth_response = supabase_anon.auth.refresh_session(refresh_token)
    except Exception as e:
        error_msg = str(e).lower()
        if "refresh" in error_msg or "invalid" in error_msg or "expired" in error_msg:
            raise InvalidRefreshToken() from e
        logger.error("Unexpected error during refresh_session: %s", e)
        raise SupabaseAuthError(f"Refresh failed: {e}") from e

    session = auth_response.session
    if session is None:
        raise InvalidRefreshToken("Supabase returned no session")

    return RefreshResult(
        access_token=session.access_token,
        refresh_token=session.refresh_token,
        expires_in=session.expires_in,
        token_type=session.token_type,
    )


# ----------------------------------------------------------------------------
# confirm_signup
# ----------------------------------------------------------------------------

def confirm_signup(token_hash: str, type: str = "signup") -> ConfirmResult:
    """
    Verify email confirmation token_hash. On success, Supabase:
    - sets auth.users.email_confirmed_at = NOW()
    - emits access + refresh tokens (auto-login)

    Uses supabase_anon (same isolation pattern as login/refresh: verify_otp mutates client state).
    Profile fetch after verify uses supabase (admin client) directly.

    Raises:
        InvalidConfirmationToken: token expired, invalid, or already used
        ProfileNotFound: profile row missing (data inconsistency)
        ConfirmationError: unexpected Supabase failure
    """
    # 1. Verify OTP via anon client
    try:
        auth_response = supabase_anon.auth.verify_otp({
            "token_hash": token_hash,
            "type": type,
        })
    except Exception as e:
        error_msg = str(e).lower()
        if "expired" in error_msg or "invalid" in error_msg or "otp" in error_msg or "token" in error_msg:
            raise InvalidConfirmationToken() from e
        logger.error("Unexpected error during verify_otp: %s", e)
        raise ConfirmationError(f"Confirmation failed: {e}") from e

    session = auth_response.session
    if session is None:
        raise InvalidConfirmationToken("Supabase returned no session after verify_otp")

    user_id = auth_response.user.id
    user_email = auth_response.user.email

    # 2. Fetch profile via admin client (untouched by verify_otp)
    profile_response = (
        supabase.table("profiles")
        .select("firstname, lastname, phone, profession, city, role")
        .eq("id", user_id)
        .maybe_single()
        .execute()
    )
    # supabase-py quirk: maybe_single() returns None (not a response object)
    # when zero rows match. Normalize before checking .data.
    profile_data = profile_response.data if profile_response is not None else None

    if profile_data is None:
        raise ProfileNotFound(f"No profile found for user_id={user_id}")

    p = profile_data
    me_result = MeResult(
        user_id=user_id,
        email=user_email,
        role=p["role"],
        firstname=p["firstname"],
        lastname=p["lastname"],
        phone=p.get("phone"),
        profession=p.get("profession"),
        city=p.get("city"),
    )

    return ConfirmResult(
        access_token=session.access_token,
        refresh_token=session.refresh_token,
        expires_in=session.expires_in,
        token_type=session.token_type,
        user=me_result,
    )


# ----------------------------------------------------------------------------
# resend_confirmation
# ----------------------------------------------------------------------------

def resend_confirmation(email: str) -> bool:
    """
    Re-send confirmation email. Anti-enumeration: ALWAYS returns True,
    even if the email doesn't exist or is already confirmed.

    Supabase silently skips invalid cases — we don't leak that info to callers.

    Returns:
        True (always, by design)
    """
    try:
        supabase_anon.auth.resend({
            "type": "signup",
            "email": email,
        })
    except Exception as e:
        # Log internally but don't surface to caller (anti-enumeration)
        logger.warning("resend_confirmation silent failure for %s: %s", email, e)

    return True


# ----------------------------------------------------------------------------
# forgot_password
# ----------------------------------------------------------------------------

def forgot_password(email: str) -> ForgotPasswordResult:
    """
    Initiate a password reset flow.

    Branches based on the provider returned by the RPC get_provider_by_email:
    - 'email' (email/password user)     -> send reset email, return email_sent=True
    - 'google' (Google OAuth user)      -> do nothing, return code="GOOGLE_USER"
    - NULL (email does not exist)       -> do nothing, return email_sent=True (anti-enumeration)

    The 'email' user case uses supabase_anon.auth.reset_password_email() which sends
    the recovery email via Supabase SMTP.

    Returns:
        ForgotPasswordResult with email_sent + optional code field
    """
    # 1. Lookup provider via RPC (works for any role, GRANT EXECUTE applied in Phase 1.1 Block F)
    try:
        rpc_response = supabase.rpc(
            "get_provider_by_email",
            {"p_email": email},
        ).execute()
        provider = rpc_response.data  # None, "email", or "google"
    except Exception as e:
        logger.error("get_provider_by_email RPC failed for %s: %s", email, e)
        # On RPC failure, fail closed (silent success) to avoid leaking info
        return ForgotPasswordResult(email_sent=True)

    # 2. Branch on provider
    if provider == "google":
        # Tell the frontend this is a Google user; no email sent
        return ForgotPasswordResult(email_sent=False, code="GOOGLE_USER")

    if provider is None:
        # Email does not exist; silent success (anti-enumeration)
        return ForgotPasswordResult(email_sent=True)

    # provider == "email": trigger Supabase reset email
    try:
        supabase_anon.auth.reset_password_email(
            email,
            {"redirect_to": DEEPLINK_RESET_PASSWORD},
        )
    except Exception as e:
        logger.error("reset_password_email failed for %s: %s", email, e)
        # Still return email_sent=True to avoid leaking failure details
        return ForgotPasswordResult(email_sent=True)

    return ForgotPasswordResult(email_sent=True)


# ----------------------------------------------------------------------------
# reset_password
# ----------------------------------------------------------------------------

def reset_password(token_hash: str, new_password: str) -> ResetPasswordResult:
    """
    Apply a password reset given a recovery token_hash.

    Flow:
    1. verify_otp(token_hash, type='recovery') -> opens a temporary session
    2. update_user(password=new_password) -> writes the new password to auth.users
    3. Fetch profile via admin client (untouched by verify_otp)
    4. Return tokens + user (auto-login)

    Raises:
        InvalidResetToken: token_hash expired, invalid, or already used
        ProfileNotFound: profile row missing (data inconsistency)
        ResetPasswordError: unexpected failures during verify_otp or update_user
    """
    # 1. Verify recovery token via anon client
    try:
        verify_response = supabase_anon.auth.verify_otp({
            "token_hash": token_hash,
            "type": "recovery",
        })
    except Exception as e:
        error_msg = str(e).lower()
        if "expired" in error_msg or "invalid" in error_msg or "otp" in error_msg or "token" in error_msg:
            raise InvalidResetToken() from e
        logger.error("verify_otp (recovery) failed: %s", e)
        raise ResetPasswordError(f"Token verification failed: {e}") from e

    if verify_response.session is None:
        raise InvalidResetToken("Supabase returned no session after verify_otp")

    # 2. Update password (uses the session opened by verify_otp)
    try:
        update_response = supabase_anon.auth.update_user({"password": new_password})
    except Exception as e:
        logger.error("update_user (password) failed: %s", e)
        raise ResetPasswordError(f"Password update failed: {e}") from e

    user_id = update_response.user.id
    user_email = update_response.user.email
    session = verify_response.session  # session from verify_otp is still valid

    # 3. Fetch profile via admin client (state of supabase admin is untouched)
    profile_response = (
        supabase.table("profiles")
        .select("firstname, lastname, phone, profession, city, role")
        .eq("id", user_id)
        .maybe_single()
        .execute()
    )
    # supabase-py quirk: maybe_single() returns None (not a response object)
    # when zero rows match. Normalize before checking .data.
    profile_data = profile_response.data if profile_response is not None else None

    if profile_data is None:
        raise ProfileNotFound(f"No profile found for user_id={user_id}")

    p = profile_data
    me_result = MeResult(
        user_id=user_id,
        email=user_email,
        role=p["role"],
        firstname=p["firstname"],
        lastname=p["lastname"],
        phone=p.get("phone"),
        profession=p.get("profession"),
        city=p.get("city"),
    )

    return ResetPasswordResult(
        access_token=session.access_token,
        refresh_token=session.refresh_token,
        expires_in=session.expires_in,
        token_type=session.token_type,
        user=me_result,
    )


# ----------------------------------------------------------------------------
# change_password
# ----------------------------------------------------------------------------

def change_password(
    user_id: str,
    user_email: str,
    current_password: str,
    new_password: str,
) -> None:
    """
    Change password for an already-authenticated user.

    Flow:
    1. Re-verify current password via sign_in_with_password (supabase_anon)
       — proves the caller knows the current password before mutating it.
    2. Apply new password via admin.update_user_by_id
       — uses the admin client so no active session is required after step 1.

    Raises:
        InvalidCurrentPassword: current_password is wrong (or re-auth fails for any reason)
        SupabaseAuthError: unexpected failure during the admin update
    """
    # 1. Verify current password
    try:
        supabase_anon.auth.sign_in_with_password({
            "email": user_email,
            "password": current_password,
        })
    except Exception as e:
        # Any error during re-auth (wrong credentials, network, etc.) maps to
        # "wrong current password" from the caller's perspective.
        logger.warning("change_password: re-auth failed for user_id=%s: %s", user_id, e)
        raise InvalidCurrentPassword() from e

    # 2. Apply new password via admin client
    try:
        supabase.auth.admin.update_user_by_id(
            user_id,
            {"password": new_password},
        )
    except Exception as e:
        logger.error("change_password: admin update failed for user_id=%s: %s", user_id, e)
        raise SupabaseAuthError(f"Password change failed: {e}") from e


# ----------------------------------------------------------------------------
# google_signin
# ----------------------------------------------------------------------------

def google_signin(id_token: str) -> GoogleSignInResult:
    """
    Sign in (or sign up on first use) via Google OAuth.

    Delegates Google ID token validation to Supabase. On first sign-in, creates the
    profile row with firstname/lastname from Google claims (phone/profession/city
    remain NULL — the frontend routes to CompleteProfileScreen to fill them).

    Error mapping (Q3 decision):
    - "invalid" / "expired" / "malformed" in error -> InvalidGoogleToken (401)
    - other errors -> GoogleOAuthError (500, likely Supabase config issue)

    Raises:
        InvalidGoogleToken: token is malformed, expired, or not for our project
        GoogleOAuthError: backend config issue (provider not enabled, etc.)
        ProfileNotFound: extremely unlikely race condition; profile insert failed silently
    """
    # 1. Validate token via Supabase + create/find user
    try:
        auth_response = supabase_anon.auth.sign_in_with_id_token({
            "provider": "google",
            "token": id_token,
        })
    except Exception as e:
        error_msg = str(e).lower()
        # User-side errors (token problem)
        if any(kw in error_msg for kw in ("invalid", "expired", "malformed", "audience", "signature")):
            raise InvalidGoogleToken() from e
        # Otherwise treat as backend config issue
        logger.error("sign_in_with_id_token failed (likely config issue): %s", e)
        raise GoogleOAuthError(f"Google OAuth failed: {e}") from e

    if auth_response.session is None or auth_response.user is None:
        raise GoogleOAuthError("Supabase returned no session/user after Google sign-in")

    session = auth_response.session
    user_id = auth_response.user.id
    user_email = auth_response.user.email

    # Extract firstname/lastname from Google claims (stored by Supabase in user_metadata)
    user_metadata = auth_response.user.user_metadata or {}
    firstname = (
        user_metadata.get("given_name")
        or user_metadata.get("firstname")
        or (user_metadata.get("name", "").split(" ")[0] if user_metadata.get("name") else "")
        or "User"
    )
    lastname = (
        user_metadata.get("family_name")
        or user_metadata.get("lastname")
        or (" ".join(user_metadata.get("name", "").split(" ")[1:]) if user_metadata.get("name") else "")
        or ""
    )

    # 2. SELECT profile via admin client
    profile_response = (
        supabase.table("profiles")
        .select("firstname, lastname, phone, profession, city, role")
        .eq("id", user_id)
        .maybe_single()
        .execute()
    )

    # supabase-py quirk: maybe_single() returns None (not a response object)
    # when zero rows match. Normalize so we can keep checking .data.
    profile_data = profile_response.data if profile_response is not None else None

    # 3. First-time Google sign-in: create profile
    if profile_data is None:
        logger.info("First-time Google sign-in for user_id=%s, creating profile", user_id)
        try:
            supabase.table("profiles").insert({
                "id": user_id,
                "firstname": firstname,
                "lastname": lastname,
                # phone/profession/city stay NULL (filled later via CompleteProfileScreen)
            }).execute()
        except Exception as e:
            logger.error("Profile insert failed for Google user_id=%s: %s", user_id, e)
            raise GoogleOAuthError(f"Failed to create profile for Google user: {e}") from e

        # Refetch to get the default role assigned by DB
        profile_response = (
            supabase.table("profiles")
            .select("firstname, lastname, phone, profession, city, role")
            .eq("id", user_id)
            .maybe_single()
            .execute()
        )
        profile_data = profile_response.data if profile_response is not None else None

    if profile_data is None:
        raise ProfileNotFound(f"Profile creation race condition for user_id={user_id}")

    p = profile_data
    me_result = MeResult(
        user_id=user_id,
        email=user_email,
        role=p["role"],
        firstname=p["firstname"],
        lastname=p["lastname"],
        phone=p.get("phone"),
        profession=p.get("profession"),
        city=p.get("city"),
    )

    return GoogleSignInResult(
        access_token=session.access_token,
        refresh_token=session.refresh_token,
        expires_in=session.expires_in,
        token_type=session.token_type,
        user=me_result,
    )
