# @File: backend/services/whmcs.py
# @Desc: Reusable WHMCS API client. All WHMCS REST API actions go through call_whmcs().
#        Credentials (WHMCS_API_URL / WHMCS_API_IDENTIFIER / WHMCS_API_SECRET) are read
#        from environment variables and are never exposed to the frontend.
#
# NOTE: Admin folder is customized to /_admin_/ on production — do not assume /admin/.
#       The WHMCS REST API itself lives at /includes/api.php (unchanged) so API calls
#       are unaffected. For admin-facing deep links, use build_admin_url() below which
#       honours the WHMCS_ADMIN_PATH env var (default: "_admin_").
import asyncio
import logging
import os
import time
from contextvars import ContextVar
from typing import Any, Dict, Optional, Tuple

import httpx
from fastapi import HTTPException

logger = logging.getLogger(__name__)


# Error classification constants — surfaced to /system/health-check so the
# admin panel can show the exact failure category instead of a generic 502.
WHMCS_ERR_AUTH_FAILED = "auth_failed"
WHMCS_ERR_IP_NOT_WHITELISTED = "ip_not_whitelisted"
WHMCS_ERR_WRONG_ENDPOINT = "wrong_endpoint"
WHMCS_ERR_INVALID_ACTION = "invalid_action"
WHMCS_ERR_EMPTY_RESPONSE = "empty_response"
WHMCS_ERR_TRANSPORT = "transport_error"
WHMCS_ERR_UPSTREAM = "upstream_error"


def classify_whmcs_error(
    http_status: Optional[int],
    upstream_message: Optional[str],
    body_text: Optional[str],
    parsed_ok: bool,
) -> Optional[str]:
    """Classify a WHMCS failure into one of the ``WHMCS_ERR_*`` tags.

    Returns ``None`` when the call actually succeeded. The tags are stable
    strings consumed by both backend logs and the admin UI, so the admin
    knows exactly what to fix (credentials vs IP whitelist vs endpoint URL).
    """
    msg = (upstream_message or "").lower()
    body_lower = (body_text or "").lower()

    if msg:
        # WHMCS returns these messages literally when the caller IP isn't
        # allowed. "invalid ip" is the classic one; newer versions use
        # "ip address is not allowed".
        if "invalid ip" in msg or "ip address is not allowed" in msg or "not whitelisted" in msg:
            return WHMCS_ERR_IP_NOT_WHITELISTED
        if (
            "authentication failed" in msg
            or "invalid identifier or secret" in msg
            or "invalid credentials" in msg
            or "invalid api credentials" in msg
        ):
            return WHMCS_ERR_AUTH_FAILED
        if "invalid action" in msg or "not allowed" in msg or "api action" in msg and "not" in msg:
            return WHMCS_ERR_INVALID_ACTION

    if http_status == 404:
        return WHMCS_ERR_WRONG_ENDPOINT
    if http_status in (401, 403) and not msg:
        return WHMCS_ERR_AUTH_FAILED

    if not parsed_ok:
        if not body_text or not body_text.strip():
            return WHMCS_ERR_EMPTY_RESPONSE
        if "<!doctype html" in body_lower or "cloudflare" in body_lower or "<html" in body_lower:
            return WHMCS_ERR_WRONG_ENDPOINT
        return WHMCS_ERR_EMPTY_RESPONSE

    if http_status and http_status >= 500:
        return WHMCS_ERR_UPSTREAM

    return None


