# so you don't need to quote foward lookahead typing
from __future__ import annotations

import binascii
from collections.abc import Callable

# from typing import TYPE_CHECKING
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import BinaryIO, Generator

import aiofiles

from fluxvault.helpers import RemoteStateDirective
from fluxvault.log import log

# if TYPE_CHECKING:
#     from fluxvault.fluxapp import RemoteStateDirective

BUFFERSIZE = 1048576 * 50

### BUILD NOTES ###

# File or dir
# └── = last file no more dirs
# ├── = last dir no files

# Depth
# "│   "
# "│   │   "
# "│   │   "
# "│   │   │   "

# if last dir at depth:
# └── = dir
# direct children files have "    " instead of "|   " for parent

# any child depths (greater than current depth) have
# "    " instead of "│   " at left

# "    " = spacer
# "|   " = ancestor line

# is parent last sibling? Yes Then spacer at parent depth -1

# slots. Each height has depth -1 slots

# slots are either spacers or ancestors

# eg

# spacer ancestor
# spacer ancestor ancestor spacer

### /BUILD NOTES ###


class FileTooLargeError(Exception):
    """"""

    ...


class bcolors:
    HEADER = "\033[95m"
    OKBLUE = "\033[94m"
    OKCYAN = "\033[96m"
    OKGREEN = "\033[92m"
    WARNING = "\033[93m"
    FAIL = "\033[91m"
    ENDC = "\033[0m"
    BOLD = "\033[1m"
    UNDERLINE = "\033[4m"


def bytes_to_human(num, suffix="B"):
    for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]:
        if abs(num) < 1024.0:
            return f"{num:3.1f}{unit}{suffix}"
        num /= 1024.0
    return f"{num:.1f}Yi{suffix}"


class FsType(Enum):
    DIRECTORY = 1
    FILE = 2
    UNKNOWN = 3


FORMATS: dict[str, Callable] = {
    "root_path": lambda size, path: f"{bytes_to_human(size)} {bcolors.OKBLUE}{path}{bcolors.ENDC}",
    "root_path_last_file": lambda prefix, size, path: f"{prefix}└── {bytes_to_human(size)} {path.name}",
    "root_path_not_last_file": lambda prefix, size, path: f"{prefix}├── {bytes_to_human(size)} {path.name}",
    "dir_terminal": lambda prefix, size, path: f"{prefix}└── {bytes_to_human(size)} {bcolors.OKBLUE}{path.name}{bcolors.ENDC}",
    "dir_not_terminal": lambda prefix, size, path: f"{prefix}├── {bytes_to_human(size)} {bcolors.OKBLUE}{path.name}{bcolors.ENDC}",
    "file_terminal_dir_last_file": lambda prefix, size, path: f"{prefix}{'    '}└── {bytes_to_human(size)} {path.name}",
    "file_terminal_dir_not_last_file": lambda prefix, size, path: f"{prefix}{'    '}├── {bytes_to_human(size)} {path.name}",
    "file_not_terminal_dir_last_file": lambda prefix, size, path: f"{prefix}{'│   '}└── {bytes_to_human(size)} {path.name}",
    "file_not_terminal_dir_not_last_file": lambda prefix, size, path: f"{prefix}{'│   '}├── {bytes_to_human(size)} {path.name}",
}


