"""Facade over the TikTok APIs (Login Kit, Display API, Content Posting API): plain `requests`
calls, no Flask, no DB.

Every function raises TikTokAPIError with a client-safe message when TikTok fails; the raw
error payload is logged server-side and never returned to a client.
"""
import logging
import math
import os
import time

import requests

log = logging.getLogger(__name__)

AUTHORIZE_URL = "https://www.tiktok.com/v2/auth/authorize/"
TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/"
USER_INFO_URL = "https://open.tiktokapis.com/v2/user/info/"
VIDEO_LIST_URL = "https://open.tiktokapis.com/v2/video/list/"
PUBLISH_INIT_URL = "https://open.tiktokapis.com/v2/post/publish/video/init/"
INBOX_INIT_URL = "https://open.tiktokapis.com/v2/post/publish/inbox/video/init/"
PUBLISH_STATUS_URL = "https://open.tiktokapis.com/v2/post/publish/status/fetch/"

DEFAULT_TIMEOUT_SECONDS = 30
UPLOAD_TIMEOUT_SECONDS = 300
CHUNK_SIZE = 32 * 1024 * 1024

USER_INFO_FIELDS = (
    "open_id,union_id,avatar_url,display_name,follower_count,"
    "following_count,likes_count,video_count"
)
VIDEO_FIELDS = (
    "id,title,video_description,duration,cover_image_url,share_url,create_time,"
    "view_count,like_count,comment_count,share_count"
)

