import hashlib
import json
import logging
import random
import re
from base64 import b64decode
from datetime import date, datetime
from typing import Any, Generator, Iterable, Optional, SupportsIndex, TypeVar, cast
from urllib.parse import parse_qs, urlencode, urlparse, urlsplit, urlunsplit

import flask
import httpx
import requests
from nh3 import nh3
from retry import retry
from typing_extensions import Protocol

from src.types.paysites import Paysites

try:
    from itertools import batched  # type: ignore
except ImportError:  # remove in 3.12
    from itertools import islice

    A = TypeVar("A")

    def batched(iterable: Iterable[A], chunk_size: int) -> Generator[tuple[A, ...], None, None]:
        iterator = iter(iterable)
        while chunk := tuple(islice(iterator, chunk_size)):
            yield chunk


freesites = {
    "kemono": {
        "title": "Kemono",
        "user": {
            "profile": lambda service, user_id: f"/{service}/{"server" if service == "discord" else "user"}/{user_id}",
            "icon": lambda service, user_id: flask.g.icons_prepend + f"/icons/{service}/{user_id}",
            "banner": lambda service, user_id: flask.g.banners_prepend + f"/banners/{service}/{user_id}",
        },
        "post": {"link": lambda service, user_id, post_id: f"/{service}/user/{user_id}/post/{post_id}"},
    }
}

paysites = Paysites()

images_pattern = re.compile(r"\.(gif|jpe?g|jpe|png|webp)$", re.IGNORECASE)


def set_query_parameter(url: str, new_params: dict[str:str]) -> str:
    scheme, netloc, path, query_string, fragment = urlsplit(url)
    query_params = parse_qs(query_string)

    for param_name, param_value in new_params.items():
        query_params[param_name] = [param_value]

    new_query_string = urlencode(query_params, doseq=True)
    return urlunsplit((scheme, netloc, path, new_query_string, fragment))


def get_query_parameters_dict(
    request_to_extract: flask.Request, on_errors: str = "ignore", clean_query_string: bool = False
) -> dict[str, str]:
    """helps prevent invalid encoded url params"""
    parsed_url = urlparse(request_to_extract.url)
    try:
        query_params = parse_qs(parsed_url.query, errors=on_errors)
        if on_errors == "ignore":
            request_to_extract.query_string = (
                request_to_extract.query_string.decode(errors=on_errors).replace("\x00", "").encode()
            )
    except UnicodeEncodeError:
        return {}
    return {key: value[0].replace("\x00", "") for key, value in query_params.items()}


def relative_time(date: datetime) -> str:
    """Take a datetime and return its "age" as a string.
    The age can be in second, minute, hour, day, month or year. Only the
    biggest unit is considered, e.g. if it's 2 days and 3 hours, "2 days" will
    be returned.
    Make sure date is not in the future, or else it won't work.
    Original Gist by 'zhangsen' @ https://gist.github.com/zhangsen/1199964
    """

    def formatn(n: int, s: str) -> str:
        """Add "s" if it's plural"""

        if n == 1:
            return "1 %s" % s
        return "%d %ss" % (n, s)

    def qnr(a: float, b: float):
        """Return quotient and remaining"""

        return a / b, a % b

    class FormatDelta:
        def __init__(self, dt: datetime):
            now = datetime.now()
            delta = now - dt
            self.day = delta.days
            self.second = delta.seconds
            self.year, self.day = qnr(self.day, 365)
            self.month, self.day = qnr(self.day, 30)
            self.hour, self.second = qnr(self.second, 3600)
            self.minute, self.second = qnr(self.second, 60)

        def format(self):
            for period in ["year", "month", "day", "hour", "minute", "second"]:
                n = getattr(self, period)
                if n >= 1:
                    return "{0} ago".format(formatn(n, period))
            return "just now"

    return FormatDelta(date).format()


def allowed_file(mime: str, accepted: list[str]) -> bool:
    return any(x in mime for x in accepted)


def url_is_for_non_logged_file_extension(path) -> bool:
    parts = path.split("/")
    if len(parts) == 0:
        return False

    blocked_extensions = ["js", "css", "ico", "svg"]
    for extension in blocked_extensions:
        if ("." + extension) in parts[-1]:
            return True
    return False


def sort_dict_list_by(list_var, key, reverse=False):
    return sorted(list_var, key=lambda v: (v[key] is None, v[key]), reverse=reverse)


def restrict_value(value, allowed, default=None):
    if value not in allowed:
        return default
    return value


class GetItem(Protocol):
    def __getitem__(self: "GetItem", key: SupportsIndex | slice, /) -> Any:
        ...

    def __len__(self: "GetItem") -> int:
        ...


T = TypeVar("T", bound=GetItem)


def take(num: int, list_var: T) -> T:
    if len(list_var) <= num:
        return list_var
    return list_var[:num]


def offset_list(num: int, list_var: T) -> T:
    if len(list_var) <= num:
        return cast(T, [])
    return list_var[num:]


