import json
import re
from functools import lru_cache
from re import Pattern
from types import MappingProxyType
from typing import Any, Callable, Dict, Mapping, cast

from bs4 import BeautifulSoup, Tag
from flask_wtf import FlaskForm
from testcontainers.community.postgres import PostgresContainer

from app.common.helpers.feature_flags import FeatureFlagBase
from tests.conftest import FundingServiceTestClient


def page_has_error(soup: BeautifulSoup, message: str) -> bool:
    error_summary = soup.find("div", class_="govuk-error-summary")
    if not error_summary:
        return False

    error_messages = error_summary.select("li a")
    return any(message in error_message.text for error_message in error_messages)


class RestrictedAny:
    """
    Analogous to mock.ANY, this class takes an arbitrary callable in its constructor and the returned instance will
    appear to "equal" anything that produces a truthy result when passed as an argument to the ``condition`` callable.

    Useful when wanting to assert the contents of a larger structure but be more flexible for certain members, e.g.

    # only care that second number is odd
    >>> (4, 5, 6,) == (4, RestrictedAny(lambda x: x % 2), 6,)
    True
    >>> (4, 9, 6,) == (4, RestrictedAny(lambda x: x % 2), 6,)
    True
    """

    def __init__(self, condition: Callable[[Any], bool]) -> None:
        self._condition = condition

    def __eq__(self, other: Any) -> bool:
        return self._condition(other)

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({self._condition})"

    def __hash__(self) -> None:  # ty: ignore[invalid-method-override]
        return None


class AnySupersetOf(RestrictedAny):
    """
    Instance will appear to "equal" any dictionary-like object that is a "superset" of the the constructor-supplied
    ``subset_dict``, i.e. will ignore any keys present in the dictionary in question but missing from the reference
    dict. e.g.

    >>> [{"a": 123, "b": 456, "less": "predictabananas"}, 789] == [AnySupersetOf({"a": 123, "b": 456}), 789]
    True
    """

    def __init__(self, subset_dict: Mapping[str, Any]) -> None:
        # take an immutable dict copy of supplied dict-like object
        self._subset_dict = MappingProxyType(dict(subset_dict))

    def _condition(self, other: Any) -> bool:
        return self._subset_dict == {k: v for k, v in other.items() if k in self._subset_dict}

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({self._subset_dict})"


class AnyStringMatching(RestrictedAny):
    """
    Instance will appear to "equal" any string that matches the constructor-supplied regex pattern

    >>> {"a": "Metempsychosis", "b": "c"} == {"a": AnyStringMatching(r"m+.+psycho.*", flags=re.I), "b": "c"}
    True
    """

    _cached_re_compile = staticmethod(lru_cache(maxsize=32)(re.compile))

    def __init__(self, *args, **kwargs) -> None:
        """
        Construct an instance which will equal any string matching the supplied regex pattern. Supports all arguments
        recognized by ``re.compile``, alternatively accepts an existing regex pattern object as a single argument.
        """
        self._regex = (
            args[0] if len(args) == 1 and isinstance(args[0], Pattern) else self._cached_re_compile(*args, **kwargs)
        )
        super().__init__(lambda other: isinstance(other, str | bytes) and bool(self._regex.match(other)))

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({self._regex})"


def build_db_config(setup_db_container: PostgresContainer | None) -> Dict[str, Any]:
    if setup_db_container is None:
        return {
            "DATABASE_HOST": "localhost",
            "DATABASE_PORT": "5432",
            "DATABASE_NAME": "db-access-not-available-for-unit-tests",
            # pragma: allowlist nextline secret
            "DATABASE_SECRET": json.dumps({"username": "invalid", "password": "invalid"}),
            "DEBUG_TB_ENABLED": "false",
        }
    return {
        "DATABASE_HOST": setup_db_container.get_container_host_ip(),
        "DATABASE_PORT": str(setup_db_container.get_exposed_port(5432)),
        "DATABASE_NAME": setup_db_container.dbname,
        "DATABASE_SECRET": json.dumps(
            {"username": setup_db_container.username, "password": setup_db_container.password}
        ),
        "DEBUG_TB_ENABLED": "false",
    }


def get_soup_text(soup: BeautifulSoup, tag: str) -> str:
    element = getattr(soup, tag)
    assert element, f"Could not find <{tag}> on page"
    return re.sub(r"\s+", " ", cast(str, element.text).strip())


def get_h1_text(soup: BeautifulSoup) -> str:
    return get_soup_text(soup, "h1")


def get_h2_text(soup: BeautifulSoup) -> str:
    return get_soup_text(soup, "h2")


def get_link_hrefs(soup: BeautifulSoup) -> list[str]:
    links = soup.find_all("a")
    link_hrefs = []
    for link in links:
        href = link.get("href")
        if href is not None and isinstance(href, str):
            link_hrefs.append(href)
    return link_hrefs


def get_service_name_text(soup: BeautifulSoup) -> str | None:
    service_name_element = soup.find("span", class_="govuk-header__product-name")
    return service_name_element.text if service_name_element else None


def page_has_link(soup: BeautifulSoup, link_text: str) -> Tag | None:
    links = soup.select("a")

    for link in links:
        if link_text in link.text:
            return link

    return None


def get_summary_list_value_by_key(soup: BeautifulSoup, key_text: str) -> Tag | None:
    keys = soup.find_all("dt", class_="govuk-summary-list__key")
    for key in keys:
        if key_text in key.text:
            return key.parent.find("dd", class_="govuk-summary-list__value") if key.parent else None
    return None


def get_table_row_by_first_column_value(table: Tag, first_column_value: str) -> Tag | None:
    rows = table.find_all("tr")
    for row in rows:
        cells = row.find_all("td")
        if not cells:
            continue
        if cells[0].text.strip() == first_column_value:
            return row

    return None


def page_has_h2(soup: BeautifulSoup, h2_text: str) -> Tag | None:
    h2s = soup.select("h2")

    for h2 in h2s:
        if h2_text in h2.text:
            return h2

    return None


def page_has_button(soup: BeautifulSoup, button_text: str) -> Tag | None:
    buttons = soup.select("button")

    for button in buttons:
        if button_text in button.text:
            return button

    return None


def page_has_flash(soup: BeautifulSoup, flash_text: str) -> Tag | None:
    flash_messages = soup.find_all(class_="govuk-notification-banner__content")

    for flash_message in flash_messages:
        if flash_text in flash_message.text:
            return flash_message

    return None


def get_form_data(form: FlaskForm, submit: str = "y") -> dict[str, Any]:
    """Get the data from a flask form suitable for passing as `data` to a Flask test client's `post` method.

    Specifically we need to strip out any null/falsey data, which can be stringified into eg `"False"` and ends up
    being processed as "truthy".
    """
    data = {k: v for k, v in form.data.items() if v}

    # If we're getting the form data, we want to submit the form
    if hasattr(form, "submit") and submit:
        data["submit"] = "y"

    return data


def enable_session_feature_flag(client: FundingServiceTestClient, flag: FeatureFlagBase) -> None:
    with client.session_transaction() as session:
        session[flag.name] = "on"


def get_test_flashes(client: FundingServiceTestClient, category: str | None = None) -> list[Any]:
    with client.session_transaction() as sess:
        flashes = sess["_flashes"]
        return [msg for cat, msg in flashes if category is None or category == cat]
