from __future__ import annotations

import itertools
import os
import random
import shutil
import sys
import unicodedata
from collections import deque
from contextlib import contextmanager
from pathlib import Path
from typing import Set, ContextManager, Union, Dict, Tuple

from clarabot_nano_common.utility.path.error import coerce_platform_os_error
from clarabot_nano_common.utility.path.resolve import resolve_path
from clarabot_nano_common.utility.path.unicode import PLATFORM_UNICODE_NORMALIZATION

from clarabot_nano_tests.testing.file import temp_path

ACUTE_E_NFD = b"e\xcc\x81".decode("utf-8")
ACUTE_E_NFC = b"\xc3\xa9".decode("utf-8")


def get_platform_alternative_acute_e() -> Tuple[str, str]:
    if unicodedata.normalize(PLATFORM_UNICODE_NORMALIZATION, ACUTE_E_NFC) == ACUTE_E_NFC:
        return ACUTE_E_NFC, ACUTE_E_NFD
    return ACUTE_E_NFD, ACUTE_E_NFC


if sys.platform.startswith("win"):

    def is_remote_security_set(p):
        try:
            with open(f"{p}:Zone.Identifier", "rb") as f:
                return f.read() == b"[ZoneTransfer]\r\nZoneId=3\r\n"
        except OSError:
            return False

elif sys.platform.startswith("linux"):

    def is_remote_security_set(p):
        return (os.lstat(p).st_mode & 0o111) == 0

elif sys.platform.startswith("darwin"):
    # TODO: research using xattr com.apple.metadata:kMDItemWhereFroms with plistlib additionally
    def is_remote_security_set(p):
        return (os.lstat(p).st_mode & 0o111) == 0

else:
    raise NotImplementedError()


