import json
import logging
import os
import re
import subprocess
from datetime import datetime
from functools import partial
from typing import Any, Callable, Dict, List, Optional, Tuple

import html2text
import yaml

from arkeo.cfg import ConfigurationManager
from arkeo.keys import PrimaryKey
from arkeo.keys import format_nav_path, is_nav_key_format
from arkeo.path import PathManager
from arkeo.utils import StatsLoggingCodes as SL, slog_domain
from utils.access import DriveManager
from utils.url import extract_domain, get_filename_from_url


log = logging.getLogger(__name__)

MIN_BODY_REDUCTION = 0.50


KEY_MARKDOWN = "markdown"
KEY_INCLUDE = "include"
KEY_EXCLUDE = "exclude"
KEY_FIND = "find"
KEY_REPLACEMENT = "repl"
KEY_SCOPE = "scope"
KEY_COUNT = "count"
KEY_SKIP = "skip"

SP_DIFF = "diff"
DIFF_PRE = "pre.md"
DIFF_PRO = "pro.md"

FILE_EXT = "md"

RE_HEADLINE = r"^#\\s"
RE_IMAGE = r"!\[(.*?)\]\((.*?)\)"

"""regex patterns for markdown to text conversion"""
RE_MD_YAML = r"^---\n.*?\n---"
RE_MD_HEADERS = r"^#{1,6}\s+"
RE_MD_BOLD = r"\*{1,2}([^*]+)\*{1,2}"
RE_MD_ITALIC = r"_{1,2}([^_]+)_{1,2}"
RE_MD_LINKS = r"\[([^\]]+)\]\([^)]+\)"
RE_MD_CODE_BLOCKS_1 = r"```[^`]*```"
RE_MD_CODE_BLOCKS_2 = r"`([^`]+)`"
RE_MD_WHITESPACE = r"\n\s*\n"


def convert_markdown_to_text(text: str) -> str:
    """convert markdown to plain text"""
    text = re.sub(RE_MD_YAML, "", text, 1, flags=re.DOTALL)
    text = re.sub(RE_MD_HEADERS, "", text, 1, flags=re.DOTALL)
    text = re.sub(RE_MD_BOLD, r"\1", text)
    text = re.sub(RE_MD_ITALIC, r"\1", text)
    text = re.sub(RE_MD_LINKS, r"\1", text)
    text = re.sub(RE_MD_CODE_BLOCKS_1, "", text, flags=re.DOTALL)
    text = re.sub(RE_MD_CODE_BLOCKS_2, r"\1", text)
    text = re.sub(RE_MD_WHITESPACE, "\n\n", text)
    return text.strip()


def extract_yaml_header(markdown: str) -> Tuple[str, str]:
    match = re.match(r"^---\n(.*?)\n---\n(.*)", markdown, re.DOTALL)
    if match:
        yaml_header = match.group(1)
        markdown = match.group(2)
        return (yaml_header, markdown)
    return ("", markdown)


def get_regex_flags(pattern: str) -> int:
    anchors = ["^", "$"]
    return re.DOTALL + (re.MULTILINE if any(a in pattern for a in anchors) else 0)