def step_int(i: int, mod: int, fix=False) -> int | None:
    """this ensures or fixes a number to make sure it is composed of multiple of mod"""
    if i % mod:
        if not fix:
            return None
        return i - (i % mod)
    return i


def limit_int(i: int, limit: int) -> int:
    if i > limit:
        return limit
    return i


def parse_int(string: str | None, default=0) -> int:
    # XXX string shouldn't be nilable here
    if string is None:
        return default
    try:
        return_value = int(string)
        if return_value > 9223372036854775807:  # 2**63 largest int postgres accepts
            return default
        return return_value
    except ValueError:
        return default


def positive_or_none(input_int: int | None) -> int | None:
    if input_int is None or input_int < 0:
        return None
    return input_int


def render_page_data() -> str:
    return json.dumps(flask.g.page_data)


def generate_import_id(data) -> str:
    salt = str(random.randrange(0, 1000))
    return take(16, hashlib.sha256((data + salt).encode("utf-8")).hexdigest())


def decode_b64(s: Optional[str]) -> Optional[str]:
    if s is not None:
        return b64decode(s.encode()).decode()


# allow_iframe only for Fanbox
allowed_tags = {
    "a",
    "abbr",
    "acronym",
    "b",
    "blockquote",
    "code",
    "em",
    "i",
    "li",
    "ol",
    "strong",
    "ul",
    "img",
    "br",
    "h1",
    "h2",
    "h3",
    "h4",
    "h5",
    "h6",
    "p",
    "div",
    "span",
    "ul",
    "ol",
    "li",
}
allowed_tags_with_iframe = {
    *allowed_tags,
    "iframe"
}
allowed_attributes = {
    "a": {"href", "title"},
    "acronym": {"title"},
    "abbr": {"title"},
    "img": {"src"},
}
allowed_attributes_with_iframe = {
    **allowed_attributes,
    "iframe": {"src"},
}


def sanitize_html(html: str, allow_iframe: bool = False) -> str:
    local_allowed_tags = allowed_tags
    local_allowed_attributes = allowed_attributes
    if allow_iframe:
        # Some Fanbox embeds require the usage of IFrame
        local_allowed_tags = allowed_tags_with_iframe
        local_allowed_attributes = allowed_attributes_with_iframe
    return nh3.clean(html, attributes=local_allowed_attributes, tags=local_allowed_tags)


def fixed_size_batches(input_list: list, batch_sizes: list[int]) -> list[list]:
    batches = [input_list]
    for batch_size in batch_sizes:
        if len(batches[-1]) > batch_size:
            batches.extend(list(batched(batches.pop(), batch_size)))
        elif len(batches[-1]) == batch_size:
            break

    return batches


def date_isoformat_serializer(obj):
    if isinstance(obj, (datetime, date)):
        return obj.isoformat()
    raise TypeError(f"Type {type(obj)} is not JSON serializable")


def parse_offset(s: Optional[str], limit: int = 50) -> int:
    i = abs(parse_int(s))
    return round(i / limit) * limit


CONNECTION_ATTEMPTS = 5


class CustomHTTPAdapter(requests.adapters.HTTPAdapter):
    """https://github.com/psf/requests/issues/4664"""

    def send(self, *args, **kwargs):
        saved = Exception("Saved exception")
        for attempt in range(CONNECTION_ATTEMPTS):
            if attempt > 1:
                logging.exception("Retrying after connection error")
            try:
                return super().send(*args, **kwargs)
            except ConnectionError as exc:
                saved = exc
                continue

        raise saved


clear_cache_session = requests.Session()
clear_cache_session.mount(
    "https://",
    CustomHTTPAdapter(pool_connections=20, max_retries=CONNECTION_ATTEMPTS, pool_maxsize=64),
)


def clear_web_cache(path):
    from src.config import Configuration
    ban_url = Configuration().ban_url
    if not ban_url:
        logging.info(f"Skipped clearing web cache because not url for path {path}")
        return
    if isinstance(ban_url, str):
        ban_urls = [ban_url]
    else:
        ban_urls = ban_url
    for ban_url in ban_urls:
        if path[0] != "/":
            path = "/" + path
        full_path = f"{ban_url}{path}"
        try:
            retry(tries=5, delay=0.2, backoff=1.5)(clear_cache_session.request)("BAN", full_path)
            logging.info(f"Cleared web cache for path {path}")
        except Exception:
            logging.exception("Failed to clear cache", extra=dict(path=path, full_path=full_path))


def clear_web_cache_for_creator(service, creator_id):
    clear_web_cache(f"/{service}/user/{creator_id}")


def clear_web_cache_for_creator_links(service, creator_id):
    clear_web_cache(f"/{service}/user/{creator_id}/links")


def clear_web_cache_for_creator_dms(service, creator_id):
    clear_web_cache(f"/{service}/user/{creator_id}/dms")


def clear_web_cache_for_post(service, creator_id, post_id):
    clear_web_cache(f"/{service}/user/{creator_id}/post/{post_id}")


def clear_web_cache_for_archive(file_hash):
    clear_web_cache(f"/posts/archives/{file_hash}")

