import json
import logging
import re
from typing import Any, Callable, Dict, List, Optional, Tuple

from bs4 import BeautifulSoup
from jsonpath_ng import parse

from arkeo.cfg import ConfigurationManager
from arkeo.keys import PrimaryKey
from arkeo.path import PathManager
from arkeo.utils import StatsLoggingCodes as SL
from utils.access import DriveManager
from utils.text import to_type_list


log = logging.getLogger(__name__)


KEY_METADATA = "metadata"

RE_JSON_LD: str = r'<script type="application/ld\+json"[^>]*>(.*?)</script>'

CSS_PREFIXES = (".", "#", "[", "meta", "div", "span", "h1", "h2", "h3")
JSONPATH_INDICATORS = ("$", "@", "..", "[*]", "[?")


class MetadataExtractor:

    def __init__(
        self,
        path_manager: Optional[PathManager] = None,
        key_manager: Optional[PrimaryKey] = None,
        config_manager: Optional[ConfigurationManager] = None,
    ):
        self.path = path_manager or PathManager()
        self.drive: DriveManager = self.path.drive
        self.pk = key_manager or PrimaryKey(self.path)
        self.cfgs = config_manager or ConfigurationManager(self.path)

        self._expr_json_ld: str = ""

    @property
    def expr_json_ld(self):
        if not self._expr_json_ld:
            self._expr_json_ld = re.compile(RE_JSON_LD, re.DOTALL)
        return self._expr_json_ld

    def set_slog(self, logger: Optional[Callable] = None) -> None:
        self.slog = logger or (lambda *args, **kwargs: None)

    def extract_metadata(
        self,
        fields: List[str],
        config: Dict[str, Any],
        html: str,
        soup: Optional[BeautifulSoup] = None,
    ) -> Dict[str, Any]:
        """extract metadata from all configured sources"""

        additional_data = {}

        if html and not soup:
            soup = BeautifulSoup(html, "html.parser")
        additional_data.update(
            self._extract_article_metadata(fields, config, html, soup)
        )

        nested = config.get("additional_data", [])
        if nested:
            additional_data.update(
                self._extract_article_metadata(fields, nested, html, soup)
            )

        return additional_data

    def _extract_article_metadata(
        self,
        fields: List[str],
        config: Dict[str, Any],
        html: str,
        soup: Optional[BeautifulSoup] = None,
    ) -> Dict[str, Any]:
        """extract metadata fields using configured selectors"""
        results = {}
        for field in fields:
            # interconnected
            if field == "images":
                pass  # distinct but newspaper3k treats as alias for 'imgs'?
            elif field == "imgs":
                if soup:
                    img_tags = soup.find_all(["img", "image"])
                    if img_tags:
                        img_data = {}
                        for img in img_tags:
                            alt = img.get("alt", "")
                            src = img.get("src", "")
                            if src and alt:
                                img_data[src] = alt
                        results[field] = json.dumps(img_data)
            elif field == "meta_img":
                if soup:
                    caption = soup.find("figcaption")
                    results[field] = caption.get_text() if caption else ""

            # stand-alone
            else:
                field_config = config.get(field, [])
                if field_config:
                    value = self._extract_field_value(field_config, html, soup)
                    if value:
                        results[field] = self._process_field_value(field, value)
        return results

    def _extract_field_value(
        self,
        field_config: List[str],
        html: str,
        soup: Optional[BeautifulSoup] = None,
    ) -> Optional[Any]:
        """extract value using JSON-LD then CSS selectors"""
        json_ld_paths, css_selectors = self._classify_selectors(field_config)

        # try JSON-LD first
        for field_path in json_ld_paths:
            value = self._extract_from_json_ld(html, field_path)
            if value:
                return value

        if soup and css_selectors:
            return self._extract_with_css_selectors(soup, css_selectors)

        return None

    # validation and classification helpers
    def _classify_selectors(self, selectors: List[str]) -> Tuple[List[str], List[str]]:
        """separate JSON-LD paths from CSS selectors"""
        json_ld_paths = []
        css_selectors = []

        for selector in selectors:
            if self._is_json_ld_path(selector):
                json_ld_paths.append(selector)
            else:
                css_selectors.append(selector)

        return json_ld_paths, css_selectors

    def _is_json_ld_path(self, selector: str) -> bool:
        """detect JSON-LD path vs CSS selector"""
        """
        OLD VERSION, remove after testing.

        return selector.startswith("$") or (
            "." in selector
            and not any(selector.startswith(prefix) for prefix in CSS_PREFIXES)
            and not selector.startswith(("div.", "span."))
        )
        """
        return any(indicator in selector for indicator in JSONPATH_INDICATORS)

    # metadata extraction methods
    def _extract_from_json_ld(self, html: str, field_path: str) -> Optional[List[Any]]:
        """extract field from json-ld schema data"""
        matches = self.expr_json_ld.findall(html)

        found = False
        for match in matches:
            try:
                data = json.loads(match)
                if "author" in field_path:
                    for item in data:
                        expr = parse(f"$.{field_path}")
                        hits = expr.find(item)
                        if hits:
                            self.slog(status=SL.SUCCESS, track=field_path)
                            return [hit.value for hit in hits]
                else:

                    keys = field_path.split(".")
                    for i, key in enumerate(keys):
                        if isinstance(data, list):
                            for item in data:
                                if isinstance(item, dict) and key in item:
                                    data = item.get(key, "")
                                    found = True
                                    if i + 1 == len(keys):
                                        continue
                                    else:
                                        break
                            if not found:
                                break
                        elif isinstance(data, dict):
                            if key in data:
                                data = data[key]
                            else:
                                break

                    if found:
                        if isinstance(data, list):
                            self.slog(status=SL.SUCCESS, track=field_path)
                            log.info(f"{field_path} found: {data}")
                            return data
                        elif isinstance(data, str):
                            self.slog(status=SL.SUCCESS, track=field_path)
                            log.info(f"{field_path} found: {data}")
                            return to_type_list(data)

            except json.JSONDecodeError:
                continue

        return None

    def _extract_with_css_selectors(
        self, soup: BeautifulSoup, field_config: Dict[str, Any]
    ) -> Optional[Any]:
        """extract field using css selectors with domain-specific config"""
        if isinstance(field_config, dict):
            return self._combine_selector_content(soup, field_config)
        else:
            return self._extract_first_match(soup, field_config)

    def _combine_selector_content(
        self, soup: BeautifulSoup, field_config: Dict[str, Any]
    ) -> Optional[List[str]]:
        """extract and combine content from multiple CSS selectors"""
        actions = field_config.get("actions", [])
        selectors = field_config.get("selectors", [])
        stop_words = field_config.get("stops", [])
        delimiters = field_config.get("delimiters", [])

        combine = actions and actions[0] not in ["first"]

        if not combine:
            return self._extract_first_match(soup, selectors)

        results = self._collect_selector_content(soup, selectors)
        if not results:
            return []

        processed_items = self._split_by_delimiters(results, delimiters)

        filtered_results = self._filter_keywords(
            processed_items, truncate=False, stop_words=stop_words or []
        )

        return sorted(set(filtered_results), key=str.lower) if filtered_results else []

    def _extract_first_match(
        self, soup: BeautifulSoup, selectors: List[str]
    ) -> Optional[str]:
        """extract content from first matching CSS selector"""
        for selector in selectors:
            element = soup.select_one(selector)
            if element:
                if element.name == "meta":
                    content = element.get("content")
                elif element.name == "link":
                    content = element.get("href")
                elif element.name == "time":
                    content = element.get("datetime")
                else:
                    content = element.get_text()

                if content:
                    self.slog(status=SL.SUCCESS, track=selector)
                    return content.replace("\n", " ").strip()
        return None

    def _collect_selector_content(
        self, soup: BeautifulSoup, selectors: List[str]
    ) -> List[str]:
        """collect content from all matching CSS selectors"""
        results = []
        for selector in selectors:
            element = soup.select_one(selector)
            if element:
                content = (
                    element.get("content")
                    if element.name == "meta"
                    else element.get_text()
                )
                if content:
                    self.slog(status=SL.SUCCESS, track=selector)
                    results.append(content.strip())
        return results

    # data processing helpers
    def _process_field_value(self, field: str, value: Any) -> Any:
        """apply field-specific transformations"""
        if field == "authors" and isinstance(value, str):
            return value.replace("By ", "")
        return value

    def _split_by_delimiters(
        self, content_list: List[str], delimiters: Optional[List[str]]
    ) -> List[str]:
        """split content by multiple delimiters sequentially"""
        if not delimiters:
            return content_list

        delimiter = delimiters[0] if delimiters else ","

        combined_text = f"{delimiter} ".join(content_list)
        current_items = [
            item.strip() for item in combined_text.split(delimiter) if item.strip()
        ]

        for sub_delimiter in delimiters[1:]:
            next_items = []
            for item in current_items:
                if sub_delimiter in item:
                    sub_items = [
                        k.strip() for k in item.split(sub_delimiter) if k.strip()
                    ]
                    next_items.extend(sub_items)
                else:
                    next_items.append(item)
            current_items = next_items

        return current_items

    def _filter_keywords(
        self,
        keywords: Any,
        truncate: bool = True,
        stop_words: Optional[List[str]] = None,
    ) -> List[str]:
        """clean keywords: truncate from first stop word else filter all"""
        if not keywords:
            return []

        if isinstance(keywords, list):
            return self._filter_keyword_list(keywords, truncate, stop_words)
        elif isinstance(keywords, str):
            return self._filter_keyword_string(keywords, truncate, stop_words)
        else:
            return self._filter_single_keyword(keywords, truncate, stop_words)

    def _filter_keyword_list(
        self, keywords: List[Any], truncate: bool, stop_words: List[str]
    ) -> List[str]:
        """filter list of keywords, optionally truncating at first stop word"""
        filtered = []
        for item in keywords:
            item_str = str(item).strip()
            if not item_str:
                continue

            if not self._has_stop_word(item_str, stop_words):
                filtered.append(item_str)
            elif truncate:
                break
        return filtered

    def _filter_keyword_string(
        self, keywords: str, truncate: bool, stop_words: List[str]
    ) -> List[str]:
        """filter comma-separated keyword string"""
        delimiter = ","

        if truncate:
            keywords = self._truncate_at_stop_word(keywords, delimiter, stop_words)

        parts = [k.strip() for k in keywords.split(delimiter) if k.strip()]

        if truncate:
            return parts
        else:
            return [k for k in parts if not self._has_stop_word(k, stop_words)]

    def _filter_single_keyword(
        self, keyword: Any, truncate: bool, stop_words: List[str]
    ) -> List[str]:
        """filter single keyword value"""
        single_keyword = str(keyword).strip()
        if not single_keyword:
            return []

        should_include = truncate or not self._has_stop_word(single_keyword, stop_words)
        return [single_keyword] if should_include else []

    def _truncate_at_stop_word(
        self, keywords: str, delimiter: str, stop_words: List[str]
    ) -> str:
        """truncate keyword string at first stop word occurrence"""
        keywords_lower = keywords.lower()
        earliest_idx = len(keywords)

        for stop_word in stop_words:
            idx = keywords_lower.find(f"{delimiter} {stop_word}")
            if idx >= 0:
                earliest_idx = min(earliest_idx, idx)

        if earliest_idx < len(keywords):
            keywords = keywords[:earliest_idx]

        return keywords

    # validation and classification helpers
    def _has_stop_word(self, text: str, stop_words: Optional[List[str]] = None) -> bool:
        """check if text contains any stop word (partial matching)"""
        text_lower = text.lower()
        return any(word.lower() in text_lower for word in stop_words)