class ComplexStruct(object):
    @classmethod
    def new(cls, tmpdir) -> ComplexStruct:
        _r = resolve_path(str(temp_path(tmpdir)))
        _r.mkdir()
        cs = cls(_r)
        cs.generate()
        return cs

    @classmethod
    @contextmanager
    def create(cls, tmpdir, keep=True) -> ContextManager[ComplexStruct]:
        cs = cls.new(tmpdir)
        try:
            yield cs
        finally:
            if not keep:
                shutil.rmtree(cs.root)

    def __init__(self, r: Path):
        self._r = r
        self._item_id = 10001
        self._dirs_by_name = dict()
        self._files_by_name = dict()
        self._links_by_name = dict()
        self.missing_by_name = dict()

    @property
    def root(self):
        return self._r

    def all_by_name_iter(self):
        return itertools.chain(self._dirs_by_name.items(), self._files_by_name.items(), self._links_by_name.items())

    def _next_item_name(self):
        tmp = self._item_id
        self._item_id += 1
        return str(tmp)

    def generate(self, levels=(2, 3), branches=(2, 4), files=(2, 4), link_iter=3):
        self._generate_raw(self._r, 1, levels, branches, files)
        # generate links for the root dirs
        dirs = []
        for item in self._r.iterdir():
            if not item.is_symlink() and item.is_dir():
                dirs.append(item)
        for d in dirs:
            for _ in range(link_iter):
                self.build_links(d)

    def _get_shuffled_keys(self, d):
        keys = deque(d.keys())
        random.shuffle(keys)
        return keys

    def build_links(self, path: Path, dirs=(1, 2), links=(1, 2)):
        in_dirs, out_dirs = self.split_by_containment(path, self._dirs_by_name)
        in_files, out_files = self.split_by_containment(path, self._files_by_name)
        # create random queues for all groups
        in_dir_keys = self._get_shuffled_keys(in_dirs)
        in_file_keys = self._get_shuffled_keys(in_files)
        out_dir_keys = self._get_shuffled_keys(out_dirs)
        out_file_keys = self._get_shuffled_keys(out_files)

        def _rot_get(keys):
            k = keys.popleft()
            keys.append(k)
            return k

        def _r_gc(keys, coll):
            k = keys.popleft()
            keys.append(k)
            return coll[k]

        # to chain symlinks collect them
        in_from_in = deque()
        in_from_out = deque()
        # create symlinks
        for parent_fn, src_fn, res_coll in [
            (
                lambda: _r_gc(in_dir_keys, in_dirs),
                lambda: _r_gc(in_file_keys, in_files) if random.random() > 0.333 else _r_gc(in_dir_keys, in_dirs),
                in_from_in,
            ),  # inside -> inside
            (
                lambda: _r_gc(in_dir_keys, in_dirs),
                lambda: _r_gc(out_file_keys, out_files) if random.random() > 0.333 else _r_gc(out_dir_keys, out_dirs),
                None,
            ),  # inside -> outside
            (
                lambda: _r_gc(out_dir_keys, out_dirs),
                lambda: _r_gc(in_file_keys, in_files) if random.random() > 0.333 else _r_gc(in_dir_keys, in_dirs),
                in_from_out,
            ),  # outside -> inside
            (
                lambda: _r_gc(out_dir_keys, out_dirs),
                lambda: _r_gc(out_file_keys, out_files) if random.random() > 0.333 else _r_gc(out_dir_keys, out_dirs),
                None,
            ),  # outside -> outside
        ]:
            for _ in range(random.randint(*dirs)):
                parent_path = parent_fn()
                for _ in range(random.randint(*links)):
                    try:
                        link = self._generate_link(parent_path, src_fn())
                    except IndexError:
                        pass
                    else:
                        if res_coll is not None:
                            res_coll.append(link)
        # be insane and chain some links
        for _ in range(random.randint(*links)):
            try:
                src = _rot_get(in_from_in)
            except IndexError:
                break
            self._generate_link(_r_gc(in_dir_keys, in_dirs), src)
        for _ in range(random.randint(*links)):
            try:
                src = _rot_get(in_from_out)
            except IndexError:
                break
            self._generate_link(_r_gc(in_dir_keys, in_dirs), src)
        # add some broken links
        for _ in range(random.randint(*dirs)):
            try:
                link = self._generate_link(_r_gc(in_dir_keys, in_dirs), str(_r_gc(in_file_keys, in_files)) + "x")
                self.missing_by_name[link.name] = link
            except IndexError:
                pass
            try:
                link = self._generate_link(_r_gc(in_dir_keys, in_dirs), self._next_item_name())
                self.missing_by_name[link.name] = link
            except IndexError:
                pass
            try:
                link = self._generate_link(_r_gc(in_dir_keys, in_dirs), "../" + self._next_item_name())
                self.missing_by_name[link.name] = link
            except IndexError:
                pass
        # add some quirky relative links
        for _ in range(random.randint(*dirs)):
            try:
                self._generate_link(_r_gc(in_dir_keys, in_dirs), "..")
            except IndexError:
                pass
            # relative
            try:
                self._generate_link(_r_gc(in_dir_keys, in_dirs), _r_gc(in_dir_keys, in_dirs), path)
            except IndexError:
                pass
            try:
                self._generate_link(_r_gc(in_dir_keys, in_dirs), _r_gc(in_file_keys, in_files), path)
            except IndexError:
                pass
            try:
                self._generate_link(_r_gc(in_dir_keys, in_dirs), _r_gc(out_dir_keys, out_dirs), self._r)
            except IndexError:
                pass
            try:
                self._generate_link(_r_gc(in_dir_keys, in_dirs), _r_gc(out_file_keys, out_files), self._r)
            except IndexError:
                pass

    def _generate_raw(self, parent: Path, depth, levels, branches, files):
        if random.random() > 0.5:
            self._generate_dir(parent)  # create empty dir
        if depth < random.randint(*levels):
            for _ in range(random.randint(*branches)):
                d = self._generate_dir(parent)
                self._generate_raw(d, depth + 1, levels, branches, files)
        for _ in range(random.randint(*files)):
            self._generate_file(parent)

    def _generate_dir(self, parent: Path) -> Path:
        name = self._next_item_name()
        new_dir = parent / name
        new_dir.mkdir()
        self._dirs_by_name[name] = new_dir
        return new_dir

    def _generate_file(self, parent: Path) -> Path:
        name = self._next_item_name()
        new_file = parent / name
        new_file.write_text(name)
        self._files_by_name[name] = new_file
        return new_file

    def _generate_link(self, parent: Path, src: Union[str, Path], relative_root=None) -> Path:
        if relative_root is not None:
            if parent == src:
                return
            assert not isinstance(src, str)
            tmp = [".."] * len(parent.relative_to(relative_root).parts)
            tmp.extend(src.relative_to(relative_root).parts)
            assert tmp, f"{parent}\n{src}\n{relative_root}\n{tmp}"
            src = os.path.sep.join(tmp)
        name = self._next_item_name()
        new_link = parent / name
        if sys.platform.startswith("win"):
            # NOTE: stripping the extended prefix from the parent, makes relative-directives normalizable by OS
            px = parent.as_posix()
            px = Path(px[4:] if px.startswith("//?/") else px)
            os.symlink(src, new_link, target_is_directory=os.path.isdir(os.path.realpath(px / src)))
        else:
            os.symlink(src, new_link)
        self._links_by_name[name] = new_link
        return new_link

    def split_by_containment(self, path: Path, coll: dict):
        path = resolve_path(path, True)  # from link target
        inside = dict()
        outside = dict()
        for n, p in coll.items():
            if len(path.parts) <= len(p.parts):
                try:
                    p.relative_to(path)
                except ValueError:
                    outside[n] = p
                    continue
                inside[n] = p
        return inside, outside

    def get_allowed_items(self, path: Path, allow_symlinks: bool, contain_symlinks: bool) -> Tuple[Set[str], Set[str]]:
        names = {path.name}  # include chroot
        # NOTE: root may be a symlink regardless of allow_symlinks
        in_dirs, in_files, in_links, un_links = self.get_allowed_paths_by_type(path, allow_symlinks, contain_symlinks)
        names.update(in_dirs.keys())
        names.update(in_files.keys())
        names.update(in_links.keys())
        return names, set(un_links.keys())

    def get_allowed_paths_by_type(
        self, path: Path, allow_symlinks: bool, contain_symlinks: bool
    ) -> Tuple[Dict[str, Path], Dict[str, Path], Dict[str, Path], Dict[str, Path]]:
        in_dirs, _ = self.split_by_containment(path, self._dirs_by_name)
        in_files, _ = self.split_by_containment(path, self._files_by_name)
        in_links, _ = self.split_by_containment(path, self._links_by_name)
        un_links = dict()
        if not allow_symlinks:
            un_links.update(in_links)
        else:
            for name, lp in list(in_links.items()):
                try:
                    with coerce_platform_os_error():
                        lp_src = resolve_path(lp, True)
                except FileNotFoundError:
                    un_links[name] = lp
                else:
                    if contain_symlinks:
                        # NOTE: works because all item names are unique
                        if all(lp_src.name not in names for names in (in_dirs, in_files, in_links)):
                            un_links[name] = lp
                            continue  # target is not inside
                    in_links[name] = lp
                    if all(lp_src.name not in names for names in (in_dirs, in_files, in_links)):
                        self._collect_all_items_under(lp_src, in_dirs, in_files, in_links, un_links)
        return in_dirs, in_files, in_links, un_links

    def _collect_all_items_under(
        self,
        path: Path,
        in_dirs: Dict[str, Path],
        in_files: Dict[str, Path],
        in_links: Dict[str, Path],
        un_links: Dict[str, Path],
    ):
        # path is resolved
        if path.is_file():
            in_files[path.name] = path
        elif path.is_dir():
            in_dirs[path.name] = path
            for item in path.iterdir():
                if all(item.name not in names for names in (in_dirs, in_files, in_links)):
                    if item.is_symlink():
                        in_links[item.name] = item
                        try:
                            with coerce_platform_os_error():
                                item_src = resolve_path(item, True)
                        except FileNotFoundError:
                            un_links[item.name] = item
                        else:
                            self._collect_all_items_under(item_src, in_dirs, in_files, in_links, un_links)
                    else:
                        self._collect_all_items_under(item, in_dirs, in_files, in_links, un_links)
        else:
            assert False

    def split_roots_by_validity(self):
        valid = list(self._dirs_by_name.values())
        invalid = list(self._files_by_name.values())
        for link in self._links_by_name.values():
            try:
                with coerce_platform_os_error():
                    if resolve_path(link, True).is_dir():
                        valid.append(link)
                    else:
                        invalid.append(link)
            except FileNotFoundError:
                invalid.append(link)
        return valid, invalid
