from __future__ import annotations

import asyncio
import json
import os
import sys
import time
import webbrowser
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import parse_qs, urlparse

import httpx

from .logging import get_logger

logger = get_logger(__name__)

DEFAULT_SCOPES = [
    "ads_management",
    "ads_read",
    "business_management",
    "pages_read_engagement",
    "pages_show_list",
    "read_insights",
]

TOKEN_EXPIRY_GRACE_SECONDS = 300  # refresh 5 minutes before expiry


def _default_cache_path() -> Path:
    override = os.getenv("META_TOKEN_CACHE_PATH")
    if override:
        return Path(override)
    return Path.home() / ".config" / "meta-ads-mcp" / "token.json"


def _ensure_parent(path: Path) -> None:
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
    except OSError as exc:  # pragma: no cover - extremely rare
        logger.warning("unable to create cache directory %s: %s", path.parent, exc)


def load_cached_token(path: Path | None = None) -> Optional[str]:
    cache_path = path or _default_cache_path()
    if not cache_path.exists():
        return None
    try:
        payload = json.loads(cache_path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as exc:
        logger.warning("failed to read token cache %s: %s", cache_path, exc)
        return None

    access_token = payload.get("access_token")
    if not access_token:
        return None

    expires_at = payload.get("expires_at")
    if expires_at:
        if time.time() >= expires_at - TOKEN_EXPIRY_GRACE_SECONDS:
            logger.info("cached access token expired or close to expiring; discarding")
            return None

    return access_token


def save_cached_token(
    access_token: str,
    *,
    expires_in: Optional[int] = None,
    path: Path | None = None,
) -> None:
    cache_path = path or _default_cache_path()
    _ensure_parent(cache_path)
    document: Dict[str, Any] = {"access_token": access_token}
    if expires_in:
        document["expires_at"] = int(time.time() + expires_in)
        document["expires_in"] = expires_in
    try:
        cache_path.write_text(json.dumps(document), encoding="utf-8")
    except OSError as exc:
        logger.warning("failed to persist token cache to %s: %s", cache_path, exc)


@dataclass
class OAuthSettings:
    app_id: str
    app_secret: str
    scopes: list[str]
    graph_version: str
    auth_host: str = "https://www.facebook.com"
    api_host: str = "https://graph.facebook.com"
    timeout_seconds: float = float(os.getenv("META_LOGIN_TIMEOUT_SECONDS", "300"))
    exchange_long_lived: bool = True

    @property
    def scope_string(self) -> str:
        return ",".join(sorted(set(self.scopes)))


def _resolve_scopes() -> list[str]:
    env_scopes = os.getenv("META_OAUTH_SCOPES")
    if env_scopes:
        return [scope.strip() for scope in env_scopes.split(",") if scope.strip()]
    return DEFAULT_SCOPES


def get_oauth_settings(graph_version: str) -> Optional[OAuthSettings]:
    app_id = os.getenv("META_APP_ID")
    app_secret = os.getenv("META_APP_SECRET")
    if not app_id or not app_secret:
        return None
    scopes = _resolve_scopes()
    return OAuthSettings(
        app_id=app_id,
        app_secret=app_secret,
        scopes=scopes,
        graph_version=graph_version,
        auth_host=os.getenv("META_OAUTH_HOST", "https://www.facebook.com"),
        api_host=os.getenv("META_GRAPH_BASE_URL", "https://graph.facebook.com"),
    )


async def _run_local_callback_server(
    future: asyncio.Future[Dict[str, Any]],
) -> tuple[asyncio.AbstractServer, str]:
    asyncio.get_running_loop()
    token_info: Dict[str, Any] = {}

    async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
        data = await reader.read(65535)
        request_line = data.decode(errors="ignore").splitlines()[0]
        path = request_line.split(" ")[1] if " " in request_line else "/"
        parsed = urlparse(path)
        params = parse_qs(parsed.query)
        html_body = "<html><body><h1>Meta Ads MCP</h1><p>You may close this window.</p></body></html>"

        if parsed.path == "/callback":
            if "error" in params:
                token_info.update({"error": params.get("error_description", ["Authentication failed"])[0]})
            elif "code" in params:
                token_info.update({"code": params["code"][0], "state": params.get("state", [""])[0]})
                html_body = (
                    "<html><body><h1>Authentication complete</h1>"
                    "<p>You can return to your MCP client.</p>"
                    "<script>setTimeout(() => window.close(), 1500);</script>"
                    "</body></html>"
                )
            else:
                token_info.update({"error": "missing_code"})
        else:
            token_info.update({"error": "invalid_path"})

        response = (
            "HTTP/1.1 200 OK\r\n"
            "Content-Type: text/html; charset=utf-8\r\n"
            f"Content-Length: {len(html_body.encode())}\r\n"
            "Connection: close\r\n\r\n"
            f"{html_body}"
        )
        writer.write(response.encode())
        await writer.drain()
        writer.close()
        try:
            await writer.wait_closed()
        except Exception:  # pragma: no cover - benign on some platforms
            pass

        if not future.done():
            future.set_result(token_info)

    server = await asyncio.start_server(handle, host="127.0.0.1", port=0)
    port = server.sockets[0].getsockname()[1]
    redirect_uri = f"http://localhost:{port}/callback"
    logger.info("OAuth callback server running on %s", redirect_uri)
    return server, redirect_uri


async def _exchange_code_for_token(
    settings: OAuthSettings, code: str, redirect_uri: str
) -> tuple[str, Optional[int]]:
    params = {
        "client_id": settings.app_id,
        "redirect_uri": redirect_uri,
        "client_secret": settings.app_secret,
        "code": code,
    }
    async with httpx.AsyncClient(timeout=settings.timeout_seconds) as client:
        token_url = f"{settings.api_host}/{settings.graph_version}/oauth/access_token"
        response = await client.get(token_url, params=params)
        response.raise_for_status()
        data = response.json()
        access_token = data["access_token"]
        expires_in = data.get("expires_in")

        if settings.exchange_long_lived:
            try:
                long_params = {
                    "grant_type": "fb_exchange_token",
                    "client_id": settings.app_id,
                    "client_secret": settings.app_secret,
                    "fb_exchange_token": access_token,
                }
                long_response = await client.get(token_url, params=long_params)
                long_response.raise_for_status()
                long_data = long_response.json()
                access_token = long_data.get("access_token", access_token)
                expires_in = long_data.get("expires_in", expires_in)
            except httpx.HTTPError as exc:
                logger.warning("long-lived token exchange failed: %s", exc)

    return access_token, expires_in


async def perform_oauth_login(settings: OAuthSettings, cache_path: Path | None = None) -> str:
    loop = asyncio.get_running_loop()
    token_future: asyncio.Future[Dict[str, Any]] = loop.create_future()
    server, redirect_uri = await _run_local_callback_server(token_future)

    auth_params = {
        "client_id": settings.app_id,
        "redirect_uri": redirect_uri,
        "response_type": "code",
        "scope": settings.scope_string,
    }
    query = str(httpx.QueryParams(auth_params))
    auth_url = f"{settings.auth_host}/{settings.graph_version}/dialog/oauth?{query}"

    logger.info("Opening browser for Meta OAuth flow")
    opened = webbrowser.open(auth_url)
    if not opened:
        logger.warning("automatic browser open failed; navigate to %s manually", auth_url)
    else:
        logger.info("If your browser did not open, visit this URL manually: %s", auth_url)

    try:
        token_info = await asyncio.wait_for(token_future, timeout=settings.timeout_seconds)
    except asyncio.TimeoutError as exc:
        logger.error("OAuth login timed out after %s seconds", settings.timeout_seconds)
        raise RuntimeError("OAuth login timed out") from exc
    finally:
        server.close()
        await server.wait_closed()

    if "error" in token_info:
        raise RuntimeError(f"OAuth flow failed: {token_info['error']}")
    if "code" not in token_info:
        raise RuntimeError("OAuth flow did not return an authorization code")

    access_token, expires_in = await _exchange_code_for_token(settings, token_info["code"], redirect_uri)
    save_cached_token(access_token, expires_in=expires_in, path=cache_path)
    return access_token


async def ensure_access_token(graph_version: str, cache_path: Path | None = None) -> str:
    env_token = os.getenv("META_ACCESS_TOKEN")
    if env_token:
        logger.info("Using Meta access token from environment variable")
        return env_token

    cached = load_cached_token(path=cache_path)
    if cached:
        logger.info("Using Meta access token from local cache")
        return cached

    settings = get_oauth_settings(graph_version)
    if not settings:
        raise RuntimeError(
            "Meta access token not provided. Set META_ACCESS_TOKEN or provide META_APP_ID and META_APP_SECRET for OAuth."
        )

    logger.info("No cached token available; starting OAuth login")
    return await perform_oauth_login(settings, cache_path=cache_path)


def token_cache_path() -> Path:
    """Return the resolved cache path where OAuth tokens are stored."""
    return _default_cache_path()


def cli_login() -> None:
    """Entry point for manual Meta OAuth login."""

    graph_version = os.getenv("META_API_VERSION", "v24.0")
    try:
        asyncio.run(ensure_access_token(graph_version))
    except RuntimeError as exc:
        print(f"[meta-ads-login] Authentication failed: {exc}", file=sys.stderr)
        raise SystemExit(1) from exc
    except Exception as exc:  # pragma: no cover - unexpected errors
        print(f"[meta-ads-login] Unexpected error: {exc}", file=sys.stderr)
        raise SystemExit(1) from exc

    if os.getenv("META_ACCESS_TOKEN"):
        print("[meta-ads-login] META_ACCESS_TOKEN environment variable detected; no new token stored.")
    else:
        cache_path = token_cache_path()
        print(f"[meta-ads-login] Authentication successful. Token cached at {cache_path}")

    # Avoid printing secret token value
    print("[meta-ads-login] Access token ready for use.")


if __name__ == "__main__":  # pragma: no cover - manual CLI usage
    cli_login()
