"""Token lifecycle: 3-step OAuth2 login, refresh, re-auth, load/save."""

from __future__ import annotations

import json
import os
import stat
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse

import httpx
import jwt
from loguru import logger

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

ACCESS_TOKEN_URL = "https://b2c-login.toyota-europe.com/oauth2/realms/root/realms/tme/access_token"
AUTHENTICATE_URL = (
    "https://b2c-login.toyota-europe.com/json/realms/root/realms/tme/authenticate"
    "?authIndexType=service&authIndexValue=oneapp"
)
AUTHORIZE_URL = (
    "https://b2c-login.toyota-europe.com/oauth2/realms/root/realms/tme/authorize"
    "?client_id=oneapp&scope=openid+profile+write&response_type=code"
    "&redirect_uri=com.toyota.oneapp:/oauth2Callback&code_challenge=plain"
    "&code_challenge_method=plain"
)

DEFAULT_AUTH_PATH = Path.home() / ".config" / "tyta" / "auth.json"

# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------


class AuthError(Exception):
    """Base class for authentication errors."""

    def __init__(self, code: str, message: str) -> None:
        super().__init__(message)
        self.code = code
        self.message = message


class NotAuthenticatedError(AuthError):
    """Raised when no token file is found."""

    def __init__(self) -> None:
        super().__init__("not_authenticated", "Not authenticated. Run `tyta auth login`.")


class TokenExpiredError(AuthError):
    """Raised when tokens are expired and refresh failed."""

    def __init__(self) -> None:
        super().__init__(
            "token_expired",
            "Token expired. Run `tyta auth login` or set TOYOTA_USERNAME/TOYOTA_PASSWORD.",
        )


class LoginError(AuthError):
    """Raised when login fails."""

    def __init__(self, message: str) -> None:
        super().__init__("login_error", message)


class InvalidUsernameError(AuthError):
    """Raised when username is not found during auth."""

    def __init__(self) -> None:
        super().__init__("invalid_input", "Invalid username. User not found.")


# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------


def get_auth_path(override: str | None = None) -> Path:
    """Resolve auth token file path.

    Precedence: override argument > TOYOTA_AUTH_FILE env var > default.

    Args:
        override: Explicit path override (e.g. from --auth-file flag).

    Returns:
        Resolved auth file path.

    """
    if override:
        return Path(override)
    env = os.environ.get("TOYOTA_AUTH_FILE")
    if env:
        return Path(env)
    return DEFAULT_AUTH_PATH


# ---------------------------------------------------------------------------
# Token file I/O
# ---------------------------------------------------------------------------


def load_token_file(path: Path) -> dict[str, Any]:
    """Load token data from file.

    Args:
        path: Path to token file.

    Returns:
        Token data dict.

    Raises:
        NotAuthenticatedError: If file does not exist.
        json.JSONDecodeError: If file is not valid JSON.

    """
    if not path.exists():
        raise NotAuthenticatedError()
    return json.loads(path.read_text())


def save_token_file(data: dict[str, Any], path: Path) -> None:
    """Save token data to file with permissions 600.

    Args:
        data: Token data to write.
        path: Path to token file.

    """
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2))
    os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)


# ---------------------------------------------------------------------------
# 3-step OAuth2 flow
# ---------------------------------------------------------------------------


async def _perform_authentication(client: httpx.AsyncClient, username: str, password: str) -> str:
    """Run ForgeRock callback loop and return tokenId.

    Args:
        client: HTTP client.
        username: Toyota account email.
        password: Toyota account password.

    Returns:
        tokenId string.

    Raises:
        LoginError: If authentication fails.
        InvalidUsernameError: If username is not found.

    """
    data: dict[str, Any] = {}

    for _ in range(10):
        if "callbacks" in data:
            for cb in data["callbacks"]:
                cb_type = cb.get("type", "")
                if (
                    cb_type == "NameCallback"
                    and cb.get("output", [{}])[0].get("value") == "User Name"
                ):
                    cb["input"][0]["value"] = username
                elif cb_type == "PasswordCallback":
                    cb["input"][0]["value"] = password
                elif (
                    cb_type == "TextOutputCallback"
                    and cb.get("output", [{}])[0].get("value") == "User Not Found"
                ):
                    raise InvalidUsernameError()

        resp = await client.post(AUTHENTICATE_URL, json=data)
        if resp.status_code != 200:
            raise LoginError(f"Authentication failed. {resp.status_code}: {resp.text}")

        data = resp.json()
        if "tokenId" in data:
            return data["tokenId"]

    raise LoginError("Authentication failed: tokenId not received after multiple attempts.")