@dataclass
class ConcreteFsEntry:
    path: Path
    parent: ConcreteFsEntry | None = None
    children: list[ConcreteFsEntry] = field(default_factory=list)
    children_index: dict[str, int] = field(default_factory=dict)
    depth: int = 0
    last_modified: int = 0
    fs_type: FsType = FsType.UNKNOWN
    size: int = 0
    fh: BinaryIO | None = None

    def __str__(self):
        """"""
        ancestor_symbols = {
            True: "    ",
            False: "│   ",
        }

        # object_symbols = {"not_last": "├── ", "last": "└── "}
        # file_symbols = {"not_last_dir": "│   ", "last_dir": "    "}

        files = [x for x in self.children if x.fs_type == FsType.FILE]
        dirs = [x for x in self.children if x.fs_type == FsType.DIRECTORY]
        is_last_sibling_dir = False

        ancestors = self.parents()

        slots = []
        for index, ancestor in enumerate(ancestors):
            if index == len(ancestors) - 1:
                target = self
            else:
                target = ancestors[index + 1]
            is_last_sibling_dir = ancestor.last_sibling_dir(target)
            slots.append(is_last_sibling_dir)

        terminal_dir = slots.pop() if slots else False
        prefix = "".join([ancestor_symbols[x] for x in slots])

        if self.depth == 0:
            dir_type = "root_path"
            params = [self.size, self.path]
        elif terminal_dir:
            dir_type = "dir_terminal"
            params = [prefix, self.size, self.path]
        else:
            dir_type = "dir_not_terminal"
            params = [prefix, self.size, self.path]

        directory = self.formatter(dir_type)(*params)

        formatted_files = []
        for index, file in enumerate(files):
            params = [prefix, file.size, file.path]
            if self.depth == 0:
                file_type = "root_path_not_last_file"
                if index + 1 == len(files) and not dirs:
                    file_type = "root_path_last_file"
            elif terminal_dir:
                file_type = "file_terminal_dir_not_last_file"
                if index + 1 == len(files) and not dirs:
                    file_type = "file_terminal_dir_last_file"
            else:
                file_type = "file_not_terminal_dir_not_last_file"
                if index + 1 == len(files) and not dirs:
                    file_type = "file_not_terminal_dir_last_file"
            formatted_files.append(self.formatter(file_type)(*params))

        # the str(d) list comp is recursive. I.e. - it calls this function
        return "\n".join([directory, *formatted_files, *[str(d) for d in dirs]])

    @classmethod
    def log_objects_in_dir(cls, dir: Path):
        if not dir.is_dir():
            return

        for child in dir.iterdir():
            log.info(f"Existing fake root object: {child} (before linking)")

    @classmethod
    def contains(cls, name: Path, dir: Path) -> bool:
        """If the directory contains the file name"""
        return (dir / name).exists()

    @classmethod
    def are_files_in_dir(cls, dir: Path) -> bool:
        if not dir.is_dir():
            return False

        return any(x.is_file() for x in dir.iterdir())

    @classmethod
    def entries_in_dir(cls, dir: Path) -> Generator[Path, None, None]:
        if not dir.is_dir:
            return []

        yield from dir.iterdir()

    @classmethod
    def find_first_path(cls, target: Path, locations: list[Path]) -> Path | None:
        """Pass in a path, it will return it if it exists, and if not,
        it will search in in paths (dirs) to find a name match. It found
        returns the full path"""

        first_match_dir = next(
            filter(lambda x: ConcreteFsEntry.contains(target, x), locations)
        )

        return first_match_dir

    @classmethod
    def build_tree(cls, base_path: Path, depth: int = 0) -> ConcreteFsEntry:
        children: list[ConcreteFsEntry] = []
        children_index = {}

        if not base_path.exists():
            return ConcreteFsEntry(base_path)

        if base_path.is_dir():
            for child in sorted(base_path.iterdir()):
                if child.is_dir():
                    fs_entry = ConcreteFsEntry.build_tree(child, depth + 1)
                elif child.is_file():
                    fs_entry = ConcreteFsEntry(
                        child, None, [], depth + 1, fs_type=FsType.FILE
                    )
                else:
                    raise Exception("FUCKED")

                children.append(fs_entry)

        parent = ConcreteFsEntry(
            base_path,
            parent=None,
            children=children,
            depth=depth,
            fs_type=FsType.DIRECTORY,
        )

        for index, child in enumerate(children):
            key = child.path.relative_to(parent.path)
            children_index[key] = index
            child.parent = parent

        parent.children_index.update(children_index)

        return parent

    @property
    def exists(self) -> bool:
        return self.path.exists()

    @property
    def empty(self) -> bool:
        """Are we empty"""
        return self.get_size == 0

    @property
    def child_dirs(self) -> bool:
        """Lets the caller know if this object has any children dirs"""
        return bool(len([x for x in self.children if x.path.is_dir()]))

    @property
    def readable(self) -> bool:
        """If this object can be called by read()"""
        return self.path.is_file()

    @property
    def storable(self) -> bool:
        """If this object can be used to store files"""
        return self.path.is_dir()

    @property
    def sibling_dirs(self) -> list:
        # this is wrong. use parent (it works but we should use our own interface)
        return self.path.is_dir() and any(
            [x for x in self.path.parent.iterdir() if x.is_dir() and x != self.path]
        )

    @staticmethod
    def formatter(section) -> Callable:
        return FORMATS[section]

    def get_child(self, target: Path) -> ConcreteFsEntry | None:
        relative_path = target.relative_to(self.path)

        if len(relative_path.parts) > 1:
            child_index = self.children_index.get(Path(relative_path.parts[0]))
            return self.children[child_index].get_child(target)

        if child_index := self.children_index.get(relative_path, None):
            return self.children[child_index]

    def get_partial_size(self, fragments: list[Path]) -> int:
        """Only makes sense to be called from a directory. However gets called
        recursively so will be for a file too"""

        sizes = []
        for target_path in fragments:
            if target_path == self.path:
                continue

            if target := self.get_child(target_path):
                sizes.append(target.size)
                continue

        return sum(sizes)

    def get_size(self) -> int:
        match self.fs_type:
            case FsType.FILE:
                return self.path.stat().st_size
            case FsType.DIRECTORY:
                return sum(
                    f.stat().st_size for f in self.path.glob("**/*") if f.is_file()
                )

    def root(self) -> ConcreteFsEntry:
        if self.depth == 0:
            return self

        ancestor_count = self.depth
        parent = None

        while ancestor_count > 0:
            ancestor_count -= 1
            if not parent:
                parent = self.parent
            else:
                parent = parent.parent

        return parent

    def parents(self) -> list[ConcreteFsEntry]:
        """Get all ancestors up the file tree, finishing at root"""
        parents: list[ConcreteFsEntry] = []
        ancestor_count = self.depth

        while ancestor_count > 0:
            ancestor_count -= 1
            if not parents:
                parents.append(self.parent)
            else:
                parents.insert(0, parents[0].parent)
        return parents

    def decendants(self) -> list[Path]:
        """Returns all child paths down the tree"""
        if self.path.is_dir():
            return [f for f in self.path.glob("**/*")]
        else:
            return []

    def last_sibling_dir(self, child: ConcreteFsEntry) -> bool:
        """Called from a child to a parent; Finds out if child is the last
        directory in parent's list of children"""
        if len(self.children):
            last = [x for x in self.children if x.fs_type == FsType.DIRECTORY][-1]
            return last.path == child.path
        return False

    def last_sibling(self, child: ConcreteFsEntry) -> bool:
        if len(self.children):
            last = self.children[-1]
            return last.path == child.path
        return False

    def recurse(self) -> Generator[ConcreteFsEntry, None, None]:
        yield self

        for child in self.children:
            yield from child.recurse()

    def realize(self):
        """Will populate FsEntry (and children) with live file details"""
        for child in self.children:
            child.realize()

        if self.readable:
            self.fs_type = FsType.FILE
            stat = self.path.stat()
            self.size = stat.st_size
            self.last_modified = stat.st_mtime

        elif self.storable:
            self.fs_type = FsType.DIRECTORY

            files_size = sum(f.size for f in self.children if f.readable)
            dirs_size = sum(d.size for d in self.children if d.storable)

            self.size = files_size + dirs_size
            self.last_modified = self.path.stat().st_mtime

    async def _reader(self, chunk_size: int) -> Generator[bytes, None, None]:
        if not self.fh:
            self.fh = await aiofiles.open(self.path, "rb").__aenter__()
        yield await self.fh.read(chunk_size)

    async def read(self, chunk_size: int | None = None) -> bytes:
        f"""Reads underlying file if entry is under {bytes_to_human(BUFFERSIZE)}, or
        reads up to chunk_size bytes. Uses a generator so file bytes aren't stored in buffer."""
        if chunk_size == None:  # reading until eof
            if self.size > BUFFERSIZE:
                raise FileTooLargeError(str(self.path))
        if not self.readable:
            raise FileNotFoundError(str(self.path))

        return await anext(self._reader(chunk_size))

    async def close(self):
        await self.fh.close()
        self.fh = None

    ### CRC OPERATIONS

    def crc_file(self, filename: Path, crc: int) -> int:
        crc = binascii.crc32(filename.name.encode(), crc)

        with open(filename, "rb") as f:
            for chunk in iter(lambda: f.read(1024 * 128), b""):
                crc = binascii.crc32(chunk, crc)

        return crc

    def crc_directory(self, directory: Path, crc: int, name: str = "") -> int:
        dir_name = name if name else directory.name
        crc = binascii.crc32(dir_name.encode(), crc)

        for path in sorted(directory.iterdir(), key=lambda p: str(p).lower()):
            crc = binascii.crc32(path.name.encode(), crc)

            if path.is_file():
                crc = self.crc_file(path, crc)
            elif path.is_dir():
                crc = self.crc_directory(path, crc)
        return crc

    def crc32(self, name: str = "") -> int:
        if self.fs_type == FsType.DIRECTORY:
            return self.crc_directory(self.path, 0, name=name)
        elif self.fs_type == FsType.FILE:
            return self.crc_file(self.path, 0)

    def get_file_hash(self, file: Path):
        crc = self.crc_file(file, 0)
        return {str(file): crc}

    def get_directory_hashes(
        self, dir: Path | None = None, name: str | None = None
    ) -> dict[str, int]:
        """Returns a dictionary of every fs object and it's corresponding crc32"""
        hashes = {}

        # fix all this... needs to hash the filename too, otherwise
        # we get the wrong hash if new empty file added
        if not dir and not self.storable:
            return hashes

        p = dir if dir else self.path

        dir_name = name if name else p.name

        crc = binascii.crc32(dir_name.encode())
        hashes.update({str(p): crc})

        for path in sorted(p.iterdir(), key=lambda p: str(p).lower()):
            if path.is_dir():
                hashes.update(self.get_directory_hashes(path))

            elif path.is_file():
                hashes.update(self.get_file_hash(path))

        return hashes


