from __future__ import annotations

import asyncio
import hashlib
import json
import os
import re
import secrets
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict
from urllib.parse import urlparse, parse_qs



from g4f.image import to_bytes, detect_file_type

try:
    import curl_cffi
    has_curl_cffi = True
except ImportError:
    has_curl_cffi = False

try:
    import zendriver as nodriver
    from zendriver import cdp
    has_nodriver = True
except ImportError:
    has_nodriver = False

from ...typing import AsyncResult, Messages, MediaListType
from ...requests import get_args_from_nodriver, raise_for_status, merge_cookies
from ...requests import StreamSession
from ...cookies import get_cookies_dir
from ...tools.files import secure_filename
from ...errors import ModelNotFoundError, CloudflareError, MissingAuthError, MissingRequirementsError, \
    RateLimitError
from ...providers.response import FinishReason, Usage, JsonConversation, ImageResponse, Reasoning, PlainTextResponse, \
    JsonRequest
from ..base_provider import AsyncGeneratorProvider, ProviderModelMixin, AuthFileMixin
from ..helper import get_last_user_message
from ... import debug


def uuid7():
    """
    Generate a UUIDv7 using Unix epoch (milliseconds since 1970-01-01)
    matching the browser's implementation.
    """
    timestamp_ms = int(time.time() * 1000)
    rand_a = secrets.randbits(12)
    rand_b = secrets.randbits(62)

    uuid_int = timestamp_ms << 80
    uuid_int |= (0x7000 | rand_a) << 64
    uuid_int |= (0x8000000000000000 | rand_b)

    hex_str = f"{uuid_int:032x}"
    return f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"


# Global variables to manage Image Cache
ImagesCache: Dict[str, dict[str, str]] = {}