async def _perform_authorization(client: httpx.AsyncClient, token_id: str) -> str:
    """Get authorization code via redirect.

    Args:
        client: HTTP client.
        token_id: tokenId from step 1.

    Returns:
        Authorization code string.

    Raises:
        LoginError: If authorization fails.

    """
    resp = await client.get(
        AUTHORIZE_URL,
        headers={"cookie": f"iPlanetDirectoryPro={token_id}"},
        follow_redirects=False,
    )
    if resp.status_code != 302:
        raise LoginError(f"Authorization failed. {resp.status_code}: {resp.text}")

    location = resp.headers.get("location", "")
    parsed = urlparse(location)
    codes = parse_qs(parsed.query).get("code")
    if not codes:
        raise LoginError(f"Authorization failed: no code in redirect location: {location}")
    return codes[0]


async def _retrieve_tokens(client: httpx.AsyncClient, auth_code: str) -> dict[str, Any]:
    """Exchange authorization code for tokens.

    Args:
        client: HTTP client.
        auth_code: Authorization code from step 2.

    Returns:
        Token response dict.

    Raises:
        LoginError: If token exchange fails.

    """
    resp = await client.post(
        ACCESS_TOKEN_URL,
        headers={"authorization": "Basic b25lYXBwOm9uZWFwcA=="},
        data={
            "client_id": "oneapp",
            "code": auth_code,
            "redirect_uri": "com.toyota.oneapp:/oauth2Callback",
            "grant_type": "authorization_code",
            "code_verifier": "plain",
        },
    )
    if resp.status_code != 200:
        raise LoginError(f"Token exchange failed. {resp.status_code}: {resp.text}")
    return resp.json()


def _parse_token_response(response_data: dict[str, Any], username: str) -> dict[str, Any]:
    """Parse token response and build token file dict.

    Args:
        response_data: Raw token response from API.
        username: Username to store in token file.

    Returns:
        Token file dict.

    Raises:
        LoginError: If required fields are missing.

    """
    required = ["access_token", "refresh_token", "id_token", "expires_in"]
    missing = [f for f in required if f not in response_data]
    if missing:
        raise LoginError(f"Token response missing fields: {', '.join(missing)}")

    decoded = jwt.decode(
        response_data["id_token"],
        algorithms=["RS256"],
        options={"verify_signature": False},
    )
    uuid = decoded.get("uuid", "")

    expires_at = datetime.now(timezone.utc) + timedelta(seconds=response_data["expires_in"])

    return {
        "username": username,
        "access_token": response_data["access_token"],
        "refresh_token": response_data["refresh_token"],
        "uuid": uuid,
        "expires_at": expires_at.isoformat(),
    }


async def _full_auth_flow(username: str, password: str) -> dict[str, Any]:
    """Run the complete 3-step OAuth2 flow.

    Args:
        username: Toyota account email.
        password: Toyota account password.

    Returns:
        Token file dict.

    """
    async with httpx.AsyncClient(timeout=60) as client:
        token_id = await _perform_authentication(client, username, password)
        auth_code = await _perform_authorization(client, token_id)
        token_data = await _retrieve_tokens(client, auth_code)
    return _parse_token_response(token_data, username)


# ---------------------------------------------------------------------------
# Token refresh
# ---------------------------------------------------------------------------


async def _refresh_tokens(refresh_token: str, username: str) -> dict[str, Any]:
    """Refresh access token using refresh token.

    Args:
        refresh_token: Current refresh token.
        username: Username to preserve in token file.

    Returns:
        Updated token file dict.

    Raises:
        TokenExpiredError: If refresh request returns 4xx.

    """
    async with httpx.AsyncClient(timeout=60) as client:
        resp = await client.post(
            ACCESS_TOKEN_URL,
            headers={"authorization": "Basic b25lYXBwOm9uZWFwcA=="},
            data={
                "client_id": "oneapp",
                "redirect_uri": "com.toyota.oneapp:/oauth2Callback",
                "grant_type": "refresh_token",
                "code_verifier": "plain",
                "refresh_token": refresh_token,
            },
        )

    if 400 <= resp.status_code < 500:
        raise TokenExpiredError()

    if resp.status_code != 200:
        raise LoginError(f"Token refresh failed. {resp.status_code}: {resp.text}")

    return _parse_token_response(resp.json(), username)


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


