import asyncio
import logging
import mimetypes
from typing import Set, Optional
from urllib.parse import urljoin, urlparse, urlunparse

import aiohttp
from bs4 import BeautifulSoup

from .config import Config

logger = logging.getLogger(__name__)

__all__ = ("WebInstrument",)


class WebInstrument:
    def __init__(self, init_url: str, config: Config):
        """
        Core class for working with webpages:

        1. get webpages

        2. get webpage tags

        3. filter links

        Args:
            init_url: webpage main link
            config: dataclass contains all params
        """
        self.init_url = init_url
        self.domain = self.get_domain(url=self.init_url)
        self.config = config

    @staticmethod
    def get_domain(url: str) -> str:
        """
        Method parse link to get core domain from it
        Args:
            url: webpage link

        Returns:
            domain name
        """
        hostname = urlparse(url=url).hostname
        if not hostname:
            return ""
        return ".".join(hostname.split(".")[-2:])

    @staticmethod
    def normalize_url(url: str) -> str:
        """Normalize a URL by stripping fragment identifiers.

        Args:
            url: URL string to normalize.

        Returns:
            The normalized URL without any fragment component.
        """
        parsed = urlparse(url=url)
        if not parsed.fragment:
            return url
        return urlunparse(parsed._replace(fragment=""))

    @staticmethod
    def find_tags(page_data: str, tag: str, key: str) -> Set[str]:
        """
        Parses webpage text and extracts values of a specific attribute from given tags,
        ignoring elements with rel="nofollow".

        Args:
            page_data: HTML content of the page
            tag: HTML tag to search for (e.g., 'a')
            key: attribute to extract (e.g., 'href')

        Returns:
            Set of extracted attribute values
        """
        result_elements = set()

        soup = BeautifulSoup(page_data, "html.parser")
        elements = soup.find_all(tag)

        for element in elements:
            if not element.has_attr(key):
                continue

            if rel_values := element.get("rel"):
                if "nofollow" in rel_values:
                    continue

            if value := element.get(key).strip():
                # Skip email links like "mailto:user@example.com"
                if value.lower().startswith("mailto:"):
                    continue
                result_elements.add(value)

        return result_elements

    async def download_page(self, url: str) -> Optional[str]:
        """
        Method connect open webpage and download it's as text
        Args:
            url: webpage for downloading

        Returns:
            Webpage as text
        """
        async with aiohttp.ClientSession(headers=self.config.header) as session:
            for attempt in self.attempts_generator():
                try:
                    async with session.get(url=url) as resp:
                        if resp.status == 200:
                            logger.info(f"Page success loaded - {url = }")
                            return await resp.text()
                        else:
                            await asyncio.sleep(1 * attempt)
                            raise ValueError(
                                f"Wrong response status {attempt = }, {url = } ; {resp.status = }, {await resp.text()}"
                            )
                except Exception as err:
                    logger.warning(f"{err}")
            else:
                logger.error(f"Page not loaded - {url = }")
        return None

    @staticmethod
    def __filter_links_query(links: Set[str], is_query_enabled: bool = True) -> Set[str]:
        """
        Method filter webpages links set and return only links with same domain or subdomain
        Args:
            links: set of links for filtering
            is_query_enabled: accept or not links with query strings

        Returns:
            Filtered list of links
        """
        result_links = set()
        for link in links:
            if is_query_enabled and urlparse(url=link).query:
                result_links.add(link)
            elif not urlparse(url=link).query:
                result_links.add(link)
        return result_links

    def is_subdomain_excluded(self, hostname: str) -> bool:
        """
        Check if a hostname contains any excluded subdomains

        Args:
            hostname: hostname to check (e.g., "blog.example.com")

        Returns:
            True if hostname contains excluded subdomain, False otherwise
        """
        if not hostname or not self.config.excluded_subdomains:
            return False

        hostname_parts = hostname.split(".")
        # Check each part of the hostname against excluded subdomains
        for part in hostname_parts:
            if part in self.config.excluded_subdomains:
                return True
        return False

    def filter_links_domain(self, links: Set[str], is_subdomain: bool = True) -> Set[str]:
        """
        Method filter webpages links set and return only links with same domain or subdomain
        Args:
            links: set of links for filtering
            is_subdomain: accept or not links with subdomain

        Returns:
            Filtered list of links
        """
        result_links = set()
        check_logic = "endswith" if is_subdomain else "__eq__"
        for link in links:
            link_domain = urlparse(url=link).hostname
            if not link_domain:
                continue

            # Check if subdomain is excluded first
            if self.is_subdomain_excluded(link_domain):
                continue

            if getattr(link_domain, check_logic)(self.domain):
                result_links.add(link)
        return result_links

    @staticmethod
    def filter_inner_links(links: Set[str]) -> Set[str]:
        """
        Method get set of links and filter them from non-inner website links
        Args:
            links: set of website links

        Returns:
            Filtered list of links only with inner links
        """
        result_links = set()
        for link in links:
            if link and not link.startswith("https://"):
                result_links.add(link)
        return result_links

    def filter_links(self, canonical_url: str, links: Set[str]) -> Set[str]:
        """
        Filter links by domain, query, and file type

        Args:
            canonical_url: Base URL for resolving relative links
            links: Set of links to filter

        Returns:
            Filtered set of links
        """
        filtered_links = set()
        # filter only local weblinks
        inner_links = self.filter_inner_links(links=links)
        # filter global domain weblinks from local links
        filtered_links.update(
            self.filter_links_domain(
                links=links.difference(inner_links),
                is_subdomain=self.config.accept_subdomains,
            )
        )
        # create fixed inner links (fixed - added to local link page url)
        filtered_links.update({urljoin(canonical_url, inner_link) for inner_link in inner_links})
        normalized_links = {self.normalize_url(link) for link in filtered_links}
        # filter weblinks from webpages link minus links with query
        filtered_links = self.__filter_links_query(
            links=normalized_links, is_query_enabled=self.config.is_query_enabled
        )

        # Apply file filtering if enabled
        if self.config.exclude_file_links:
            filtered_links = self.filter_file_links(filtered_links)

        return filtered_links

    def is_web_page_url(self, url: str) -> bool:
        """
        Check if URL represents a web page rather than a downloadable file

        Args:
            url: URL to check

        Returns:
            True if URL represents a web page, False if it's a file
        """
        parsed = urlparse(url)
        path = parsed.path.lower() if parsed.path else ""

        # Skip protocol-relative URLs and special schemes
        if url.startswith(("mailto:", "tel:", "javascript:", "data:")):
            return False

        # Check file extensions
        if path.endswith(tuple(self.config.web_page_extensions)):
            return True

        # Directory paths (no extension) are considered web pages
        path_parts = path.split("/")
        filename = path_parts[-1] if path_parts and path_parts[-1] else ""
        if "." not in filename:
            return True

        # Check MIME type
        mime_type, _ = mimetypes.guess_type(url)
        if mime_type:
            # Web page MIME types
            if mime_type in ["text/html", "application/xhtml+xml"]:
                return True
            # Known file types (not web pages)
            elif not any(mime_type.startswith(prefix) for prefix in ["text/", "application/xhtml"]):
                return False

        # Check against excluded file extensions
        for ext in self.config.excluded_file_extensions:
            if path.endswith(ext.lower()):
                return False

        # Check against allowed file extensions (whitelist mode)
        if self.config.allowed_file_extensions is not None:
            for ext in self.config.allowed_file_extensions:
                if path.endswith(ext.lower()):
                    return True
            return False  # Not in whitelist, treat as file

        return True  # Default to web page if uncertain

    def is_file_url(self, url: str) -> bool:
        """
        Check if URL represents a downloadable file (inverse of is_web_page_url)

        Args:
            url: URL to check

        Returns:
            True if URL represents a file, False if it's a web page
        """
        return not self.is_web_page_url(url)

    def filter_file_links(self, links: Set[str]) -> Set[str]:
        """
        Filter out file links from a set of URLs, keeping only web pages

        Args:
            links: Set of URLs to filter

        Returns:
            Set containing only web page URLs (files filtered out)
        """
        if not self.config.exclude_file_links:
            return links

        filtered_links = set()
        for link in links:
            if self.is_web_page_url(link):
                filtered_links.add(link)

        return filtered_links

    async def check_url_content_type(self, url: str) -> Optional[str]:
        """
        Check the actual Content-Type of a URL via HEAD request

        Args:
            url: URL to check

        Returns:
            Content-Type header value or None if unable to determine
        """
        try:
            async with aiohttp.ClientSession(headers=self.config.header) as session:
                async with session.head(url) as response:
                    return response.headers.get("Content-Type")
        except Exception:
            return None

    @staticmethod
    def attempts_generator(amount: int = 6):
        """
        Function generates a generator of length equal to `amount`

        Args:
            amount: number of attempts generated

        Returns:
            Attempt number generator
        """
        yield from range(1, amount)
