import json
import re
import time
import warnings
from functools import wraps
from hashlib import blake2b
from os.path import getsize
from pathlib import Path
from subprocess import run, PIPE
from typing import (
    Tuple,
    List,
    Callable,
    Iterable,
    Union,
    Literal,
    Any,
    Optional,
)
from xml.etree import ElementTree

import requests
import urllib.parse
from openpyxl import Workbook  # type: ignore
from openpyxl.utils import get_column_letter  # type: ignore
from requests import post, HTTPError, ConnectionError

from urllib3.connectionpool import InsecureRequestWarning

from websites_test_framework.custom_types import PathLike, HtmlValidationResult
from websites_test_framework.param import (
    HTML_VALIDATOR_URL,
    CSS_VALIDATOR_URL,
    CHECK_METHOD,
    BASE_PATH,
    BASE_URL,
    MAX_TRY,
    CACHE_FOLDER,
)

# STUDENTS = "/home/nicolas/Dropbox/Travail/ADMIN/listings/TDFT01-130.csv"
CACHE_HTML_VALIDATION = "/home/manager/cache_html_validation.json"
CACHE_CSS_VALIDATION = "/home/manager/cache_css_validation.json"

try:
    with open(CACHE_HTML_VALIDATION) as _cache_html:
        JSON_HTML_VALIDATION = json.load(_cache_html)
except FileNotFoundError:
    JSON_HTML_VALIDATION = {}

try:
    with open(CACHE_CSS_VALIDATION) as _cache_css:
        JSON_CSS_VALIDATION = json.load(_cache_css)
except FileNotFoundError:
    JSON_CSS_VALIDATION = {}


# SELF_CLOSING_TAGS = ("area", "base", "br", "embed", "hr", "iframe", "img", "input")


# -------------------------
#  TOOLS FOR PYTHON < 3.10
# -------------------------


def cached(f):
    @wraps(f)
    def cached_f(self=None, *args, **kw):
        key = f.__name__
        try:
            if key in self._cache:
                return self._cache[key]
        except AttributeError:
            self._cache = {}
        result = f(self, *args, **kw)
        self._cache[key] = result
        return result

    return cached_f


def cached_property(f):
    return property(cached(f))


def is_relative_to(path: PathLike, root: PathLike) -> bool:
    """Function similar to Path.is_relative_to(), which is only available for python >= 3.9."""
    path = Path(path).resolve()
    root = Path(root).resolve()
    try:
        path.relative_to(root)
        return True
    except ValueError:
        return False


# ------------------
#  VALIDATION TOOLS
# ------------------


def hash_file(path: Path) -> str:
    with open(path, "rb") as file:
        return blake2b(file.read(), digest_size=32).hexdigest()


def cache_result_as_json_file(html_or_css: Literal["html", "css"]) -> Callable:
    def decorator(f: Callable[[PathLike], Any]):
        @wraps(f)
        def cached_f(filename: PathLike):
            filename = as_path(filename)
            cache_path = as_path(CACHE_FOLDER) / html_or_css / f"{hash_file(filename)}.json"
            if cache_path.is_file():
                # Lecture dans le CACHE si une précédente passe a eu lieu.
                result = json.loads(cache_path.read_text(encoding="utf8"))
            else:
                result = f(filename)
                cache_path.parent.mkdir(parents=True, exist_ok=True)
                cache_path.write_text(json.dumps(result), encoding="utf8")
            return result

        return cached_f

    return decorator


@cache_result_as_json_file("html")
def validate_html(filename: PathLike) -> HtmlValidationResult:
    """Renvoie la liste des erreurs et celle des avertissements émis
    par le validateur HTML du W3C."""
    if CHECK_METHOD == "get": # Get method
        uri = str( filename).replace( BASE_PATH, BASE_URL)
        payload={ "parser" : "html5", "doc" : uri, "out" : "json"}
        for _ in range(MAX_TRY):
            try:
                time.sleep(1)
                r = requests.get( HTML_VALIDATOR_URL, params=payload)
                r.raise_for_status()
                break
            except HTTPError:
                print(f"Erreur de validation de {filename!r}. Nouvel essai...")
    else: # Post method
        for _ in range(MAX_TRY):
            try:
                time.sleep(1)
                with open(filename, "rb") as f:
                    r = requests.post(
                        HTML_VALIDATOR_URL,
                        data={ "parser" : "html5", "out": "json"},
                        files={ "file": ("file.html", f)}
                    )
                r.raise_for_status()
                break
            except HTTPError:
                print(f"Erreur de validation de {filename!r}. Nouvel essai...")
    messages = r.json()["messages"]
    errors_list = [msg["message"] for msg in messages if msg["type"] == "error"]
    warnings_list = [msg["message"] for msg in messages if msg.get("subType") == "warning"]
    assert isinstance(errors_list, list)
    assert all(isinstance(error, str) for error in errors_list)
    assert isinstance(warnings_list, list), warnings_list
    assert all(isinstance(warning, str) for warning in warnings_list)
    return {"errors": errors_list, "warnings": warnings_list}