@dataclass
class FsEntryStateManager:
    """Wrapper around ConcreteFsEntry. An abstract Filesystem. Syncs state from local to remote
    objects. From Keeper point of view, the StreamWriter for the remote system
    is the filesystem"""

    name: Path  # dir or file
    remit: RemoteStateDirective
    local_parent: Path | None = None
    remote_workdir: Path = Path("/tmp")
    local_crc: int = 0
    remote_crc: int = 0
    validated_remote_crc: int = 0
    keeper_context: bool = True  # Eventually share these objects
    remote_object_exists: bool = False
    in_sync: bool = False
    concrete_fs: ConcreteFsEntry | None = None
    # root_name: str = ""

    @property
    def local_path(self):
        return self.local_parent / self.name

    @property
    def empty(self):
        return self.concrete_fs.empty

    @property
    def local_object_exists(self):
        return self.concrete_fs.exists

    @property
    def storable(self):
        return self.concrete_fs.storable

    @property
    def readable(self):
        return self.concrete_fs.readable

    @property
    def absolute_remote_path(self) -> Path:
        # this is ugly, fix
        # if self.concrete_fs.parent:
        #     print("CONCRETE FS PARENT", self.concrete_fs.parent.path)
        # else:
        #     print("NO CONCRETE FS PARENT")
        # print("NAME", self.name)
        # # this is the fileserver root
        # if self.concrete_fs.parent == None:

        #     return self.remote_workdir

        # fileserver
        # if self.root_name:
        #     return self.remote_workdir / self.root_name

        if self.concrete_fs.parent and self.concrete_fs.parent.path.name == "fake_root":
            return "/" / Path(self.name)

        match self.remit.remote_dir:
            case x if x and x.is_absolute():
                return x / self.name
            case x if x:
                return self.remote_workdir / self.remit.remote_dir / self.name
            case x if not x:
                return self.remote_workdir / self.name

    @property
    def absolute_remote_dir(self) -> Path:
        match self.remit.remote_dir:
            case x if x and x.is_absolute():
                return x
            case x if x:
                return self.remote_workdir / self.remit.remote_dir
            case x if not x:
                return self.remote_workdir

    def root(self) -> Path:
        return self.concrete_fs.root().path

    # just use property
    def set_syncronized(self):
        self.in_sync = True

    # BUILD THIS
    def serialize(self):
        ...

    def validate_local_object(self):
        # this doesn't make sense anymore, validation is done as part of instantiation
        # If we're the root object, fake the name for crc
        # if self.concrete_fs.depth == 0:
        #     self.local_crc = self.concrete_fs.crc32(name=self.name)
        # else:
        self.local_crc = self.concrete_fs.crc32()

    def compare_objects(self):
        """"""
        if not self.local_object_exists:
            return

        if not self.remote_crc:  # remote file crc is 0 if it doesn't exist
            self.remote_object_exists = False
            self.in_sync = False

            if self.local_crc:
                log.info(f"Agent needs new object {self.name}... sending")
                return

        self.remote_object_exists = True

        if self.remote_crc != self.local_crc:
            self.in_sync = False
            if (
                self.validated_remote_crc == self.remote_crc
                and self.local_object_exists
            ):
                log.info(
                    f"Agent remote object {self.name} is different than local object but has been validated due to sync strategy"
                )
                return

            if self.local_object_exists:
                log.info(
                    f"Remote object {self.name} CRC: {self.remote_crc} Local object CRC: {self.local_crc}... validating difference"
                )
                return

        if self.remote_crc == self.local_crc:
            # generates too many log messages
            # log.info(f"Agent object {self.name} is up to date... skipping!")
            self.in_sync = True