class MarkdownCleaner:

    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._initialize()

    def _initialize(self) -> None:
        """configure HTML to markdown converter"""
        self.html2 = html2text.HTML2Text()
        self.html2.ignore_links = False
        self.html2.body_width = 0

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

    def reprocess(self, nav_key: str) -> bool:
        """convert html to markdown with html fallback"""
        if not is_nav_key_format(nav_key):
            nav_key = self.pk.get_nav_key(nav_key)

        file_path = f"{self.path.corpus}{format_nav_path(nav_key)}.{FILE_EXT}"
        if not self.drive.file_exists(file_path):
            log.warning(f"file doesnt exist, {file_path}")
            return False

        markdown = self.drive.read_or_none(file_path)

        if False:
            match = re.match(r"^---\n(.*?)\n---\n(.*)", markdown, re.DOTALL)
            if match:
                yaml_header = match.group(1)
                markdown = match.group(2)

                yaml_data = yaml.safe_load(yaml_header)
                url = yaml_data.get("canonical", "")
                self.set_slog(partial(slog_domain, url=url) if url else None)
                domain = extract_domain(url)
            else:
                yaml_header = ""
                domain = ""
        yaml_header, markdown = extract_yaml_header(markdown)
        if yaml_header:
            yaml_data = yaml.safe_load(yaml_header)
            url = yaml_data.get("canonical", "")
            self.set_slog(partial(slog_domain, url=url) if url else None)
            domain = extract_domain(url)
        else:
            yaml_header = ""
            domain = ""

        len0 = len(markdown)
        markdown = markdown.replace("\r\n", "\n").replace("\r", "\n")
        log.info(f"length: {len(markdown)} ↤ {len0} with " f"normalized \n")

        config = self.cfgs.get(domain)
        markdown = self._apply_markdown_edits(markdown, config[KEY_MARKDOWN])

        # reattach YAML header
        if yaml_header:
            markdown = f"---\n{yaml_header}\n---\n{markdown}"

        markdown = re.sub(r"\n{3,}", "\n\n", markdown)

        out_path = file_path.replace(f".{FILE_EXT}", f"_redo.{FILE_EXT}")
        return self.drive.write_or_false(out_path, markdown)

    def convert(
        self,
        html: str,
        config: Dict[str, Any],
        header_meta: Dict[str, str],
        image_meta: Optional[str] = "",
    ) -> str:
        """convert html to markdown with html fallback"""

        # convert to markdown
        len0 = len(html)
        markdown = self.html2.handle(html)
        log.info(f"length: {len(markdown)} ↤ {len0} with html2text.handle")

        # normalize line feeds
        len0 = len(markdown)
        markdown = markdown.replace("\r\n", "\n").replace("\r", "\n")
        log.info(f"length: {len(markdown)} ↤ {len0} with normalized newline chars\n")

        markdown = self._apply_markdown_edits(markdown, config)

        # truncated links
        domain = header_meta.get("source_url", "")
        markdown = markdown.replace("(/", f"(https://{domain}/")

        # extra spaces
        markdown = re.sub(r"\n{3,}", "\n\n", markdown)

        if image_meta:
            markdown = self.update_markdown_alttext(markdown, image_meta)

        # missing header
        if re.search(RE_HEADLINE, markdown, re.MULTILINE):
            markdown = f"{self.format_header(markdown, header_meta)}\n{markdown}"

        return markdown

    def extract_body(self, html: str, config: Dict[str, Any]) -> str:
        """extract html content using tag pairs"""
        len0 = len(html)

        tags = config.get(KEY_INCLUDE, [])
        if len(tags) < 2:
            msg = "need 1+ pair of open and close 'content:tags'"
            self.slog(status=SL.ERROR, track=tags, details=msg)
            log.warning(msg)
            return ""

        for i, (open_tag, close_tag) in enumerate(zip(tags[::2], tags[1::2])):
            pattern = f"{open_tag}(.*?){close_tag}"

            matches = re.findall(pattern, html, re.DOTALL)
            if not matches:
                log.warning(f"pair {i} has 0 matches; trying next pair.")
                continue

            if len(matches) > 1:
                log.info(f"pair {i} has {len(matches)} matches.")
                match = "\n\n".join(matches)
            else:
                match = matches[0]

            self.slog(status=SL.SUCCESS, track=f"[{open_tag}, {close_tag}]")
            log.info(f"len: {len(match)} ↤ {len(html)}; tags [{repr(pattern)}].")
            if log.level == logging.DEBUG:
                timestamp = datetime.now().strftime("%y%m%d%H%M")
                file_path = f"{self.path.temp}{timestamp}_body.txt"
                self.drive.write_or_false(file_path, match)

            reduced = len(match) / len0
            if reduced < MIN_BODY_REDUCTION:
                self.slog(
                    status=SL.ERROR,
                    track=f"[{open_tag}, {close_tag}]",
                    details=f"too severe reduction of {reduced:.0%}",
                )
                log.warning(
                    f"pair {i}, tag {repr(pattern)} not used; reduced to {reduced:.0%}"
                )
                continue
            self.slog(status=SL.SUCCESS, track=f"[{open_tag}, {close_tag}]")
            return match

        log.warning("no content tag pairs had matches; check config.")
        return ""

    def format_header(self, markdown: str, metadata: Dict[str, str]) -> str:
        """generate markdown header, avoid repetition"""
        header = ""

        for key, value in metadata.items():
            if key in ["canonical", "category", "meta_img", "source_url"]:
                continue
            elif key == "title":
                if not value in markdown:
                    prefix = "# " if "\n# " not in markdown else ""
                    header += f"{prefix}{value}\n"
            elif key == "top_image":
                top_image = get_filename_from_url(value)
                if not top_image in markdown:
                    header += f"\n![{metadata.get('meta_img', '') or ''}]({value})\n"
            elif key == "authors":
                if value and not value in markdown:
                    header += f"\nBy {value}\n"
            elif isinstance(value, datetime):
                header += f'\n{value.strftime("%B %d, %Y")}\n'
            else:
                if not value in markdown:
                    header += f"\n{value}\n"

        header += "\n"
        return header

    def format_header_yaml(self, metadata: Dict[str, str]) -> str:
        yaml_content = yaml.dump(metadata, default_flow_style=False, allow_unicode=True)
        return f"---\n{yaml_content}---\n"

    def update_markdown_alttext(self, markdown: str, imgs: str) -> str:
        """update markdown image alt text with data from img tags"""
        if imgs:
            try:
                img_dict = json.loads(imgs)

                def replace_match(match):
                    alt_text = match.group(1)
                    url = match.group(2)
                    filename = os.path.basename(url.split("?")[0])

                    if self._should_replace_alt_text(alt_text):
                        matching_key = self._find_matching_img_src(filename, img_dict)
                        new_alt = img_dict.get(matching_key, "") if matching_key else ""
                        return f"![{new_alt}]({url})"

                    return match.group(0)

                markdown = re.sub(RE_IMAGE, replace_match, markdown)

            except Exception as e:
                log.warning(e)

        return markdown

    def update_markdown_imgs(self, markdown: str, nav_key: str) -> str:
        if nav_key:
            key = nav_key.split("/")[-1]
            markdown = markdown.replace("](images/", f"](images/{key}_")
        return markdown

    def _apply_markdown_edits(self, markdown: str, config: Dict[str, Any]) -> str:
        """apply edits to markdown"""
        try:
            edits = config.get(KEY_EXCLUDE, [])
            for i, edit in enumerate(edits):
                len0 = len(markdown)
                re_find = edit.get(KEY_FIND, "")
                re_count = edit.get(KEY_COUNT, 0)
                re_skip = edit.get(KEY_SKIP, 0)
                if re_find:
                    re_repl = edit.get(KEY_REPLACEMENT, "")
                    re_scope = edit.get(KEY_SCOPE, "")
                    try:
                        if re_scope:
                            scopes = self._get_scopes(re_scope, markdown) or [markdown]
                            for scope in scopes:
                                scoped = self._re_sub(
                                    re_find, re_repl, scope, re_count, re_skip
                                )
                                markdown = markdown.replace(scope, scoped)
                        else:
                            markdown = self._re_sub(
                                re_find, re_repl, markdown, re_count, re_skip
                            )

                    except Exception as e:
                        log.warning(e)

                    len1 = len(markdown)
                    if len1 < len0:
                        self.slog(SL.SUCCESS, track=re_find)
                    log.info(f"#{i} len: {len1} ↤ {len0}, [{repr(re_find)}]")

            return markdown
        except Exception as e:
            log.warning(e)
        return markdown

    def _re_sub(
        self, find: str, repl: str = "", scope: str = "", count: int = 0, skip: int = 0
    ) -> str:
        flags = get_regex_flags(find)
        scoped = self._re_sub_skip(find, repl, scope, count, skip, flags)
        """
        if skip <= 0:
            scoped = re.sub(find, repl, scope, count, flags)
        else:
            scoped = self._re_sub_skip(find, repl, scope, count, skip, flags)
        """

        if count == 1 and log.level <= logging.INFO:
            self.drive.write(self.path.corpus + DIFF_PRE, scope)
            self.drive.write(self.path.corpus + DIFF_PRO, scoped)
            self._diff(self.path.corpus + DIFF_PRE, self.path.corpus + DIFF_PRO)

        return scoped

    def _re_sub_skip(
        self,
        find: str,
        repl: str = "",
        scope: str = "",
        count: int = 0,
        skip: int = 0,
        flags: int = 0,
    ) -> str:
        """replace all matches except nth occurrence (1-indexed, negatives allowed)"""
        if skip == 0:
            return re.sub(find, repl, scope, count, flags)

        # map negative in reverse from last item index
        if skip < 0:
            total_matches = len(re.findall(find, scope, flags))
            if total_matches == 0:
                return scope

            # convert negative index to positive (1-indexed)
            skip = total_matches + skip + 1

            # if the converted skip is out of bounds, no skipping needed
            if skip <= 0:
                return re.sub(find, repl, scope, count, flags)

        match_num = 0

        def replace_func(match):
            nonlocal match_num
            match_num += 1
            return match.group(0) if match_num == skip else repl

        return re.sub(find, replace_func, scope, count, flags)

    def _diff(self, before_path: str = "", after_path: str = "") -> None:
        timestamp = datetime.now().strftime("%y%m%d_%H%M%S_%f")
        pre_path = f"{self.path.temp}{before_path or DIFF_PRE}"
        pro_path = f"{self.path.temp}{after_path or DIFF_PRO}"

        try:
            result = subprocess.run(
                [SP_DIFF, pre_path, pro_path],
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
            )

            self.drive.write(
                f"{self.path.temp}{SP_DIFF}.{timestamp}.txt",
                f"{SP_DIFF} {pre_path} vs {pro_path}\n\n{result.stdout}",
            )
        except FileNotFoundError:
            log.warning("subprocess command not found")

    def _get_scopes(self, pattern: str, html: str) -> Optional[List[str]]:
        """extract HTML content using tag pairs"""
        if not pattern:
            log.warning("missing pattern to search markdown with")
            return []

        return re.findall(pattern, html, get_regex_flags(pattern))

    def _find_matching_img_src(
        self, filename: str, img_dict: Dict[str, str]
    ) -> Optional[str]:
        """find img src that contains the given filename"""
        for src_key in img_dict:
            if filename in src_key:
                return src_key

        return None

    def _should_replace_alt_text(self, alt_text: str) -> bool:
        return not alt_text or "http" in alt_text