@cache_result_as_json_file("css")
def validate_css(filename: PathLike) -> int:
    """Renvoie le nombre d'erreurs CSS détectées."""
    if CHECK_METHOD == "get": # Get method
        uri = str( filename).replace( BASE_PATH, BASE_URL)
        payload={ "profile": "css3svg", "uri": uri, "output": "soap12"}
        for _ in range(MAX_TRY):
            try:
                time.sleep(1)
                text = requests.get( CSS_VALIDATOR_URL, params=payload).text
                break
            except ConnectionError:
                print( f"Erreur de validation de {filename!r}. Nouvel essai...")
    else: # Post method
        for _ in range(MAX_TRY):
            try:
                time.sleep(1)
                with open(filename, "rb") as f:
                    payload={ "profile": "css3svg", "text": f.read(), "output": "soap12"}
                    text = post(
                        CSS_VALIDATOR_URL,
                        data=payload
                    ).text
                break
            except ConnectionError:
                print(f"Erreur de validation de {filename!r}. Nouvel essai...")
    match = re.search("<m:errorcount>([0-9]+)</m:errorcount>", text)
    assert match is not None, repr(text)
    return int(match.group(1))


def is_valid_xml(html_file: Path) -> Tuple[bool, str]:
    """Test if HTML file is also a valid XML file.

    `path` is the path of an HTML file.

    Return the result of the test (True/False) and a message.
    """

    try:
        with open(html_file) as f:
            lines = f.readlines()
        # Search for first nonempty line.
        # This should be Doctype line.
        for i, line in enumerate(lines):
            if line.strip():
                break
        else:
            # Only empty lines
            return False, f"Empty file: {html_file.name}"
        # cf. https://bit.ly/2T8eEVX
        # You can define more entities here, if needed.
        lines[i] = (
            "<!DOCTYPE html ["
            "<!ENTITY nbsp ' '> "
            "<!ENTITY commat '@'>"
            "<!ENTITY gt '>'>"
            "<!ENTITY lt '<'>"
            "<!ENTITY eacute 'é'>"
            "]>\n"
        )
        ElementTree.fromstring("".join(lines))
        return True, ""

    except (ElementTree.ParseError, UnicodeError) as error:
        # warn("Invalid XHTML", str(html_file))
        # print(error)
        return False, error.args[0]


def detect_encoding(filename: str) -> str:
    """Detect and return encoding of file."""
    stdout = run(["file", "--mime-encoding", filename], stdout=PIPE).stdout
    if getsize(filename) == 0:
        return "utf-8"
    encoding = stdout.split(b":")[1].decode().strip()
    if encoding == "us-ascii":
        return "utf-8"
    return encoding


# --------------------
#  XLSX Files support
# --------------------


def list2xlsx(data: Iterable[Iterable[Union[str, float]]], xlsx_file: PathLike):
    """Write a matrix (list of lists) to an XLSX (Excel) file."""
    # Fill Workbook
    wb = Workbook()
    ws = wb.active
    for i, row in enumerate(data, start=1):
        for j, val in enumerate(row, start=1):
            ws.cell(row=i, column=j).value = val

    # Adapt column widths
    def size(value) -> int:
        if isinstance(value, float):
            value = round(value, 6)
        return len(str(value))

    column_widths: List[int] = []
    for row in data:
        for i, val in enumerate(row):
            if i < len(column_widths):
                if size(val) > column_widths[i]:
                    column_widths[i] = size(val)
            else:
                column_widths += [size(val)]
    for i, column_width in enumerate(column_widths, 1):  # ,1 to start at 1
        ws.column_dimensions[get_column_letter(i)].width = column_width
    # Save Workbook
    wb.save(str(xlsx_file))
    wb.close()


# -------
#  OTHER
# -------


def is_url_relative(url: str) -> bool:
    # "\w+:" http:, https:, mailto:, file: ...
    url = url.strip()
    return re.match(r"\w+:", url) is None and not url.startswith("/")


def url_exists(url: str) -> Optional[bool]:
    # urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    with warnings.catch_warnings():
        warnings.filterwarnings("ignore", category=InsecureRequestWarning)
        try:
            with requests.get(url, stream=True, timeout=20, verify=False) as response:
                status = response.status_code
                if status >= 500 or status == 403:
                    return None
                return status < 400
        except requests.exceptions.ConnectionError:
            return False
        except requests.exceptions.ReadTimeout:
            return None


def as_path(path: PathLike) -> Path:
    return Path(path).expanduser().resolve()


def warn(title: str = "", msg: str = "") -> None:
    """Print a warning."""
    print(f"\033[43;1mWARNING{' - ' if title else ''}{title}{':' if msg else ''}\033[0m {msg}")


def right_replace(string: str, old: str, new: str, count: int = -1) -> str:
    """Works like python builtin str.replace(), but replace from right to left."""
    return new.join(string.rsplit(old, count))