@dataclass
class FsStateManager:
    """File System State Manager"""

    managed_objects: list[FsEntryStateManager] = field(default_factory=list)
    directives: list[RemoteStateDirective] = field(default_factory=list)
    # local_workdir: Path | None = None
    # remote_workdir: Path | None = None

    def __iter__(self):
        yield from self.managed_objects

    @classmethod
    def filter_hierarchy(
        cls, current_path: Path, existing_paths: list[Path]
    ) -> list[Path]:
        # this needs heavy testing

        paths = existing_paths
        for existing_path in existing_paths:
            if current_path.is_relative_to(existing_path):
                # our ancestor is already in the list. We will get replaced
                # when they get synced (don't add ourselves)
                return paths

            elif existing_path.is_relative_to(current_path):
                # we are higher up the tree.. remove existing_path and
                # install ourselves
                paths.remove(existing_path)
                paths.append(current_path)
                return paths

        # sibling
        paths.append(current_path)

        return paths

    def set_syncronized(self, targets: list[Path]):
        for target in targets:
            if entry := self.get_object_by_local_path(target):
                entry.set_syncronized()

    def get_directive_by_path(self, path: Path) -> RemoteStateDirective | None:
        return next(
            filter(
                lambda x: x.content_source == path.parent and x.name == path.name,
                self.directives,
            ),
            None,
        )

    def get_directive_by_remote_path(self, path: Path) -> RemoteStateDirective:
        return next(
            filter(
                lambda x: x.remote_dir == path.parent and x.name == path.name,
                self.directives,
            ),
            None,
        )

    def add_directives(self, directives: list[RemoteStateDirective]):
        for directive in directives:
            self.add_directive(directive)

    def add_directive(self, directive: RemoteStateDirective):
        if isinstance(directive, RemoteStateDirective):
            self.directives.append(directive)
        else:
            log.warn(
                f"Directive: {directive} is not of type `RemoteStateDirective`. Skipping..."
            )

    def add_objects(self, objects: list):
        for obj in objects:
            self.add_object(obj)

    def add_object(self, fs_object: FsEntryStateManager):
        if isinstance(fs_object, FsEntryStateManager):
            self.managed_objects.append(fs_object)
        else:
            log.warn(
                f"FsObject: {fs_object} is not of type `FsEntryStateManager`. Skipping..."
            )

    def absolute_remote_dirs(self) -> list[Path]:
        return [x.absolute_remote_path for x in self.managed_objects if x.storable]

    def absolute_remote_paths(self) -> list[str]:
        return [str(x.absolute_remote_path) for x in self.managed_objects]

    # def merge_config(self, objects):
    #     for obj in objects:
    #         if isinstance(obj, RemoteStateDirective):
    #             self.managed_objects.append(obj)
    #         else:
    #             log.error(
    #                 f"Object of type {type(obj)} added to managed_objects, must be `RemoteStateDirective`"
    #             )

    def get_object_by_remote_path(self, remote: Path) -> FsEntryStateManager:
        # just lambda
        for fs_object in self.managed_objects:
            path = fs_object.absolute_remote_path

            if path == remote:
                return fs_object

    def get_object_by_local_path(self, local: Path) -> FsEntryStateManager:
        # just lambda
        for fs_object in self.managed_objects:
            path = fs_object.concrete_fs.path

            if path == local:
                return fs_object

    def get_all_objects(self) -> list[FsEntryStateManager]:
        return self.managed_objects

    def update_paths(self, local: Path, remote: Path | None = None):
        self.working_dir = local
        self.remote_workdir = remote

    def validate_local_objects(self):
        for obj in self.managed_objects:
            obj.validate_local_object()