async def detect_outbound_ip(timeout: float = 5.0) -> Dict[str, Any]:
    """Return the backend's public outbound IPv4 as seen by the internet.

    This is the IP the administrator must add to WHMCS → Setup → General
    Settings → Security → "API IP Access Restriction". We try multiple
    providers so a single outage doesn't break the diagnostic.

    Returns a dict: ``{"ip": str | None, "provider": str | None, "error": str | None}``.
    Never raises — callers always get a structured response.
    """
    providers = [
        ("ipify", "https://api.ipify.org?format=json", "ip"),
        ("ifconfig.co", "https://ifconfig.co/json", "ip"),
        ("icanhazip", "https://ipv4.icanhazip.com", None),
    ]
    last_error: Optional[str] = None
    async with httpx.AsyncClient(timeout=timeout) as client:
        for name, url, json_key in providers:
            try:
                resp = await client.get(url, headers={"User-Agent": "DoitEasy-Portal/1.0"})
                if resp.status_code != 200:
                    last_error = f"{name} returned HTTP {resp.status_code}"
                    continue
                if json_key:
                    data = resp.json()
                    ip = (data.get(json_key) or "").strip()
                else:
                    ip = (resp.text or "").strip()
                if ip:
                    return {"ip": ip, "provider": name, "error": None}
                last_error = f"{name} returned empty body"
            except Exception as exc:  # noqa: BLE001 - best-effort probe
                last_error = f"{name} failed: {exc}"
                continue
    return {"ip": None, "provider": None, "error": last_error or "no provider succeeded"}

# ContextVar carrying the current request's user email (set by middleware/deps),
# so call_whmcs() can record it into whmcs_api_logs without changing call sites.
_current_user_email: ContextVar[Optional[str]] = ContextVar(
    "_current_user_email", default=None
)


def set_current_user_email(email: Optional[str]) -> None:
    """Called by auth dependency to tag subsequent WHMCS calls with the user."""
    _current_user_email.set(email)


def get_current_user_email() -> Optional[str]:
    return _current_user_email.get()

# Keep the WHMCS request timeout well below the Cloudflare / platform edge
# timeout (~30s) so a slow WHMCS never causes an origin 502 at the edge.
_DEFAULT_TIMEOUT = 10.0

# Simple in-process cache for GetClientsDetails lookups. The dashboard fires
# 5-6 /whmcs/* endpoints in parallel on load, each of which needs to resolve
# the client record by email; caching for a minute avoids hammering WHMCS.
# Structure: { email_lower: (expires_at_epoch_seconds, client_dict_or_None) }
_CLIENT_CACHE: Dict[str, Tuple[float, Optional[Dict[str, Any]]]] = {}
_CLIENT_CACHE_TTL = 60.0  # seconds


def _get_credentials() -> Dict[str, str]:
    """Load WHMCS credentials from environment variables.

    Raises HTTPException(500) if any required value is missing so callers get a clear error.
    """
    url = os.environ.get("WHMCS_API_URL")
    identifier = os.environ.get("WHMCS_API_IDENTIFIER")
    secret = os.environ.get("WHMCS_API_SECRET")

    if not url or not identifier or not secret:
        logger.error("WHMCS credentials are not fully configured in environment variables.")
        raise HTTPException(
            status_code=500,
            detail="WHMCS integration is not configured. Please contact the administrator.",
        )

    # If only a host is provided (e.g. 'client.doiteasy.net'), normalize to the api.php URL.
    normalized = url.strip()
    if not normalized.startswith(("http://", "https://")):
        normalized = f"https://{normalized}"
    if "/includes/api.php" not in normalized:
        normalized = normalized.rstrip("/") + "/includes/api.php"

    return {"url": normalized, "identifier": identifier, "secret": secret}


def _get_base_host() -> str:
    """Derive the WHMCS base host (scheme + domain) from WHMCS_API_URL.

    Example: WHMCS_API_URL='https://client.doiteasy.net/includes/api.php'
             -> 'https://client.doiteasy.net'
    """
    url = (os.environ.get("WHMCS_API_URL") or "").strip()
    if not url:
        return ""
    if not url.startswith(("http://", "https://")):
        url = f"https://{url}"
    # Strip trailing /includes/api.php or any path.
    # We only need scheme://host.
    try:
        from urllib.parse import urlparse

        parsed = urlparse(url)
        if parsed.scheme and parsed.netloc:
            return f"{parsed.scheme}://{parsed.netloc}"
    except Exception:
        pass
    return url.rstrip("/")


