# tiktok_client.py (Python 3.8 compatible)
import os
import time
import urllib.parse
from typing import Any, Dict, Optional, Literal

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

from config import (
    TIKTOK_CLIENT_KEY,
    TIKTOK_CLIENT_SECRET,
    TIKTOK_REDIRECT_URI,
    TIKTOK_SCOPES,
    TIKTOK_PRODUCTION,  # lo dejo por compatibilidad aunque no se use aquí
)

# ----------------------------
# Endpoints (TikTok Open API v2)
# ----------------------------
TIKTOK_OAUTH_AUTHORIZE_URL = "https://www.tiktok.com/v2/auth/authorize/"
TIKTOK_OAUTH_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/"

# Draft (inbox) upload – requires video.upload
INBOX_INIT_URL = "https://open.tiktokapis.com/v2/post/publish/inbox/video/init/"

# Direct post (publish) – requires video.publish
DIRECT_POST_INIT_URL = "https://open.tiktokapis.com/v2/post/publish/video/init/"

# Required: creator info and status fetch
CREATOR_INFO_URL = "https://open.tiktokapis.com/v2/post/publish/creator_info/query/"
STATUS_FETCH_URL = "https://open.tiktokapis.com/v2/post/publish/status/fetch/"

# ----------------------------
# HTTP session (retries)
# ----------------------------
_ALLOWED_RETRY_METHODS = frozenset(["GET", "POST", "PUT"])


def _make_retry() -> Retry:
    """
    Compatibilidad urllib3:
      - urllib3 nuevo: allowed_methods=
      - urllib3 viejo: method_whitelist=
    Además, algunas versiones no aceptan raise_on_status.
    """
    base_kwargs = dict(
        total=3,
        connect=3,
        read=3,
        backoff_factor=0.4,
        status_forcelist=(429, 500, 502, 503, 504),
    )

    # 1) urllib3 >= 1.26
    try:
        return Retry(
            **base_kwargs,
            allowed_methods=_ALLOWED_RETRY_METHODS,
            raise_on_status=False,
        )
    except TypeError:
        pass

    # 2) urllib3 < 1.26
    try:
        return Retry(
            **base_kwargs,
            method_whitelist=_ALLOWED_RETRY_METHODS,
            raise_on_status=False,
        )
    except TypeError:
        pass

    # 3) fallback final (por si raise_on_status no existe)
    return Retry(
        **base_kwargs,
        method_whitelist=_ALLOWED_RETRY_METHODS,
    )


def _build_session() -> requests.Session:
    s = requests.Session()
    retry = _make_retry()
    adapter = HTTPAdapter(max_retries=retry, pool_connections=20, pool_maxsize=20)
    s.mount("https://", adapter)
    s.mount("http://", adapter)
    return s


_SESSION = _build_session()

# ----------------------------
# Token helpers
# ----------------------------
def compute_expires_at(now_ts: int, expires_in: int, skew_seconds: int = 120) -> int:
    """
    Guarda expiración absoluta con margen (skew) para evitar usar tokens al límite.
    """
    return now_ts + max(0, int(expires_in) - int(skew_seconds))


def _normalize_token_bundle(bundle: Dict[str, Any]) -> Dict[str, Any]:
    """
    Normaliza el bundle OAuth a expiraciones absolutas:
      - expires_at_ts
      - refresh_expires_at_ts
    """
    now = int(time.time())
    access_expires_in = int(bundle.get("expires_in") or 0)
    refresh_expires_in = int(bundle.get("refresh_expires_in") or 0)

    bundle["expires_at_ts"] = compute_expires_at(now, access_expires_in, skew_seconds=120) if access_expires_in else 0
    bundle["refresh_expires_at_ts"] = compute_expires_at(now, refresh_expires_in, skew_seconds=0) if refresh_expires_in else 0
    bundle["updated_at_ts"] = now
    return bundle


def _raise_oauth_error_if_any(payload: Dict[str, Any]) -> None:
    """
    OAuth v2 devuelve errores en top-level:
      {"error":"...", "error_description":"...", "log_id":"..."}
    """
    if isinstance(payload, dict) and payload.get("error"):
        raise RuntimeError(
            f"oauth_error={payload.get('error')} "
            f"desc={payload.get('error_description')} "
            f"log_id={payload.get('log_id')}"
        )