# example (needs updated for parent, fh, last_modified, depth)

# blah = ConcreteFsEntry(
#     Path("/tmp/rangi"),
#     fs_type=FsType.DIRECTORY,
#     size=0,
#     children=[
#         ConcreteFsEntry(
#             Path("/tmp/rangi/bluht.txt"),
#             depth=1,
#             fs_type=FsType.FILE,
#             size=0,
#         ),
#         ConcreteFsEntry(
#             Path("/tmp/rangi/weiner"),
#             depth=1,
#             fs_type=FsType.FILE,
#             size=0,
#         ),
#         ConcreteFsEntry(
#             Path("/tmp/rangi/wrongo"),
#             depth=1,
#             fs_type=FsType.DIRECTORY,
#             size=0,
#             children=[
#                 ConcreteFsEntry(
#                     Path("/tmp/rangi/wrongo.job.exe"),
#                     depth=2,
#                     fs_type=FsType.FILE,
#                     size=0,
#                 )
#             ],
#         ),
#     ],
# )

if __name__ == "__main__":
    blimp = ConcreteFsEntry.build_tree(
        # Path("/Users/davew/.vault/gravyboat/components/fluxagent/fake_root/racing")
        # Path("/Users/davew/.vault")
        Path("/Users/davew/code/flux/fluxvault/LICENSE")
    )
    blimp.realize()
    print(blimp)

    # async def main():
    #     chug = ConcreteFsEntry(
    #         Path("/tmp/rangi/ubu/ubuntu-22.04.1-live-server-amd64.iso"),
    #         depth=0,
    #         fs_type=FsType.UNKNOWN,
    #         size=0,
    #     )
    #     print(await chug.read())
    #     print(await chug.read(5000))
    #     await chug.close()

    # asyncio.run(main())