async def login(
    username: str,
    password: str,
    auth_path: Path,
    config_path: Path | None = None,
) -> dict[str, Any]:
    """Perform full OAuth2 login, save token file, and auto-set default VIN.

    After a successful login, this function attempts to fetch the vehicle list.
    If the account has exactly one vehicle, its VIN is written as ``default_vin``
    in the config file and an informational message is included in the returned
    dict under the ``auto_vin_message`` key. For zero or multiple vehicles the
    config is left untouched.

    Any error from the vehicle-listing step is silently ignored so that the
    login itself never fails due to a post-login best-effort action.

    Args:
        username: Toyota account email.
        password: Toyota account password.
        auth_path: Path to write token file.
        config_path: Optional path to config file; uses default if None.

    Returns:
        Token data dict (same as what was written to disk), possibly enriched
        with an ``auto_vin_message`` key.

    """
    from tyta.core.client import ToyotaClient
    from tyta.core.config import load_config, save_config
    from tyta.core.vehicles import list_vehicles

    token_data = await _full_auth_flow(username, password)
    save_token_file(token_data, auth_path)

    # Best-effort: auto-set default VIN when exactly one vehicle is linked.
    try:
        client = ToyotaClient(
            access_token=token_data["access_token"],
            uuid=token_data["uuid"],
        )
        vehicles = await list_vehicles(client)
        if len(vehicles) == 1:
            vin = vehicles[0]["vin"]
            config = load_config(config_path)
            config["default_vin"] = vin
            save_config(config, config_path)
            token_data = {
                **token_data,
                "auto_vin_message": f"Default VIN set to '{vin}' (single vehicle on account)",
            }
    except Exception as exc:
        logger.debug(f"Auto-VIN skipped: {exc}")
        pass  # Never let vehicle listing failure break login

    return token_data


def logout(auth_path: Path) -> bool:
    """Delete token file.

    Args:
        auth_path: Path to token file.

    Returns:
        True if file was deleted, False if it did not exist.

    """
    if auth_path.exists():
        auth_path.unlink()
        return True
    return False


def status(auth_path: Path) -> dict[str, Any]:
    """Return current auth status.

    Args:
        auth_path: Path to token file.

    Returns:
        Dict with keys: authenticated, username, expires_at, expired.

    """
    if not auth_path.exists():
        return {
            "authenticated": False,
            "username": None,
            "expires_at": None,
            "expired": None,
        }

    try:
        data = load_token_file(auth_path)
    except (json.JSONDecodeError, NotAuthenticatedError):
        return {
            "authenticated": False,
            "username": None,
            "expires_at": None,
            "expired": None,
        }

    expires_at_str = data.get("expires_at")
    expired: bool | None = None
    if expires_at_str:
        try:
            expires_at_dt = datetime.fromisoformat(expires_at_str)
            expired = expires_at_dt <= datetime.now(timezone.utc)
        except ValueError:
            expired = None

    return {
        "authenticated": True,
        "username": data.get("username"),
        "expires_at": expires_at_str,
        "expired": expired,
    }


async def get_valid_token(auth_path: Path) -> dict[str, Any]:
    """Load token file and refresh if needed.

    Token resolution algorithm:
      1. Load token file — if missing: raise NotAuthenticatedError
      2. If access_token not expired — return token data
      3. If expired — attempt refresh
         - success: write updated tokens, return
         - 4xx: refresh expired; try env var re-auth, else raise TokenExpiredError
      4. Env var re-auth: TOYOTA_USERNAME + TOYOTA_PASSWORD both set — full auth flow

    Args:
        auth_path: Path to token file.

    Returns:
        Valid token data dict.

    Raises:
        NotAuthenticatedError: If token file missing.
        TokenExpiredError: If tokens expired and no env var credentials available.

    """
    token_data = load_token_file(auth_path)

    expires_at_str = token_data.get("expires_at", "")
    try:
        expires_at_dt = datetime.fromisoformat(expires_at_str)
        token_expired = expires_at_dt <= datetime.now(timezone.utc)
    except ValueError:
        token_expired = True

    if not token_expired:
        return token_data

    # Attempt refresh
    try:
        refreshed = await _refresh_tokens(token_data["refresh_token"], token_data["username"])
        save_token_file(refreshed, auth_path)
        return refreshed
    except TokenExpiredError:
        pass

    # Refresh token expired — try env var re-auth
    env_username = os.environ.get("TOYOTA_USERNAME")
    env_password = os.environ.get("TOYOTA_PASSWORD")

    if env_username and env_password:
        new_data = await _full_auth_flow(env_username, env_password)
        save_token_file(new_data, auth_path)
        return new_data

    raise TokenExpiredError()