def refresh_user_access_token(client_key: str, client_secret: str, refresh_token: str) -> Dict[str, Any]:
    """
    Refresca access_token usando refresh_token.
    Devuelve el bundle OAuth (sin normalizar).
    """
    data = {
        "client_key": client_key,
        "client_secret": client_secret,
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
    }
    r = _SESSION.post(
        TIKTOK_OAUTH_TOKEN_URL,
        data=data,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=30,
    )
    payload = r.json() if r.content else {}
    if r.status_code != 200:
        raise RuntimeError(f"refresh_failed http={r.status_code} payload={payload}")

    _raise_oauth_error_if_any(payload)
    return payload


def refresh_token_bundle(current: Dict[str, Any]) -> Dict[str, Any]:
    """
    Refresca el bundle actual y aplica rotación de refresh_token si TikTok lo devuelve.
    """
    rt = (current or {}).get("refresh_token")
    if not rt:
        raise RuntimeError("missing_refresh_token")

    bundle = refresh_user_access_token(TIKTOK_CLIENT_KEY, TIKTOK_CLIENT_SECRET, rt)

    # TikTok puede rotar refresh_token: persistir el nuevo si llega
    if bundle.get("refresh_token"):
        current["refresh_token"] = bundle["refresh_token"]

    if bundle.get("access_token"):
        current["access_token"] = bundle["access_token"]

    # Copiar campos útiles si vienen
    for k in ("expires_in", "refresh_expires_in", "open_id", "scope", "token_type"):
        if k in bundle and bundle[k] is not None:
            current[k] = bundle[k]

    return _normalize_token_bundle(current)


def ensure_fresh_token_bundle(current: Dict[str, Any], *, refresh_if_lt_seconds: int = 300) -> Dict[str, Any]:
    """
    Garantiza que access_token sea válido.
    - Si expira en <refresh_if_lt_seconds, refresca.
    - Si refresh_token expiró, lanza error (requiere relogin).
    """
    if not current:
        raise RuntimeError("missing_token_bundle")

    now = int(time.time())
    refresh_expires_at = int(current.get("refresh_expires_at_ts") or 0)
    if refresh_expires_at and now >= refresh_expires_at:
        raise RuntimeError("refresh_token_expired_requires_relogin")

    expires_at = int(current.get("expires_at_ts") or 0)
    if not expires_at or now >= (expires_at - int(refresh_if_lt_seconds)):
        return refresh_token_bundle(current)

    return current


# ----------------------------
# OAuth helpers
# ----------------------------
def build_authorize_url(state: str) -> str:
    params = {
        "client_key": TIKTOK_CLIENT_KEY,
        "response_type": "code",
        "scope": TIKTOK_SCOPES,
        "redirect_uri": TIKTOK_REDIRECT_URI,
        "state": state,
    }
    return TIKTOK_OAUTH_AUTHORIZE_URL + "?" + urllib.parse.urlencode(params)


def exchange_code_for_token(code: str) -> Dict[str, Any]:
    """
    Intercambia code por token bundle y lo normaliza con expiraciones absolutas.
    """
    payload = {
        "client_key": TIKTOK_CLIENT_KEY,
        "client_secret": TIKTOK_CLIENT_SECRET,
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": TIKTOK_REDIRECT_URI,
    }
    resp = _SESSION.post(
        TIKTOK_OAUTH_TOKEN_URL,
        data=payload,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=30,
    )
    data = resp.json() if resp.content else {}
    if resp.status_code != 200:
        raise RuntimeError(f"oauth_exchange_failed http={resp.status_code} payload={data}")

    _raise_oauth_error_if_any(data)
    return _normalize_token_bundle(data)


# ----------------------------
# TikTok API helpers
# ----------------------------
def _api_headers(access_token: str) -> Dict[str, str]:
    return {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json; charset=UTF-8",
    }


def _raise_api_error_if_any(payload: Dict[str, Any], *, context: str) -> None:
    """
    TikTok API v2 suele devolver:
      {"error":{"code":"ok",...}, "data":{...}}
    """
    err = (payload or {}).get("error") or {}
    code = err.get("code")
    if code and code != "ok":
        raise RuntimeError(f"{context} error_code={code} message={err.get('message')} log_id={err.get('log_id')}")


# ----------------------------
# Mandatory audit endpoints
# ----------------------------
def query_creator_info(access_token: str) -> Dict[str, Any]:
    headers = _api_headers(access_token)
    resp = _SESSION.post(CREATOR_INFO_URL, headers=headers, json={}, timeout=30)
    payload = resp.json() if resp.content else {}
    if resp.status_code != 200:
        raise RuntimeError(f"creator_info_http_failed http={resp.status_code} payload={payload}")
    _raise_api_error_if_any(payload, context="creator_info")
    return payload.get("data", {}) or {}