def build_admin_url(path: str = "") -> str:
    """Build a deep link into the WHMCS admin area.

    Honours the WHMCS_ADMIN_PATH env var because the admin folder on the live
    production site is customized from the default '/admin/' to '/_admin_/'.
    Hardcoding '/admin/' would break every admin deep-link in production.

    Args:
        path: Optional sub-path within the admin area (e.g. 'supporttickets.php?id=123').
              Leading slashes are stripped.

    Returns:
        Fully qualified URL, e.g. 'https://client.doiteasy.net/_admin_/supporttickets.php?id=123'.
        Returns an empty string if WHMCS_API_URL is not configured.
    """
    host = _get_base_host()
    if not host:
        return ""
    admin_segment = (os.environ.get("WHMCS_ADMIN_PATH") or "_admin_").strip().strip("/")
    sub = (path or "").lstrip("/")
    if sub:
        return f"{host}/{admin_segment}/{sub}"
    return f"{host}/{admin_segment}/"


async def call_whmcs(action: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    """Call a WHMCS REST API action.

    Args:
        action: WHMCS action name (e.g. 'GetClientsDetails', 'AddClient').
        payload: Extra parameters for the action. Credentials + responsetype are added automatically.

    Returns:
        Parsed JSON response as a dict.

    Raises:
        HTTPException: On transport errors or non-success WHMCS responses.
    """
    creds = _get_credentials()
    data: Dict[str, Any] = {
        "identifier": creds["identifier"],
        "secret": creds["secret"],
        "action": action,
        "responsetype": "json",
    }
    if payload:
        data.update({k: v for k, v in payload.items() if v is not None})

    start = time.time()
    user_email = get_current_user_email()

    def _schedule_log(status: str, http_status: Optional[int], error: Optional[str]) -> None:
        try:
            from services.whmcs_logger import record_whmcs_call  # lazy import

            duration_ms = int((time.time() - start) * 1000)
            asyncio.create_task(
                record_whmcs_call(
                    action=action,
                    status=status,
                    user_email=user_email,
                    http_status=http_status,
                    duration_ms=duration_ms,
                    error=error,
                )
            )
        except Exception:  # noqa: BLE001
            logger.exception("Failed to schedule whmcs_api_logs write for %s", action)

    # Identify our portal clearly in User-Agent. Some Cloudflare WAF rules will
    # challenge or block empty / default httpx user-agents.
    request_headers = {
        "User-Agent": "DoitEasy-Portal/1.0 (+https://doiteasy.net)",
        "Accept": "application/json, text/plain, */*",
    }

    try:
        async with httpx.AsyncClient(timeout=_DEFAULT_TIMEOUT) as http_client:
            response = await http_client.post(
                creds["url"], data=data, headers=request_headers
            )
    except httpx.RequestError as exc:
        logger.exception("WHMCS request transport error for action %s", action)
        _schedule_log("error", None, f"transport: {exc}")
        raise HTTPException(
            status_code=502,
            detail=f"Failed to reach WHMCS API: {exc}",
        ) from exc

    # Capture Cloudflare diagnostic headers for logging so the admin can give
    # the cf-ray id to Cloudflare / hosting support when needed.
    cf_ray = response.headers.get("cf-ray") or response.headers.get("CF-RAY")
    cf_server = response.headers.get("server")

    # Even non-200 responses from WHMCS typically carry a JSON body like
    # {"result":"error","message":"Invalid IP 1.2.3.4"} — try to surface that
    # message to the caller instead of a generic 502.
    if response.status_code != 200:
        logger.error(
            "WHMCS API returned HTTP %s for action %s (cf-ray=%s server=%s): %s",
            response.status_code,
            action,
            cf_ray,
            cf_server,
            response.text[:500],
        )
        upstream_message: Optional[str] = None
        try:
            upstream_body = response.json()
            if isinstance(upstream_body, dict):
                raw_msg = upstream_body.get("message")
                if isinstance(raw_msg, str) and raw_msg.strip():
                    upstream_message = raw_msg.strip()
        except ValueError:
            upstream_message = None

        # 403 with "Invalid IP …" means the portal's outbound IP is not on the
        # WHMCS API IP whitelist. Surface that clearly so ops can fix it.
        if response.status_code == 403 and upstream_message:
            _schedule_log("error", response.status_code, upstream_message)
            raise HTTPException(
                status_code=502,
                detail=(
                    "Billing system rejected the request: "
                    f"{upstream_message}. Please contact the administrator to "
                    "whitelist the portal's outbound IP in the WHMCS API IP access list."
                ),
            )

        # Cloudflare-origin errors (520/521/522/523/524/525/526/527) indicate
        # the WHMCS host itself is down / misconfigured — it is NOT our fault.
        # Surface a clear, actionable message + cf-ray id so the admin can hand
        # it to Cloudflare / hosting support.
        body_text_lower = (response.text or "")[:2000].lower()
        looks_like_cloudflare_origin_error = (
            520 <= response.status_code <= 530
            or "cloudflare" in body_text_lower
            or "origin web server" in body_text_lower
            or "error 52" in body_text_lower
        )
        if looks_like_cloudflare_origin_error:
            err = (
                f"cloudflare-origin HTTP {response.status_code}"
                + (f" cf-ray={cf_ray}" if cf_ray else "")
            )
            _schedule_log("error", response.status_code, err)
            detail = (
                f"Billing system is temporarily unavailable "
                f"(upstream error {response.status_code}). "
                "The WHMCS origin server did not return a valid response. "
                "Please contact the administrator."
            )
            if cf_ray:
                detail += f" (cf-ray: {cf_ray})"
            raise HTTPException(status_code=502, detail=detail)

        if upstream_message:
            _schedule_log("error", response.status_code, upstream_message)
            raise HTTPException(
                status_code=502,
                detail=f"Billing system error: {upstream_message}",
            )
        err_tag = f"HTTP {response.status_code}" + (
            f" cf-ray={cf_ray}" if cf_ray else ""
        )
        _schedule_log("error", response.status_code, err_tag)
        detail = (
            f"Billing system is unavailable (HTTP {response.status_code}). "
            "Please try again in a moment."
        )
        if cf_ray:
            detail += f" (cf-ray: {cf_ray})"
        raise HTTPException(status_code=502, detail=detail)

    try:
        body = response.json()
    except ValueError as exc:
        logger.error(
            "WHMCS API returned non-JSON body for action %s (cf-ray=%s): %s",
            action,
            cf_ray,
            response.text[:500],
        )
        body_text_lower = (response.text or "")[:2000].lower()
        # 200 OK but body is HTML from Cloudflare/WAF — still treat as upstream issue.
        if "cloudflare" in body_text_lower or "<!doctype html" in body_text_lower:
            err = "non-json body (cloudflare html)" + (
                f" cf-ray={cf_ray}" if cf_ray else ""
            )
            _schedule_log("error", response.status_code, err)
            detail = (
                "Billing system is temporarily unavailable (upstream returned an "
                "invalid response). Please contact the administrator."
            )
            if cf_ray:
                detail += f" (cf-ray: {cf_ray})"
            raise HTTPException(status_code=502, detail=detail) from exc
        _schedule_log("error", response.status_code, "non-json body")
        raise HTTPException(
            status_code=502,
            detail="Billing system returned an unexpected response. Please try again.",
        ) from exc

    # WHMCS always sends result=success | error at the top level.
    if isinstance(body, dict) and body.get("result") == "error":
        message = body.get("message") or "Unknown WHMCS error."
        logger.warning("WHMCS API error for action %s: %s", action, message)
        _schedule_log("error", response.status_code, message)
        # Classify common credential-level errors as 401 so the frontend can
        # show "Invalid email or password" instead of a generic 400.
        lowered = message.lower()
        if any(
            needle in lowered
            for needle in (
                "invalid username or password",
                "login details incorrect",
                "invalid login details",
                "client not found",
            )
        ):
            raise HTTPException(status_code=401, detail="Invalid email or password.")
        raise HTTPException(status_code=400, detail=f"WHMCS error: {message}")

    _schedule_log("success", response.status_code, None)
    return body


async def call_whmcs_safe(
    action: str, payload: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
    """Like ``call_whmcs`` but swallows expected errors and returns ``None``.

    This is intended for read-only list endpoints where a single permission
    error (e.g. "API action 'getinvoices' is not allowed") or transient upstream
    failure should NOT take down the rest of the dashboard. The caller can
    treat ``None`` as "no data available right now" and render an empty list.

    Unexpected exceptions are still logged but never propagate to the caller.
    """
    try:
        return await call_whmcs(action, payload)
    except HTTPException as exc:
        logger.warning(
            "call_whmcs_safe: swallowing WHMCS error for action %s: %s",
            action,
            exc.detail,
        )
        return None
    except Exception:  # noqa: BLE001 - defensive: never bubble up from a dashboard list
        logger.exception("call_whmcs_safe: unexpected error for action %s", action)
        return None


def _cache_get(email_lower: str) -> Optional[Optional[Dict[str, Any]]]:
    """Return cached client record (or cached-miss None) if still fresh.

    Returns:
        - A dict when a client was previously found and the entry is fresh.
        - ``None`` (the cached "not found" value) when we previously confirmed
          the email had no WHMCS client and the entry is fresh.
        - The sentinel tuple is missing / expired → we return the Python
          sentinel ``...`` via a separate "miss" signal; since we can't
          distinguish "cached None" from "cache miss" with just ``None``,
          callers should use ``_cache_has`` first.
    """
    entry = _CLIENT_CACHE.get(email_lower)
    if not entry:
        return None
    expires_at, value = entry
    if expires_at < time.time():
        _CLIENT_CACHE.pop(email_lower, None)
        return None
    return value


def _cache_has(email_lower: str) -> bool:
    entry = _CLIENT_CACHE.get(email_lower)
    if not entry:
        return False
    if entry[0] < time.time():
        _CLIENT_CACHE.pop(email_lower, None)
        return False
    return True


def _cache_set(email_lower: str, value: Optional[Dict[str, Any]]) -> None:
    _CLIENT_CACHE[email_lower] = (time.time() + _CLIENT_CACHE_TTL, value)


async def get_client_by_email(
    email: str, use_cache: bool = True
) -> Optional[Dict[str, Any]]:
    """Look up a WHMCS client by email. Returns None if not found.

    WHMCS 'GetClientsDetails' with an email returns result=success when the client exists,
    otherwise it returns result=error with a message like 'Client Not Found'. We translate
    that to None so callers can handle missing users cleanly.

    The result is cached in-process for 60s per email (both hits and misses)
    to protect WHMCS from the parallel burst of dashboard requests.
    """
    email_lower = (email or "").strip().lower()
    if not email_lower:
        return None

    if use_cache and _cache_has(email_lower):
        return _cache_get(email_lower)

    creds = _get_credentials()
    data = {
        "identifier": creds["identifier"],
        "secret": creds["secret"],
        "action": "GetClientsDetails",
        "email": email_lower,
        "stats": "true",
        "responsetype": "json",
    }
    try:
        async with httpx.AsyncClient(timeout=_DEFAULT_TIMEOUT) as http_client:
            response = await http_client.post(creds["url"], data=data)
    except httpx.RequestError as exc:
        logger.exception("WHMCS transport error during GetClientsDetails")
        raise HTTPException(status_code=502, detail=f"Failed to reach WHMCS API: {exc}") from exc

    if response.status_code != 200:
        raise HTTPException(status_code=502, detail=f"WHMCS API returned status {response.status_code}.")

    try:
        body = response.json()
    except ValueError as exc:
        raise HTTPException(status_code=502, detail="WHMCS API returned a non-JSON response.") from exc

    if isinstance(body, dict) and body.get("result") == "success":
        _cache_set(email_lower, body)
        return body

    # Treat 'Client Not Found' style errors as missing so the caller can offer registration.
    _cache_set(email_lower, None)
    return None


def invalidate_client_cache(email: Optional[str] = None) -> None:
    """Drop a single or all entries from the in-process client cache.

    Call after any WHMCS mutation (UpdateClient, AddClient, password change) so
    subsequent reads don't serve stale profile data.
    """
    if email is None:
        _CLIENT_CACHE.clear()
        return
    _CLIENT_CACHE.pop(email.strip().lower(), None)