class LastOrderedSet:
    """Un ordered set minimalist implementation, based on a dict.

    Implemented methods:
    - add(element) : add element at the end of the set
    - pop() : remove first element of the set

    If an already present value is added to the ordered set, it will be moved to its end.

    (It is also iterable and have its length defined.)
    """

    def __init__(self, *args):
        self._dict = dict.fromkeys(args)

    def add(self, elt):
        if elt in self._dict:
            del self._dict[elt]
        self._dict[elt] = None

    def pop(self):
        try:
            element = next(iter(self._dict))
            del self._dict[element]
            return element
        except StopIteration:
            raise KeyError("pop from an empty set")

    def __iter__(self):
        return iter(self._dict)

    def __len__(self):
        return len(self._dict)

    def __repr__(self):
        return f"LastOrderSet({','.join(repr(elt) for elt in self)})"


# def isHTMLfile(name: str) -> bool:
#     return name.lower().endswith(".html") and not detect_encoding(name) == "binary"
#
#
# def isCSSfile(name: str) -> bool:
#     return name.lower().endswith(".css")


# def HTML_validity(html_files):
#     result = {"errors": 0, "warnings": 0}
#     for (fullpath, name) in html_files:
#         errors, warnings = validate_html(fullpath)
#         if errors or warnings:
#             print(
#                 f"File {basename(name)!r}: {len(errors)} errors "
#                 f"and {len(warnings)} warnings found."
#             )
#             for error in errors:
#                 print(" • \033[31mERROR:\033[0m " + error)
#             filtered_warnings = []
#             for warning in warnings:
#                 if warning.startswith("This document appears to be written in"):
#                     # This is a known false positive.
#                     # https://productforums.google.com/forum/#!topic/webmasters/gSzE2PlSlUY
#                     print(" • \033[33mINFO:\033[0m " + warning)
#                 else:
#                     print(" • \033[33mWARNING:\033[0m " + warning)
#                     filtered_warnings.append(warning)
#             warnings = filtered_warnings
#         else:
#             print(f"File {name!r}: \033[32mOK\033[0m")
#         result["errors"] += len(errors)
#         result["warnings"] += len(warnings)
#     return {"Erreurs HTML": result["errors"], "Warnings HTML": result["warnings"]}

# def extract_authors(path: str) -> List[str]:
#     """Return the list of the authors.
#
#     Search if a file named `auteurs.txt` is present, if so read its content.
#     Else, extract student name from path.
#     """
#     content = listdir(path)
#     if "web" in content:
#         web_path = join(path, "web")
#     else:
#         for name in content:
#             if isdir(join(path, name)):
#                 break
#         else:
#             raise RuntimeError("No directory found !")
#         web_path = join(path, name)
#
#     def is_authors_file(filename: str) -> bool:
#         return basename(filename).lower() in (
#             "auteurs.txt",
#             "auteur.txt",
#             "auteurs.txt.txt",
#             "auteur.txt.txt",
#         )
#
#     for authors_file, _ in walker(web_path, is_authors_file):
#         with open(authors_file) as f:
#             return list(f)
#     return [extract_student_name(basename(path), reverse=True)]


# def html_stats(filename: str) -> Dict[str, int]:
#     """Return a dict {html_tag: how many occurrences}."""
#     encoding = detect_encoding(filename)
#     try:
#         with open(filename, encoding=encoding) as f:
#             txt = f.read()
#     except (LookupError, UnicodeError):
#         print(f"ERROR: Filename {filename!r} - Encoding: {encoding!r}.")
#         return {}
#
#     html_tags = re.findall("(?<=<)[A-Za-z]+[1-6]?", txt)
#     occurrences = dict.fromkeys(html_tags, 0)
#     for tag in html_tags:
#         occurrences[tag] += 1
#     return occurrences


# def extract_student_name(folder_name: str, reverse=False) -> str:
#     """Search for student name at the beginning of dirname.
#
#     >>> name_ = "Alan picard dupont_7662_assignsubmission_file_"
#     >>> extract_student_name(name_)
#     'Alan Picard Dupont'
#     >>> extract_student_name(name_, reverse=True)
#     'Picard Dupont Alan'
#     """
#     m = re.match("[A-Za-z- ]*", basename(folder_name))
#     assert m is not None
#     name = " ".join(s.capitalize() for s in m.group().split()).strip()
#     if not name:
#         warn(f"empty student name for {folder_name!r} directory !")
#         raise RuntimeError("Student name not found !")
#     if reverse:
#         try:
#             surname, name = name.split(" ", 1)
#             name = " ".join([name, surname])
#         except ValueError:
#             warn(f"Can't extract surname and name from {name!r}.")
#     return name


# def walker(
#     dirname: str, filter_func: Callable[[str], bool], skip_dirs: Iterable = ()
# ) -> Iterator[Tuple[str, str]]:
#     """Generate an iterator through files of dirname.
#
#     The iterator returns (fullpath, name) for each file
#     of the directory.
#
#     Filter is a function which returns a boolean value."""
#     # print('SKIP DIRS:', skip_dirs)
#     for folder, folders, filenames in walk(dirname):
#         if basename(folder).lower() in skip_dirs:
#             print(f"Skipping {basename(folder)!r}")
#             folders.clear()
#             continue
#         print(f"Reading {basename(folder)!r}")
#         for name in filenames:
#             fullpath = join(folder, name)
#             if filter_func(fullpath):
#                 yield fullpath, name