def fetch_post_status(access_token: str, publish_id: str) -> Dict[str, Any]:
    headers = _api_headers(access_token)
    resp = _SESSION.post(STATUS_FETCH_URL, headers=headers, json={"publish_id": publish_id}, timeout=30)
    payload = resp.json() if resp.content else {}
    if resp.status_code != 200:
        raise RuntimeError(f"status_fetch_http_failed http={resp.status_code} payload={payload}")
    _raise_api_error_if_any(payload, context="status_fetch")
    return payload.get("data", {}) or {}


# ============================
# MODE 1: DRAFT (video.upload) - FILE_UPLOAD
# ============================
def _upload_video_as_draft(file_path: str, access_token: str) -> Dict[str, Any]:
    file_size = os.path.getsize(file_path)

    headers = _api_headers(access_token)
    body = {
        "source_info": {
            "source": "FILE_UPLOAD",
            "video_size": file_size,
            "chunk_size": file_size,
            "total_chunk_count": 1,
        }
    }

    init_resp = _SESSION.post(INBOX_INIT_URL, headers=headers, json=body, timeout=60)
    init_data = init_resp.json() if init_resp.content else {}
    if init_resp.status_code != 200:
        raise RuntimeError(f"inbox_init_http_failed http={init_resp.status_code} payload={init_data}")

    _raise_api_error_if_any(init_data, context="inbox_init")

    data = init_data.get("data", {}) or {}
    upload_url = data.get("upload_url")
    if not upload_url:
        raise RuntimeError(f"inbox_init_missing_upload_url payload={init_data}")

    # PUT file (single chunk) with Content-Range
    end_byte = file_size - 1
    put_headers = {
        "Content-Type": "video/mp4",
        "Content-Range": f"bytes 0-{end_byte}/{file_size}",
    }

    with open(file_path, "rb") as f:
        put_resp = _SESSION.put(upload_url, headers=put_headers, data=f, timeout=300)

    if put_resp.status_code >= 400:
        raise RuntimeError(f"inbox_put_failed http={put_resp.status_code} body={put_resp.text[:1200]}")

    return init_data


# ============================
# MODE 2A: DIRECT POST - PULL_FROM_URL
# ============================
def _publish_video_from_url(
    *,
    access_token: str,
    video_url: str,
    caption: str,
    privacy_level: str,
    disable_comment: bool,
    disable_duet: bool,
    disable_stitch: bool,
    brand_content_toggle: bool,
    brand_organic_toggle: bool,
    is_aigc: bool = True,
    video_cover_timestamp_ms: Optional[int] = None,
) -> Dict[str, Any]:
    if not video_url or not isinstance(video_url, str):
        raise ValueError("video_url is required for PULL_FROM_URL")

    headers = _api_headers(access_token)

    post_info: Dict[str, Any] = {
        "privacy_level": privacy_level,
        "title": caption or "",
        "disable_comment": bool(disable_comment),
        "disable_duet": bool(disable_duet),
        "disable_stitch": bool(disable_stitch),
        "brand_content_toggle": bool(brand_content_toggle),
        "brand_organic_toggle": bool(brand_organic_toggle),
        "is_aigc": bool(is_aigc),
    }
    if video_cover_timestamp_ms is not None:
        post_info["video_cover_timestamp_ms"] = int(video_cover_timestamp_ms)

    body = {
        "source_info": {
            "source": "PULL_FROM_URL",
            "video_url": video_url,
        },
        "post_info": post_info,
    }

    init_resp = _SESSION.post(DIRECT_POST_INIT_URL, headers=headers, json=body, timeout=60)
    init_data = init_resp.json() if init_resp.content else {}
    if init_resp.status_code != 200:
        raise RuntimeError(f"direct_init_http_failed http={init_resp.status_code} payload={init_data}")

    _raise_api_error_if_any(init_data, context="direct_init_pull_from_url")
    return init_data