def check_link_expiry(url):
    # Parse the URL and its query parameters
    parsed_url = urlparse(url)
    params = parse_qs(parsed_url.query)
    amz_date_str = params.get("X-Amz-Date", [None])[0]
    expires_delta = params.get("X-Amz-Expires", [None])[0]
    if not amz_date_str or not expires_delta:
        return False
    creation_time = datetime.strptime(amz_date_str, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
    expiry_time = creation_time.timestamp() + int(expires_delta)
    current_time = datetime.now(timezone.utc).timestamp()
    return current_time <= expiry_time


if has_nodriver:
    async def click_trunstile(page: nodriver.Tab, element='document.getElementById("cf-turnstile")'):
        for _ in range(3):
            size = None
            for idx in range(15):
                size = await page.js_dumps(f'{element}?.getBoundingClientRect()||{{}}')
                debug.log(f"Found size: {size.get('x'), size.get('y')}")
                if "x" not in size:
                    break
                await page.flash_point(size.get("x") + idx * 3, size.get("y") + idx * 3)
                await page.mouse_click(size.get("x") + idx * 3, size.get("y") + idx * 3)
                await asyncio.sleep(2)
            if "x" not in size:
                break
        debug.log("Finished clicking trunstile.")


class LMArena(AsyncGeneratorProvider, ProviderModelMixin, AuthFileMixin):
    label = "LMArena"
    url = "https://arena.ai"
    share_url = None
    create_evaluation = "https://arena.ai/nextjs-api/stream/create-evaluation"
    post_to_evaluation = "https://arena.ai/nextjs-api/stream/post-to-evaluation/{id}"
    models_url = "https://arena.ai/?mode=direct"
    working = True
    active_by_default = True
    use_stream_timeout = False

    _models_loaded = False
    image_cache = True
    _next_actions = {
        "generateUploadUrl": "7012303914af71fce235a732cde90253f7e2986f2b",
        "getSignedUrl": "605373b76a30947cc26be49fc7b00c885910e21559",
        "updateTouConsent": "40efff1040868c07750a939a0d8120025f246dfe28",
        "createPointwiseFeedback": "605a0e3881424854b913fe1d76d222e50731b6037b",
        "createPairwiseFeedback": "600777eb84863d7e79d85d214130d3214fc744c80f",
        "getProxyImage": "60049198d4936e6b7acc63719b63b89284c58683e6",
        "deleteEvaluationSession": "6009c985d7e84eae2ec94547453ba388005b22e2a5",
        "getEmailProvider": "607c2dd3d84af5a00b322b577498d1b2a739c5dfe0",
        "deleteAccount": "40a57e8c369eaf8a82483fae2f8106489ce041dffd",
    }

    @classmethod
    def load_models(cls, models_data: str):
        cls.text_models = {model["publicName"]: model["id"] for model in models_data if
                            "text" in model["capabilities"]["outputCapabilities"]}
        cls.image_models = {model["publicName"]: model["id"] for model in models_data if
                                "image" in model["capabilities"]["outputCapabilities"]}
        cls.video_models = {model["publicName"]: model["id"] for model in models_data if
                                "video" in model["capabilities"]["outputCapabilities"]}
        cls.vision_models = [model["publicName"] for model in models_data if
                                "image" in model["capabilities"]["inputCapabilities"]]
        cls.models = list(cls.text_models) + list(cls.image_models)
        cls.default_model = list(cls.text_models.keys())[0]
        cls._models_loaded = True

    @classmethod
    def load_models_from_cache(cls):
        models_path = Path(get_cookies_dir()) / ".models" / f"{secure_filename(cls.models_url)}.json"
        if models_path.exists():
            try:
                data = models_path.read_text()
                models_data = json.loads(data)
                for key, value in models_data.items():
                    setattr(cls, key, value)
                cls._models_loaded = True
                debug.log(f"Loaded models from cache: {cls.models}")
            except Exception as e:
                debug.error(f"Failed to load cached models from {models_path}: {e}")

    @classmethod
    def get_models(cls, timeout: int = None, **kwargs) -> list[str]:
        if not cls._models_loaded and has_curl_cffi:
            # Try to load models from cache
            args = cls.read_args()
            if not args:
                cls.load_models_from_cache()
            if cls._models_loaded:
                return cls.models
            response = curl_cffi.get(cls.models_url, **args, timeout=timeout)
            if response.ok:
                for line in response.text.splitlines():
                    if "initialModels" not in line:
                        continue
                    line = line.split("initialModels", maxsplit=1)[-1].split("initialModelAId")[0][3:-3]
                    line = line.encode("utf-8").decode("unicode_escape")
                    models = json.loads(line)
                    cls.load_models(models)
                    cls.live += 1
                    break
                try:
                    models_path = Path(get_cookies_dir()) / ".models" / f"{secure_filename(cls.models_url)}.json"
                    models_path.parent.mkdir(parents=True, exist_ok=True)
                    with open(models_path, "w") as f:
                        json.dump({
                            "text_models": cls.text_models,
                            "image_models": cls.image_models,
                            "video_models": cls.video_models,
                            "vision_models": cls.vision_models,
                            "models": cls.models,
                            "default_model": cls.default_model
                        }, f, indent=4)
                except Exception as e:
                    debug.error(f"Failed to cache models to {models_path}: {e}")
            else:
                cls.live -= 1
                cls.load_models_from_cache()
                debug.log(f"Failed to load models from {cls.url}: {response.status_code} {response.reason}")
        return cls.models

    @classmethod
    async def get_models_async(cls) -> list[str]:
        if not cls._models_loaded:
            async with StreamSession() as session:
                async with session.get(cls.models_url) as response:
                    await cls.__load_actions(await response.text())
        return cls.models

    @classmethod
    async def get_args_from_nodriver(cls, proxy, clear_cookies=False):
        cache_file = cls.get_cache_file()
        grecaptcha = []

        async def callback(page: nodriver.Tab):
            try:
                button = await page.find("Accept Cookies")
            except TimeoutError:
                button = None
            if button:
                await button.click()
            else:
                debug.log("No 'Accept Cookies' button found, skipping.")
            await asyncio.sleep(1)
            try:
                textarea = await page.select('textarea[name="message"]')
            except TimeoutError:
                textarea = None
            if textarea:
                await textarea.send_keys("Hello")
            # await asyncio.sleep(1)
            # button = await page.select('button[type="submit"]')
            # if button:
            #     await button.click()
            # button = await page.find("Agree")
            # if button:
            #     await button.click()
            # else:
            #     debug.log("No 'Agree' button found, skipping.")
            # await asyncio.sleep(1)
            # try:
            #     element = await page.select('[style="display: grid;"]')
            # except TimeoutError:
            #     element = None
            # if element:
            #     await click_trunstile(page, 'document.querySelector(\'[style="display: grid;"]\')')
            while not await page.evaluate('document.cookie.indexOf("arena-auth-prod-v1") >= 0'):
                debug.log("No authentication cookie found, waiting for authenticate.")
                #await page.select('#cf-turnstile', 300)
                #debug.log("Found Element: 'cf-turnstile'")
                await asyncio.sleep(3)
                #await click_trunstile(page)
            while not await page.evaluate('document.cookie.indexOf("arena-auth-prod-v1") >= 0'):
                await asyncio.sleep(1)
            while not await page.evaluate('!!document.querySelector(\'textarea\')'):
                await asyncio.sleep(1)
            while not await page.evaluate('window.grecaptcha && window.grecaptcha.enterprise'):
                await asyncio.sleep(1)
            captcha = await page.evaluate(
                """window.grecaptcha.enterprise.execute('6Led_uYrAAAAAKjxDIF58fgFtX3t8loNAK85bW9I',  { action: 'chat_submit' }  );""",
                await_promise=True)
            grecaptcha.append(captcha)
            debug.log("Obtained grecaptcha token.")
            html = await page.get_content()
            await cls.__load_actions(html)

        args = await get_args_from_nodriver(cls.url, proxy=proxy, callback=callback,
                                            clear_cookies_except=["cf_clearance", "app_banner_state"] if clear_cookies else None)

        with cache_file.open("w") as f:
            json.dump(args, f)

        return args, next(iter(grecaptcha))

    @classmethod
    async def get_grecaptcha(cls, args, proxy):
        cache_file = cls.get_cache_file()
        grecaptcha = []

        async def callback(page: nodriver.Tab):
            while not await page.evaluate('window.grecaptcha && window.grecaptcha.enterprise'):
                await asyncio.sleep(1)
            captcha = await page.evaluate(
                """new Promise((resolve) => {
                    window.grecaptcha.enterprise.ready(async () => {
                        try {
                            const token = await window.grecaptcha.enterprise.execute(
                                '6Led_uYrAAAAAKjxDIF58fgFtX3t8loNAK85bW9I',
                                { action: 'chat_submit' }
                            );
                            resolve(token);
                        } catch (e) {
                            console.error("[LMArena API] reCAPTCHA execute failed:", e);
                            resolve(null);
                        }
                    });
                });""",
                await_promise=True
            )
            if isinstance(captcha, str):
                grecaptcha.append(captcha)
            else:
                raise Exception(captcha)
            html = await page.get_content()
            await cls.__load_actions(html)

        args = await get_args_from_nodriver(
            cls.url, proxy=proxy, callback=callback
        )

        with cache_file.open("w") as f:
            json.dump(args, f)

        return args, next(iter(grecaptcha))

    @classmethod
    async def __load_actions(cls, html):
        def pars_children(data):
            data = data["children"]
            if len(data) < 4:
                return
            if data[1] in ["div", "defs", "style", "script"]:
                return
            if data[0] == "$":
                pars_data(data[3])
            else:
                for child in data:
                    if isinstance(child, list) and len(data) >= 4:
                        pars_data(child[3])

        def pars_data(data):
            if not isinstance(data, (list, dict)):
                return
            if isinstance(data, dict):
                json_data = data
            elif data[0] == "$":
                if data[1] in ["div", "defs", "style", "script"]:
                    return
                json_data = data[3]
            else:
                return
            if not json_data:
                return
            if 'userState' in json_data:
                debug.log(json_data)
            elif 'initialModels' in json_data:
                models = json_data["initialModels"]
                cls.load_models(models)
            elif 'children' in json_data:
                pars_children(json_data)

        line_pattern = re.compile("^([0-9a-fA-F]+):(.*)")
        pattern = r'self\.__next_f\.push\((\[[\s\S]*?\])\)(?=<\/script>)'
        matches = re.findall(pattern, html)
        for match in matches:
            # Parse the JSON array
            data = json.loads(match)
            for chunk in data[1].split("\n"):
                match = line_pattern.match(chunk)
                if not match:
                    continue
                chunk_id, chunk_data = match.groups()
                if chunk_data.startswith("I["):
                    data = json.loads(chunk_data[1:])
                    async with StreamSession() as session:
                        if "Evaluation" == data[2]:
                            js_files = dict(zip(data[1][::2], data[1][1::2]))
                            for js_id, js in list(js_files.items())[::-1]:
                                js_url = f"{cls.url}/_next/{js}"
                                async with session.get(js_url) as js_response:
                                    js_text = await js_response.text()
                                    if "createServerReference" in js_text:
                                        cls.__extract_actions(js_text)

                elif chunk_data.startswith(("[", "{")):
                    try:
                        data = json.loads(chunk_data)
                        pars_data(data)
                    except json.decoder.JSONDecodeError:
                        ...

    @classmethod
    def __extract_actions(cls, js_text):
        # updateTouConsent, createPointwiseFeedback, createPairwiseFeedback, generateUploadUrl, getSignedUrl, getProxyImage
        start_id = re.findall(r'\("([a-f0-9]{40,})".*?"(\w+)"\)', js_text)
        for v, k in start_id:
            if len(v) == 42:
                cls._next_actions[k] = v
                debug.log(f"{k}: {v}")
            else:
                debug.error(f"wrong {k} value: {v}")

    @classmethod
    async def prepare_images(cls, args, media: list[tuple]) -> list[dict[str, str]]:
        files = []
        if not media:
            return files
        async with StreamSession(**args, ) as session:
            for index, (_file, file_name) in enumerate(media):
                data_bytes = to_bytes(_file)
                # Check Cache
                hasher = hashlib.md5()
                hasher.update(data_bytes)
                image_hash = hasher.hexdigest()
                file = ImagesCache.get(image_hash)
                if cls.image_cache and file:
                    if check_link_expiry(file.get("url")):
                        debug.log("Using cached image")
                        files.append(file)
                        continue
                    debug.log("Expiry cached image")

                extension, file_type = detect_file_type(data_bytes)
                file_name = file_name or f"file-{len(data_bytes)}{extension}"
                async with session.post(
                        url=cls.url,
                        json=[file_name, file_type],
                        headers={
                            "accept": "text/x-component",
                            "content-type": "text/plain;charset=UTF-8",
                            "next-action": cls._next_actions["generateUploadUrl"],
                            "referer": cls.url
                        }
                ) as response:
                    await raise_for_status(response)
                    text = await response.text()
                    line = next(filter(lambda x: x.startswith("1:"), text.split("\n")), "")
                    if not line:
                        raise Exception("Failed to get upload URL")
                    chunk = json.loads(line[2:])
                    if not chunk.get("success"):
                        raise Exception("Failed to get upload URL")
                    uploadUrl = chunk.get("data", {}).get("uploadUrl")
                    key = chunk.get("data", {}).get("key")
                    if not uploadUrl:
                        raise Exception("Failed to get upload URL")

                async with session.put(
                    url=uploadUrl,
                    headers={
                        "content-type": file_type,
                    },
                    data=data_bytes,
                ) as response:
                    await raise_for_status(response)
                async with session.post(
                        url=cls.url,
                        json=[key],
                        headers={
                            "accept": "text/x-component",
                            "content-type": "text/plain;charset=UTF-8",
                            "next-action": cls._next_actions["getSignedUrl"],
                            "referer": cls.url
                        }
                ) as response:
                    await raise_for_status(response)
                    text = await response.text()
                    line = next(filter(lambda x: x.startswith("1:"), text.split("\n")), "")
                    if not line:
                        raise Exception("Failed to get download URL")

                    chunk = json.loads(line[2:])
                    if not chunk.get("success"):
                        raise Exception("Failed to get download URL")
                    image_url = chunk.get("data", {}).get("url")
                    uploaded_file = {
                        "name": key,
                        "contentType": file_type,
                        "url": image_url
                    }
                debug.log(f"Uploaded image to: {image_url}")
                ImagesCache[image_hash] = uploaded_file
                files.append(uploaded_file)
        return files

    @classmethod
    def read_args(cls, args: dict = {}):
        cache_file = cls.get_cache_file()
        if not args and cache_file.exists():
            try:
                with cache_file.open("r") as f:
                    args = json.load(f)
            except json.JSONDecodeError:
                debug.log(f"Cache file {cache_file} is corrupted, removing it.")
                cache_file.unlink()
                args = None
        return args

    @classmethod
    async def get_quota(cls, **kwargs):
        args = cls.read_args()
        if not args:
            raise MissingAuthError("No authentication arguments found.")
        return {key: len(value) if value else 0 for key, value in args.items()}

    @classmethod
    async def create_async_generator(
        cls,
        model: str,
        messages: Messages,
        conversation: JsonConversation = None,
        media: MediaListType = None,
        proxy: str = None,
        timeout: int = None,
        **kwargs
    ) -> AsyncResult:
        prompt = get_last_user_message(messages)
        cache_file = cls.get_cache_file()
        args = cls.read_args(kwargs.get("lmarena_args", {}))
        grecaptcha = kwargs.pop("grecaptcha", "")
        _need_clear_cookies = False
        for _ in range(2):
            if args:
                pass
            elif has_nodriver:
                args, grecaptcha = await cls.get_args_from_nodriver(proxy, _need_clear_cookies)
            else:
                raise MissingRequirementsError("No auth file found and nodriver is not available.")

            if not cls._models_loaded:
                # change to async
                await cls.get_models_async()

            def get_mode_id(_model):
                model_id = None
                # if not model:
                #     model = cls.default_model
                if _model in cls.model_aliases:
                    _model = cls.model_aliases[_model]
                if _model in cls.text_models:
                    model_id = cls.text_models[_model]
                elif _model in cls.image_models:
                    model_id = cls.image_models[_model]
                elif _model in cls.video_models:
                    model_id = cls.video_models[_model]
                elif _model:
                    raise ModelNotFoundError(f"Model '{_model}' is not supported by LMArena provider.")
                return model_id

            modelA:str = model
            modelB:str = kwargs.get("modelB", "")
            modelAId = get_mode_id(modelA)
            modelBId = get_mode_id(modelB) if modelB else None
            if modelAId and modelBId:
                mode = "side-by-side"
            elif modelAId:
                mode = "direct"
            else:
                mode = "battle"
            if conversation and getattr(conversation, "evaluationSessionId", None):
                url = cls.post_to_evaluation.format(id=conversation.evaluationSessionId)
                evaluationSessionId = conversation.evaluationSessionId
            else:
                url = cls.create_evaluation
                evaluationSessionId = str(uuid7())
            is_image_model = modelA in cls.image_models
            userMessageId = str(uuid7())
            modelAMessageId = str(uuid7())
            modelBMessageId = str(uuid7())
            if not grecaptcha and has_nodriver:
                debug.log("No grecaptcha token found, obtaining new one...")
                args, grecaptcha = await cls.get_grecaptcha(args, proxy)
            files = await cls.prepare_images(args, media)
            data = {
                "id": evaluationSessionId,
                "mode": mode,
                "userMessageId": userMessageId,
                "modelAMessageId": modelAMessageId,
                "userMessage": {
                    "content": prompt,
                    "experimental_attachments": files,
                    "metadata": {}
                },
                "modality": "image" if is_image_model else "chat",
                "recaptchaV3Token": grecaptcha
            }
            if modelAId:
                data["modelAId"] = modelAId
            if modelBId:
                data["modelBId"] = modelBId
            if mode in ["side-by-side", "battle"]:
                data["modelBMessageId"] = modelBMessageId

            yield JsonRequest.from_dict(data)
            try:
                async with StreamSession(**args, timeout=timeout or 5 * 60) as session:
                    async with session.post(
                            url,
                            json=data,
                            proxy=proxy,
                    ) as response:
                        await raise_for_status(response)
                        args["cookies"] = merge_cookies(args["cookies"], response)
                        async for chunk in response.iter_lines():
                            line = chunk.decode()
                            yield PlainTextResponse(line)
                            if line.startswith("a0:"):
                                chunk = json.loads(line[3:])
                                if chunk == "hasArenaError":
                                    raise ModelNotFoundError("LMArena Beta encountered an error: hasArenaError")
                                yield chunk
                            elif line.startswith("b0:"):
                                ...
                            elif line.startswith("ag:"):
                                chunk = json.loads(line[3:])
                                yield Reasoning(chunk)
                            elif (line.startswith("a2:") or line.startswith("b2:")) and line == 'a2:[{"type":"heartbeat"}]':
                                # 'a2:[{"type":"heartbeat"}]'
                                continue
                            elif line.startswith("a2:"):
                                chunk = json.loads(line[3:])
                                __images = [image.get("image") for image in chunk if image.get("image")]
                                if __images:
                                    yield ImageResponse(__images, prompt, {"model": modelA})

                            elif line.startswith("b2:"):
                                chunk = json.loads(line[3:])
                                __images = [image.get("image") for image in chunk if image.get("image")]
                                if __images:
                                    yield ImageResponse(__images, prompt, {"model": modelB})

                            elif line.startswith("ad:"):
                                yield JsonConversation(evaluationSessionId=evaluationSessionId)
                                finish = json.loads(line[3:])
                                if "finishReason" in finish:
                                    yield FinishReason(finish["finishReason"])
                                if "usage" in finish:
                                    yield Usage(**finish["usage"])
                            elif line.startswith("bd:"):
                                ...
                            elif line.startswith("a3:"):
                                raise RuntimeError(f"LMArena: {json.loads(line[3:])}")
                            elif line.startswith("b3:"):
                                ...
                            else:
                                debug.log(f"LMArena: Unknown line prefix: {line[:2]}")
                break
            except (CloudflareError, MissingAuthError) as error:
                args = None
                debug.error(error)
                debug.log(f"{cls.__name__}: Cloudflare error")
                continue
            except RateLimitError as error:
                args = None
                _need_clear_cookies = True
                debug.error(error)
                continue
            except:
                raise
        if args:
            debug.log("Save args to cache file:", str(cache_file))
            with cache_file.open("w") as f:
                f.write(json.dumps(args))

def get_content_type(url: str) -> str:
    if url.endswith(".webp"):
        return "image/webp"
    elif url.endswith(".png"):
        return "image/png"
    elif url.endswith(".jpg") or url.endswith(".jpeg"):
        return "image/jpeg"
    else:
        return "application/octet-stream"