PRIVACY_LEVELS = ("PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "SELF_ONLY")
POST_MODES = ("draft", "direct")
FINAL_PUBLISH_STATUSES = ("PUBLISH_COMPLETE", "SEND_TO_USER_INBOX", "FAILED")
VIDEO_PAGE_MAX = 20


class TikTokAPIError(Exception):
    """TikTok API failure; `message` is safe to show to a client, the details live in the log."""

    def __init__(self, status, message, code=None):
        super().__init__(message)
        self.status = status
        self.message = message
        self.code = code


def _safe_message(status, code):
    if code in ("access_token_invalid", "access_token_expired", "invalid_access_token"):
        return "TikTok rejected the access token. Reconnect the account."
    if code == "scope_not_authorized":
        return "TikTok refused the request: a permission (scope) is missing. Reconnect the account and grant all requested permissions."
    if status == 401:
        return "TikTok rejected the access token. Reconnect the account."
    if status == 403:
        return "TikTok refused the request: the app is not allowed to do this (unaudited apps can only post SELF_ONLY videos)."
    if status == 404:
        return "The requested TikTok object does not exist."
    if status == 429 or code in ("rate_limit_exceeded", "too_many_requests"):
        return "TikTok API rate limit or daily posting cap reached; try again later."
    return "TikTok API request failed."


def _check(payload, status):
    """TikTok returns HTTP 200 with an error body for many failures; both are checked."""
    if not isinstance(payload, dict):
        log.error("tiktok.api non-dict response status=%s", status)
        raise TikTokAPIError(status, "TikTok API request failed.")
    error = payload.get("error") or {}
    code = error.get("code") if isinstance(error, dict) else None
    if code and code != "ok":
        log.error("tiktok.api error status=%s code=%s message=%s", status, code, error.get("message"))
        raise TikTokAPIError(status, _safe_message(status, code), code)
    if status >= 400:
        log.error("tiktok.api http error status=%s body=%s", status, payload)
        raise TikTokAPIError(status, _safe_message(status, code), code)
    return payload


def _request(method, url, timeout=DEFAULT_TIMEOUT_SECONDS, **kwargs):
    response = requests.request(method, url, timeout=timeout, **kwargs)
    try:
        payload = response.json()
    except ValueError:
        log.error("tiktok.api non-json response status=%s", response.status_code)
        raise TikTokAPIError(response.status_code, "TikTok API request failed.") from None
    return _check(payload, response.status_code)


def _bearer(token):
    return {"Authorization": f"Bearer {token}"}


def exchange_code(code, client_key, client_secret, redirect_uri, code_verifier=None):
    """Exchange the OAuth code (plus the PKCE verifier of its flow) for access + refresh tokens, open_id and granted scopes."""
    data = {
        "client_key": client_key,
        "client_secret": client_secret,
        "code": code,
        "grant_type": "authorization_code",
        "redirect_uri": redirect_uri,
    }
    if code_verifier:
        data["code_verifier"] = code_verifier
    return _request("POST", TOKEN_URL, data=data)


def refresh_token(refresh_token_value, client_key, client_secret):
    """Exchange a refresh token for new tokens; TikTok rotates the refresh token on every use."""
    return _request("POST", TOKEN_URL, data={
        "client_key": client_key,
        "client_secret": client_secret,
        "grant_type": "refresh_token",
        "refresh_token": refresh_token_value,
    })


def get_user_info(token):
    """The connected account's profile and stats."""
    payload = _request("GET", USER_INFO_URL, headers=_bearer(token), params={"fields": USER_INFO_FIELDS})
    return payload.get("data", {}).get("user", {}) or {}


def list_videos(token, max_count=20, cursor=0):
    """Published videos with their stats; returns {videos, cursor, has_more}."""
    body = {"max_count": max(1, min(int(max_count), VIDEO_PAGE_MAX))}
    if cursor:
        body["cursor"] = cursor
    payload = _request(
        "POST", VIDEO_LIST_URL, params={"fields": VIDEO_FIELDS},
        headers={**_bearer(token), "Content-Type": "application/json"}, json=body,
    )
    data = payload.get("data", {}) or {}
    return {
        "videos": data.get("videos", []) or [],
        "cursor": data.get("cursor", cursor),
        "has_more": bool(data.get("has_more")),
    }


def _init_body(source_info, title, privacy_level, mode):
    """The init URL and body: a draft goes to the inbox endpoint with source_info only (the creator
    writes the caption in the TikTok app); a direct post carries post_info."""
    body = {"source_info": source_info}
    if mode != "direct":
        return INBOX_INIT_URL, body
    post_info = {"privacy_level": privacy_level or "SELF_ONLY"}
    if title:
        post_info["title"] = title
    body["post_info"] = post_info
    return PUBLISH_INIT_URL, body


def init_video_upload(token, video_size, chunk_size, total_chunk_count, title=None, privacy_level=None, mode="draft"):
    """Initialize a FILE_UPLOAD publish (inbox draft or direct post); returns {publish_id, upload_url}."""
    url, body = _init_body(
        {"source": "FILE_UPLOAD", "video_size": video_size, "chunk_size": chunk_size, "total_chunk_count": total_chunk_count},
        title, privacy_level, mode,
    )
    payload = _request("POST", url, headers={**_bearer(token), "Content-Type": "application/json"}, json=body)
    data = payload.get("data", {}) or {}
    if not data.get("publish_id") or not data.get("upload_url"):
        raise TikTokAPIError(200, "TikTok API request failed.")
    return {"publish_id": data["publish_id"], "upload_url": data["upload_url"]}


def init_video_pull(token, video_url, title=None, privacy_level=None, mode="draft"):
    """Initialize a PULL_FROM_URL publish (inbox draft or direct post); returns {publish_id}."""
    url, body = _init_body({"source": "PULL_FROM_URL", "video_url": video_url}, title, privacy_level, mode)
    payload = _request("POST", url, headers={**_bearer(token), "Content-Type": "application/json"}, json=body)
    data = payload.get("data", {}) or {}
    if not data.get("publish_id"):
        raise TikTokAPIError(200, "TikTok API request failed.")
    return {"publish_id": data["publish_id"]}


def upload_chunk(upload_url, chunk, start, end, total):
    """PUT one chunk to the upload URL with its Content-Range."""
    response = requests.put(
        upload_url,
        data=chunk,
        headers={
            "Content-Range": f"bytes {start}-{end}/{total}",
            "Content-Type": "video/mp4",
        },
        timeout=UPLOAD_TIMEOUT_SECONDS,
    )
    if response.status_code >= 400:
        log.error("tiktok.upload chunk failed status=%s", response.status_code)
        raise TikTokAPIError(response.status_code, "TikTok rejected the video upload.")
    return response


def fetch_publish_status(token, publish_id):
    """The processing status of a publish: PROCESSING_*, PUBLISH_COMPLETE or FAILED."""
    payload = _request("POST", PUBLISH_STATUS_URL, headers={**_bearer(token), "Content-Type": "application/json"}, json={
        "publish_id": publish_id,
    })
    data = payload.get("data", {}) or {}
    return {
        "publish_status": data.get("publish_status", "UNKNOWN"),
        "video_id": data.get("publicly_available_post_id"),
        "fail_reason": (data.get("fail_reason") or {}).get("code") if isinstance(data.get("fail_reason"), dict) else None,
    }


def publish_video_file(token, file_path, title=None, privacy_level=None, mode="draft", on_progress=None):
    """Upload a local video file to TikTok (init + chunked PUT); returns {publish_id}.
    The video then processes asynchronously — poll with wait_for_publish / fetch_publish_status."""
    size = os.path.getsize(file_path)
    if size <= 0:
        raise TikTokAPIError(400, "The video file is empty.")
    chunk_size = min(CHUNK_SIZE, size)
    total_chunks = max(1, math.ceil(size / chunk_size))
    init = init_video_upload(
        token, video_size=size, chunk_size=chunk_size, total_chunk_count=total_chunks,
        title=title, privacy_level=privacy_level, mode=mode,
    )
    with open(file_path, "rb") as f:
        for index in range(total_chunks):
            chunk = f.read(chunk_size)
            start = index * chunk_size
            upload_chunk(init["upload_url"], chunk, start, start + len(chunk) - 1, size)
            if on_progress:
                on_progress(min(1.0, (start + len(chunk)) / size))
    return {"publish_id": init["publish_id"]}


def wait_for_publish(token, publish_id, timeout_seconds=90, poll_seconds=5, sleep=time.sleep):
    """Poll the publish status until it completes, fails or the timeout elapses; returns the last status."""
    deadline = time.monotonic() + timeout_seconds
    status = {"publish_status": "PROCESSING", "video_id": None, "fail_reason": None}
    while True:
        status = fetch_publish_status(token, publish_id)
        if status["publish_status"] in FINAL_PUBLISH_STATUSES:
            return status
        if time.monotonic() >= deadline:
            return status
        sleep(poll_seconds)