# ============================
# MODE 2B: DIRECT POST - FILE_UPLOAD
# ============================
def _upload_and_publish_video_file_upload(
    *,
    file_path: str,
    access_token: str,
    caption: str,
    privacy_level: str,
    disable_comment: bool,
    disable_duet: bool,
    disable_stitch: bool,
    brand_content_toggle: bool,
    brand_organic_toggle: bool,
    is_aigc: bool = True,
    video_cover_timestamp_ms: Optional[int] = None,
) -> Dict[str, Any]:
    file_size = os.path.getsize(file_path)
    headers = _api_headers(access_token)

    post_info: Dict[str, Any] = {
        "privacy_level": privacy_level,
        "title": caption or "",
        "disable_comment": bool(disable_comment),
        "disable_duet": bool(disable_duet),
        "disable_stitch": bool(disable_stitch),
        "brand_content_toggle": bool(brand_content_toggle),
        "brand_organic_toggle": bool(brand_organic_toggle),
        "is_aigc": bool(is_aigc),
    }
    if video_cover_timestamp_ms is not None:
        post_info["video_cover_timestamp_ms"] = int(video_cover_timestamp_ms)

    body = {
        "source_info": {
            "source": "FILE_UPLOAD",
            "video_size": file_size,
            "chunk_size": file_size,
            "total_chunk_count": 1,
        },
        "post_info": post_info,
    }

    init_resp = _SESSION.post(DIRECT_POST_INIT_URL, headers=headers, json=body, timeout=60)
    init_data = init_resp.json() if init_resp.content else {}
    if init_resp.status_code != 200:
        raise RuntimeError(f"direct_init_http_failed http={init_resp.status_code} payload={init_data}")

    _raise_api_error_if_any(init_data, context="direct_init_file_upload")

    data = init_data.get("data", {}) or {}
    upload_url = data.get("upload_url")
    if not upload_url:
        raise RuntimeError(f"direct_init_missing_upload_url payload={init_data}")

    # PUT file (single chunk)
    end_byte = file_size - 1
    put_headers = {
        "Content-Type": "video/mp4",
        "Content-Range": f"bytes 0-{end_byte}/{file_size}",
    }

    with open(file_path, "rb") as f:
        put_resp = _SESSION.put(upload_url, headers=put_headers, data=f, timeout=300)

    if put_resp.status_code >= 400:
        raise RuntimeError(f"direct_put_failed http={put_resp.status_code} body={put_resp.text[:1200]}")

    return init_data


# ----------------------------
# Public wrappers (stable API for Flask)
# ----------------------------
def upload_video_draft(file_path: str, access_token: str) -> Dict[str, Any]:
    return _upload_video_as_draft(file_path=file_path, access_token=access_token)


PublishMode = Literal["PULL_FROM_URL", "FILE_UPLOAD"]


def upload_video_direct_post(
    *,
    access_token: str,
    caption: str,
    privacy_level: str,
    disable_comment: bool,
    disable_duet: bool,
    disable_stitch: bool,
    brand_content_toggle: bool,
    brand_organic_toggle: bool,
    is_aigc: bool = True,
    video_cover_timestamp_ms: Optional[int] = None,
    mode: PublishMode = "PULL_FROM_URL",
    # Required for PULL_FROM_URL:
    video_url: Optional[str] = None,
    # Required for FILE_UPLOAD:
    file_path: Optional[str] = None,
) -> Dict[str, Any]:
    """
    Unified entrypoint:
      - mode="PULL_FROM_URL": requiere video_url (recomendado si el MP4 ya existe en tu servidor)
      - mode="FILE_UPLOAD": requiere file_path (fallback/tests)
    """
    if mode == "PULL_FROM_URL":
        if not video_url:
            raise ValueError("video_url is required when mode='PULL_FROM_URL'")
        return _publish_video_from_url(
            access_token=access_token,
            video_url=video_url,
            caption=caption,
            privacy_level=privacy_level,
            disable_comment=disable_comment,
            disable_duet=disable_duet,
            disable_stitch=disable_stitch,
            brand_content_toggle=brand_content_toggle,
            brand_organic_toggle=brand_organic_toggle,
            is_aigc=is_aigc,
            video_cover_timestamp_ms=video_cover_timestamp_ms,
        )

    if mode == "FILE_UPLOAD":
        if not file_path:
            raise ValueError("file_path is required when mode='FILE_UPLOAD'")
        return _upload_and_publish_video_file_upload(
            file_path=file_path,
            access_token=access_token,
            caption=caption,
            privacy_level=privacy_level,
            disable_comment=disable_comment,
            disable_duet=disable_duet,
            disable_stitch=disable_stitch,
            brand_content_toggle=brand_content_toggle,
            brand_organic_toggle=brand_organic_toggle,
            is_aigc=is_aigc,
            video_cover_timestamp_ms=video_cover_timestamp_ms,
        )

    raise ValueError(f"Unsupported mode: {mode}")
