from __future__ import annotations

import random
import re
import time
from typing import Any, Dict, Iterable, List, Mapping, MutableMapping, Optional, Sequence, Tuple, Union

import httpx

from .autopost import SotiAutopost
from .exceptions import SotiAPIError, SotiConnectionError, SotiValidationError
from .models import Bot, BotList

Snowflake = Union[str, int]


class Soti:
    _DEFAULT_SITE_BASE = "https://flarehub.pro"
    _DEFAULT_API_BASE = "https://api.flarehub.pro"
    _SNOWFLAKE_RE = re.compile(r"^\d{17,21}$")

    def __init__(
        self,
        token_or_base_url: Optional[str] = None,
        *,
        api_token: Optional[str] = None,
        bot_token: Optional[str] = None,
        base_url: Optional[str] = None,
        site_base_url: Optional[str] = None,
        api_base_url: Optional[str] = None,
        timeout: float = 10.0,
        retries: int = 2,
        retry_delay: float = 0.25,
        headers: Optional[Mapping[str, str]] = None,
        user_agent: Optional[str] = None,
        cookie: Optional[str] = None,
        transport: Optional[httpx.BaseTransport] = None,
    ):
        inferred_token: Optional[str] = None
        inferred_base: Optional[str] = None

        if token_or_base_url:
            raw = str(token_or_base_url).strip()
            if raw.startswith("http://") or raw.startswith("https://"):
                inferred_base = raw
            else:
                inferred_token = raw

        if api_token is None:
            api_token = inferred_token

        if base_url is None:
            base_url = inferred_base

        site_candidate = site_base_url or base_url or self._DEFAULT_SITE_BASE
        api_candidate = api_base_url or self._derive_api_base(site_candidate)

        self.site_base_url = self._normalize_base_url(site_candidate, field_name="site_base_url")
        self.api_base_url = self._normalize_base_url(api_candidate, field_name="api_base_url")
        self.base_url = self.site_base_url

        self.api_token = api_token.strip() if isinstance(api_token, str) and api_token.strip() else None
        self.bot_token = bot_token.strip() if isinstance(bot_token, str) and bot_token.strip() else None

        if timeout <= 0:
            raise SotiValidationError("timeout must be > 0", {"timeout": timeout})
        if retries < 0:
            raise SotiValidationError("retries must be >= 0", {"retries": retries})
        if retry_delay < 0:
            raise SotiValidationError("retry_delay must be >= 0", {"retry_delay": retry_delay})

        self.timeout = float(timeout)
        self.retries = int(retries)
        self.retry_delay = float(retry_delay)
        self.cookie = str(cookie).strip() if isinstance(cookie, str) else ""

        self._default_headers: Dict[str, str] = dict(headers or {})
        self.user_agent = user_agent.strip() if isinstance(user_agent, str) and user_agent.strip() else "sotipy/2.0.0"
        self._client = httpx.Client(timeout=self.timeout, transport=transport)

    def close(self) -> None:
        self._client.close()

    def __enter__(self) -> "Soti":
        return self

    def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
        self.close()

    def set_api_token(self, token: Optional[str]) -> "Soti":
        self.api_token = token.strip() if isinstance(token, str) and token.strip() else None
        return self

    def set_bot_token(self, token: Optional[str]) -> "Soti":
        self.bot_token = token.strip() if isinstance(token, str) and token.strip() else None
        return self

    def set_cookie(self, cookie: Optional[str]) -> "Soti":
        self.cookie = cookie.strip() if isinstance(cookie, str) else ""
        return self

    def set_site_base_url(self, site_base_url: str) -> "Soti":
        self.site_base_url = self._normalize_base_url(site_base_url, field_name="site_base_url")
        self.base_url = self.site_base_url
        return self

    def set_api_base_url(self, api_base_url: str) -> "Soti":
        self.api_base_url = self._normalize_base_url(api_base_url, field_name="api_base_url")
        return self

    def autopost(
        self,
        *,
        bot_id: Optional[Snowflake] = None,
        interval: Union[int, float, str] = "15m",
        platform: Optional[str] = None,
        source: Optional[str] = None,
        immediate: bool = True,
        loop: Optional[Any] = None,
        daemon: bool = True,
    ) -> SotiAutopost:
        normalized_bot_id = str(bot_id) if bot_id is not None else None
        return SotiAutopost(
            self,
            bot_id=normalized_bot_id,
            interval=interval,
            platform=platform,
            source=source,
            immediate=immediate,
            loop=loop,
            daemon=daemon,
        )

    def get_api_health(self) -> Any:
        return self._request_json("/api/v1/health", token_type="none")

    def get_approved_bots(
        self,
        category: Optional[Union[str, Sequence[str]]] = None,
        search: Optional[str] = None,
    ) -> BotList:
        params: Dict[str, Any] = {}
        if category is not None:
            if isinstance(category, (list, tuple, set)):
                params["category"] = [str(item).strip() for item in category if str(item).strip()]
            else:
                text = str(category).strip()
                if text:
                    params["category"] = text
        if search is not None:
            text = str(search).strip()
            if text:
                params["search"] = text

        data = self._request_json("/api/approved_bots", query=params, token_type="none")

        if not isinstance(data, list):
            raise SotiAPIError(
                "Unexpected payload for get_approved_bots",
                code="invalid_response",
                payload=data,
            )

        bots = [Bot.from_payload(item) for item in data if isinstance(item, Mapping)]
        return BotList(bots=bots, total=len(bots), raw={"items": data})

    def search_bots(self, query: str) -> BotList:
        return self.get_approved_bots(search=query)

    def get_new_bots(self) -> BotList:
        data = self._request_json("/api/new_bots", token_type="none")

        if not isinstance(data, list):
            raise SotiAPIError(
                "Unexpected payload for get_new_bots",
                code="invalid_response",
                payload=data,
            )

        bots = [Bot.from_payload(item) for item in data if isinstance(item, Mapping)]
        return BotList(bots=bots, total=len(bots), raw={"items": data})

    def get_bot(self, bot_id: Snowflake, *, strict: bool = False) -> Optional[Bot]:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")

        try:
            payload = self._request_json(f"/api/v1/bots/{bid}", route="api")
            bot = self._extract_bot(payload)
            if bot is not None:
                return bot
        except SotiAPIError as exc:
            if strict and not self._can_try_bot_fallback(exc):
                raise

        try:
            payload = self._request_json(f"/api/bot/{bid}", route="site", token_type="none")
            bot = self._extract_bot(payload)
            if bot is not None:
                return bot
        except SotiAPIError as exc:
            if strict:
                raise
            if not self._is_not_found_like(exc):
                raise

        return None

    def get_bot_stats(self, bot_id: Snowflake) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        return self._request_json(f"/api/v1/bots/{bid}/stats", route="api")

    def get_bot_stats_history(
        self,
        bot_id: Snowflake,
        *,
        limit: int = 100,
        offset: Optional[int] = None,
        token: Optional[str] = None,
        api_token: Optional[str] = None,
        bot_token: Optional[str] = None,
    ) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")

        if limit < 1:
            raise SotiValidationError("limit must be >= 1", {"limit": limit})
        if offset is not None and offset < 0:
            raise SotiValidationError("offset must be >= 0", {"offset": offset})

        query: Dict[str, Any] = {"limit": int(limit)}
        if offset is not None:
            query["offset"] = int(offset)

        candidates = self._unique_tokens([token, bot_token, self.bot_token, api_token, self.api_token])

        if not candidates:
            return self._request_json(
                f"/api/v1/bots/{bid}/stats/history",
                query=query,
                route="api",
                token_type="none",
            )

        last_auth_error: Optional[SotiAPIError] = None

        for candidate in candidates:
            try:
                return self._request_json(
                    f"/api/v1/bots/{bid}/stats/history",
                    query=query,
                    route="api",
                    token_type="none",
                    token=candidate,
                )
            except SotiAPIError as exc:
                if exc.status_code == 401:
                    last_auth_error = exc
                    continue
                raise

        if last_auth_error is not None:
            raise last_auth_error

        raise SotiAPIError("Unable to fetch bot stats history", code="request_failed")

    def post_bot_stats(self, bot_id: Snowflake, stats: Mapping[str, Any]) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        payload = self._normalize_stats(stats)

        headers: Dict[str, str] = {}
        idempotency_key = payload.get("idempotency_key")
        if isinstance(idempotency_key, str) and idempotency_key:
            headers["Idempotency-Key"] = idempotency_key

        return self._request_json(
            f"/api/v1/bots/{bid}/stats",
            method="POST",
            route="api",
            json_body=payload,
            headers=headers,
        )

    def send_bot_metrics(
        self,
        bot_id: Snowflake,
        *,
        servers: Optional[int] = None,
        users: Optional[int] = None,
        shards: Optional[int] = None,
        shard_id: Optional[int] = None,
        platform: Optional[str] = None,
        source: Optional[str] = None,
        extra: Optional[Mapping[str, Any]] = None,
        idempotency_key: Optional[str] = None,
    ) -> Any:
        payload: Dict[str, Any] = {}

        if servers is not None:
            payload["servers"] = servers
        if users is not None:
            payload["users"] = users
        if shards is not None:
            payload["shards"] = shards
        if shard_id is not None and "shards" not in payload:
            payload["shard_count"] = shard_id
        if platform is not None:
            payload["platform"] = platform
        if source is not None:
            payload["source"] = source
        if extra is not None:
            payload["extra"] = dict(extra)
        if idempotency_key is not None:
            payload["idempotency_key"] = str(idempotency_key)

        if not payload:
            raise SotiValidationError("At least one metric must be provided")

        return self.post_bot_stats(bot_id, payload)

    def get_session_info(self) -> Any:
        return self._request_json("/api/session", token_type="none")

    def get_user_token(self) -> Any:
        return self._request_json("/api/user/api-token")

    def regenerate_user_token(self) -> Any:
        return self._request_json("/api/user/api-token/regenerate", method="POST", json_body={})

    def get_my_bots(self) -> BotList:
        data = self._request_json("/api/my_bots")

        if isinstance(data, list):
            bots = [Bot.from_payload(item) for item in data if isinstance(item, Mapping)]
            return BotList(bots=bots, total=len(bots), raw={"items": data})

        raise SotiAPIError("Unexpected payload for get_my_bots", code="invalid_response", payload=data)

    def get_bot_token(self, bot_id: Snowflake) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        return self._request_json("/api/bot/token", query={"bot_id": bid})

    def regenerate_bot_token(self, bot_id: Snowflake) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        return self._request_json("/api/bot/token/regenerate", method="POST", json_body={"bot_id": bid})

    def get_reviews(self, bot_id: Snowflake) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        return self._request_json(f"/api/reviews/{bid}", token_type="none")

    def submit_review(self, bot_id: Snowflake, rating: int, comment: str = "") -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")

        if rating < 1 or rating > 5:
            raise SotiValidationError("rating must be between 1 and 5", {"rating": rating})

        review_text = str(comment or "").strip()
        if len(review_text) < 16:
            raise SotiValidationError("comment must be at least 16 characters", {"comment": review_text})

        return self._request_json(
            "/api/reviews",
            method="POST",
            json_body={"bot_id": bid, "rating": int(rating), "review_text": review_text},
        )

    def upvote_bot(
        self,
        bot_id: Snowflake,
        *,
        stiosp_token: Optional[str] = None,
        pow_counter: Optional[int] = None,
    ) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")

        payload: Dict[str, Any] = {"bot_id": bid}

        if stiosp_token is not None:
            payload["stiosp_token"] = str(stiosp_token)

        if pow_counter is not None:
            if int(pow_counter) < 0:
                raise SotiValidationError("pow_counter must be >= 0")
            payload["pow_counter"] = int(pow_counter)

        return self._request_json("/api/upvote", method="POST", json_body=payload)

    def get_upvote_cooldown(self, bot_id: Snowflake) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        return self._request_json("/api/upvote/cooldown", query={"bot_id": bid})

    def get_upvote_count(self, bot_id: Snowflake) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        return self._request_json("/api/upvote/count", query={"bot_id": bid}, token_type="none")

    def get_bot_upvotes(self, bot_id: Snowflake, limit: int = 100) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        return self._request_json(f"/api/v1/bots/{bid}/upvotes", query={"limit": int(limit)}, route="api")

    def get_bot_upvoters(self, bot_id: Snowflake) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        return self._request_json(f"/api/v1/bots/{bid}/upvoters", route="api")

    def check_user_upvoted(self, bot_id: Snowflake, user_id: Snowflake) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        uid = self._validate_snowflake(user_id, field_name="user_id")
        return self._request_json(f"/api/v1/bots/{bid}/upvotes/{uid}", route="api")

    def report_bot_issue(self, bot_id: Snowflake, issue_type: str, description: str) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        issue = str(issue_type or "").strip().lower()
        allowed = {"spam", "nsfw", "broken", "other"}
        if issue not in allowed:
            raise SotiValidationError("issue_type must be one of spam, nsfw, broken, other")
        return self._request_json(
            f"/api/v1/bots/{bid}/report",
            method="POST",
            route="api",
            json_body={"type": issue, "description": str(description or "")},
        )

    def update_bot_profile(self, bot_id: Snowflake, **updates: Any) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        return self._request_json(
            f"/api/v1/bots/{bid}",
            method="PUT",
            route="api",
            json_body=updates,
        )

    def delete_review(self, bot_id: Snowflake, review_id: Union[str, int]) -> Any:
        bid = self._validate_snowflake(bot_id, field_name="bot_id")
        review = str(review_id).strip()
        if not review:
            raise SotiValidationError("review_id is required")
        return self._request_json(
            f"/api/v1/bots/{bid}/reviews/{review}",
            method="DELETE",
            route="api",
        )

    def _request_json(
        self,
        path: str,
        *,
        method: str = "GET",
        query: Optional[Mapping[str, Any]] = None,
        json_body: Optional[Any] = None,
        token: Optional[str] = None,
        token_type: str = "api",
        route: Optional[str] = None,
        headers: Optional[Mapping[str, str]] = None,
    ) -> Any:
        if not path.startswith("/"):
            path = "/" + path

        base_url = self._select_base_url(path=path, route=route)
        url = f"{base_url}{path}"

        merged_headers = self._build_headers(token=token, token_type=token_type, headers=headers, has_json=json_body is not None)
        params = self._normalize_query(query)

        last_error: Optional[Exception] = None

        for attempt in range(self.retries + 1):
            try:
                response = self._client.request(
                    method.upper(),
                    url,
                    params=params,
                    json=json_body,
                    headers=merged_headers,
                )
            except httpx.RequestError as exc:
                connection_error = SotiConnectionError("Failed to connect to Soti API", cause=exc)
                if attempt >= self.retries or not self._is_retryable_exception(exc):
                    raise connection_error from exc
                last_error = connection_error
                time.sleep(self._retry_delay(attempt))
                continue

            payload = self._parse_payload(response)

            if response.status_code >= 400:
                api_error = self._build_api_error(payload=payload, response=response)
                if attempt >= self.retries or not self._is_retryable_status(response.status_code):
                    raise api_error
                last_error = api_error
                time.sleep(self._retry_delay(attempt))
                continue

            return self._normalize_payload(payload=payload, response=response)

        if last_error:
            raise last_error

        raise SotiConnectionError("Request failed")

    def _build_headers(
        self,
        *,
        token: Optional[str],
        token_type: str,
        headers: Optional[Mapping[str, str]],
        has_json: bool,
    ) -> Dict[str, str]:
        merged: Dict[str, str] = dict(self._default_headers)
        if headers:
            merged.update({str(k): str(v) for k, v in headers.items()})

        if not any(k.lower() == "user-agent" for k in merged):
            merged["User-Agent"] = self.user_agent

        if not any(k.lower() == "accept" for k in merged):
            merged["Accept"] = "application/json"

        if has_json and not any(k.lower() == "content-type" for k in merged):
            merged["Content-Type"] = "application/json"

        if self.cookie and not any(k.lower() == "cookie" for k in merged):
            merged["Cookie"] = self.cookie

        selected_token = token.strip() if isinstance(token, str) and token.strip() else self._select_token(token_type)

        if token_type != "none" and selected_token and not any(k.lower() == "authorization" for k in merged):
            merged["Authorization"] = f"Bearer {selected_token}"

        return merged

    def _normalize_query(self, query: Optional[Mapping[str, Any]]) -> Optional[Dict[str, Any]]:
        if not query:
            return None

        normalized: Dict[str, Any] = {}

        for key, value in query.items():
            if value is None:
                continue

            if isinstance(value, (list, tuple, set)):
                items = [str(item) for item in value if str(item).strip()]
                if items:
                    normalized[str(key)] = items
                continue

            text = str(value).strip()
            if text:
                normalized[str(key)] = text

        return normalized or None

    def _select_base_url(self, *, path: str, route: Optional[str]) -> str:
        if route == "api":
            return self.api_base_url
        if route == "site":
            return self.site_base_url
        if path.startswith("/api/v1/"):
            return self.api_base_url
        return self.site_base_url

    def _parse_payload(self, response: httpx.Response) -> Any:
        text = response.text
        if not text:
            return None

        content_type = (response.headers.get("content-type") or "").lower()

        if "application/json" in content_type or "+json" in content_type:
            try:
                return response.json()
            except ValueError as exc:
                raise SotiAPIError(
                    "Invalid JSON response",
                    response.status_code,
                    code="invalid_json_response",
                    payload=text,
                ) from exc

        try:
            return response.json()
        except ValueError:
            return text

    def _normalize_payload(self, *, payload: Any, response: httpx.Response) -> Any:
        if isinstance(payload, Mapping):
            ok_flag = payload.get("ok")
            if ok_flag is False:
                raise self._build_api_error(payload=payload, response=response)
            if ok_flag is True:
                if "data" in payload:
                    return payload.get("data")
                normalized = dict(payload)
                normalized.pop("ok", None)
                return normalized

            if payload.get("success") is False:
                raise self._build_api_error(payload=payload, response=response)

        return payload

    def _build_api_error(self, *, payload: Any, response: Optional[httpx.Response] = None) -> SotiAPIError:
        status_code = response.status_code if response is not None else None
        code = "api_error"
        message = "Soti API request failed"
        details: Optional[Mapping[str, Any]] = None
        request_id: Optional[str] = None

        if isinstance(payload, Mapping):
            error_field = payload.get("error")

            if isinstance(error_field, Mapping):
                code = str(error_field.get("code") or error_field.get("error") or payload.get("code") or code)
                message = str(error_field.get("message") or payload.get("message") or message)
                if isinstance(error_field.get("details"), Mapping):
                    details = error_field["details"]
                elif isinstance(payload.get("details"), Mapping):
                    details = payload["details"]
            else:
                code = str(payload.get("code") or payload.get("error") or code)
                message = str(payload.get("message") or payload.get("error") or message)
                if isinstance(payload.get("details"), Mapping):
                    details = payload["details"]

            if isinstance(payload.get("meta"), Mapping):
                request_id_value = payload["meta"].get("request_id")
                if request_id_value is not None:
                    request_id = str(request_id_value)

        elif isinstance(payload, str) and payload.strip():
            message = payload.strip()

        if response is not None:
            request_id = request_id or response.headers.get("x-request-id") or response.headers.get("X-Request-ID")

        return SotiAPIError(
            message,
            status_code,
            code=code,
            details=details,
            request_id=request_id,
            payload=payload,
        )

    def _select_token(self, token_type: str) -> Optional[str]:
        if token_type == "none":
            return None
        if token_type == "bot":
            return self.bot_token or self.api_token
        return self.api_token or self.bot_token

    def _extract_bot(self, payload: Any) -> Optional[Bot]:
        if isinstance(payload, Mapping):
            if payload.get("error") is not None:
                return None
            if payload.get("ok") is False or payload.get("success") is False:
                return None
            return Bot.from_payload(payload)
        return None

    @classmethod
    def _validate_snowflake(cls, value: Snowflake, *, field_name: str) -> str:
        text = str(value or "").strip()
        if not cls._SNOWFLAKE_RE.match(text):
            raise SotiValidationError(f"{field_name} must be a valid Discord snowflake", {field_name: value})
        return text

    def _normalize_stats(self, stats: Mapping[str, Any]) -> Dict[str, Any]:
        if not isinstance(stats, Mapping):
            raise SotiValidationError("stats must be a mapping")

        allowed_keys = {
            "servers",
            "guilds",
            "server_count",
            "guild_count",
            "users",
            "members",
            "user_count",
            "member_count",
            "shards",
            "shard_count",
            "platform",
            "source",
            "extra",
            "idempotency_key",
        }

        unknown = [key for key in stats.keys() if str(key) not in allowed_keys]
        if unknown:
            raise SotiValidationError("stats contains unknown fields", {"fields": unknown})

        servers = self._first_number(stats, ["servers", "guilds", "server_count", "guild_count"])
        if servers is None:
            raise SotiValidationError("servers is required")

        users = self._first_number(stats, ["users", "members", "user_count", "member_count"], default=0)
        shards = self._first_number(stats, ["shards", "shard_count"], default=0)

        if servers < 0 or servers > 5_000_000:
            raise SotiValidationError("servers must be between 0 and 5_000_000")
        if users < 0 or users > 1_000_000_000:
            raise SotiValidationError("users must be between 0 and 1_000_000_000")
        if shards < 0 or shards > 2_048:
            raise SotiValidationError("shards must be between 0 and 2_048")

        payload: Dict[str, Any] = {
            "servers": servers,
            "users": users,
            "shards": shards,
        }

        if stats.get("platform") is not None:
            platform = str(stats.get("platform")).strip()
            if platform:
                payload["platform"] = platform

        if stats.get("source") is not None:
            source = str(stats.get("source")).strip()
            if source:
                payload["source"] = source

        if isinstance(stats.get("extra"), Mapping):
            payload["extra"] = dict(stats.get("extra"))

        if stats.get("idempotency_key") is not None:
            key = str(stats.get("idempotency_key")).strip()
            if key:
                if len(key) > 128:
                    raise SotiValidationError("idempotency_key must be <= 128 chars")
                payload["idempotency_key"] = key

        return payload

    @staticmethod
    def _first_number(stats: Mapping[str, Any], keys: Sequence[str], *, default: Optional[int] = None) -> Optional[int]:
        for key in keys:
            if key in stats and stats.get(key) is not None:
                value = stats.get(key)
                try:
                    number = int(value)
                except (TypeError, ValueError) as exc:
                    raise SotiValidationError(f"{key} must be an integer") from exc
                return number
        return default

    @staticmethod
    def _is_retryable_exception(exc: httpx.RequestError) -> bool:
        code = getattr(exc, "errno", None)
        if code is not None:
            return True

        text = str(exc).lower()
        markers = [
            "timed out",
            "connection reset",
            "temporary failure",
            "network is unreachable",
            "connect",
        ]
        return any(marker in text for marker in markers)

    @staticmethod
    def _is_retryable_status(status: int) -> bool:
        return status in {408, 409, 425, 429} or status >= 500

    def _retry_delay(self, attempt: int) -> float:
        jitter = random.uniform(0.0, 0.1)
        return self.retry_delay * (2 ** attempt) + jitter

    @classmethod
    def _derive_api_base(cls, site_base: str) -> str:
        text = str(site_base or "").strip()
        if not text:
            return cls._DEFAULT_API_BASE

        try:
            parsed = httpx.URL(text)
        except Exception:
            return cls._DEFAULT_API_BASE

        host = (parsed.host or "").lower()
        if host in {"flarehub.pro", "www.flarehub.pro"}:
            scheme = parsed.scheme or "https"
            return f"{scheme}://api.flarehub.pro"
        return text.rstrip("/")

    @staticmethod
    def _normalize_base_url(value: str, *, field_name: str) -> str:
        text = str(value or "").strip()
        if not text:
            raise SotiValidationError(f"{field_name} is required")

        try:
            parsed = httpx.URL(text)
        except Exception as exc:
            raise SotiValidationError(f"{field_name} must be a valid absolute URL", {field_name: value}) from exc

        if not parsed.scheme or not parsed.host:
            raise SotiValidationError(f"{field_name} must be a valid absolute URL", {field_name: value})

        return str(parsed).rstrip("/")

    @staticmethod
    def _is_not_found_like(exc: SotiAPIError) -> bool:
        if exc.status_code == 404:
            return True
        code = (exc.code or "").lower()
        return code in {
            "not_found",
            "invalid_bot_id",
            "bot_is_private",
            "not_a_bot_account",
            "cannot_verify_bot_public",
            "forbidden",
        }

    @staticmethod
    def _can_try_bot_fallback(exc: SotiAPIError) -> bool:
        if exc.status_code == 404:
            return True
        code = (exc.code or "").lower()
        return code in {"not_found", "invalid_bot_id"}

    @staticmethod
    def _unique_tokens(candidates: Iterable[Optional[str]]) -> List[str]:
        seen = set()
        out: List[str] = []
        for token in candidates:
            if not isinstance(token, str):
                continue
            text = token.strip()
            if not text or text in seen:
                continue
            seen.add(text)
            out.append(text)
        return out
