import os
import stat
from pathlib import Path
from typing import Iterable, Tuple, Sequence, Optional, Dict, List

import aiohttp
from clarabot_nano_common.indexer.utility import create_accessor_path
from clarabot_nano_common.utility.file import stat_equals
from clarabot_nano_common.utility.path.resolve import resolve_path

from clarabot_nano.indexer.config import IndexerConfig
from clarabot_nano.indexer.extractor.source import FileExtractionSource


class ImageBucketSelector(object):
    """
    Handle image (thumbnail) bucketing logic with reasonable limits and eventual consistency, meaning the limits are
    soft and may be slightly over-stepped in case of some commit scheduling race condition.
    NOTE: In case of intensively edited and long-standing directories, strong bucket fragmentation is possible, when
    lots of old images are removed and not many new ones are added, while some are kept forever. This could be
    optimized by the indexer by completely re-building the bucketing under a directory later.
    """

    __slots__ = ("buckets", "builder")

    BUCKET_MAX_SIZE = 2000  # ~12 chunks (6MB for 500KB chunk-size) for avg 3KB thumbnails

    @classmethod
    def from_facets(cls, facet_buckets: Iterable[Tuple[str, int]] = ()):
        buckets = {int(b): c for b, c in facet_buckets}
        builder = buckets.pop(0, None)  # bucket 0 is reserved for storing the selected builder
        return cls(buckets, builder)

    @classmethod
    def from_docs(cls, docs: Sequence[dict], builder: Optional[int]):
        buckets = {}
        for d in docs:
            if b := d.get("img_bucket"):
                buckets[b] = buckets.get(b, 0) + 1
        return cls(buckets, builder)

    def __init__(self, buckets: Dict[int, int], builder: Optional[int]):
        self.buckets = buckets
        self.builder = builder

    def _get_builder_bucket(self) -> int:
        if self.builder is None:
            pass  # go to selecting one; this should only happen when there are no buckets yet at all
        elif self.buckets.get(self.builder, 0) < self.BUCKET_MAX_SIZE:
            return self.builder  # return already selected builder
        # select new builder, preferring a shrunk existing bucket if any
        sel_b = None
        sel_c = self.BUCKET_MAX_SIZE
        for b, c in self.buckets.items():
            if c < sel_c:
                sel_c = c
                sel_b = b
        if sel_b is None:  # no bucket to continue building
            sel_b = 1
            while sel_b in self.buckets:  # select first empty bucket
                sel_b += 1
        self.builder = sel_b
        return self.builder

    def acquire_bucket(self) -> Tuple[int, bool]:
        """
        Select a bucket for a newly indexed image document.
        Return a boolean indicating if anew builder was selected or not.
        """
        tmp = self.builder
        b = self._get_builder_bucket()
        self.buckets[b] = self.buckets.get(b, 0) + 1
        return b, tmp == b

    def free_bucket(self, b: Optional[int]) -> None:
        v = self.buckets.get(b, 0)
        if v > 0:
            self.buckets[b] = v - 1


class SyncFilesystemItem(object):
    __slots__ = ("name", "path", "stat", "accessor_path", "posix_path")

    def __init__(self, name: str, path: Path, s: os.stat_result, accessor_path: str):
        # Name that is used in the accessor path. It is the name of the symlink if the item is a symlink for example.
        self.name = name
        # Path is resolved. If item is a symlink this path may not be the child of the parent directory from listing.
        self.path = path
        # The path's stat.
        self.stat = s
        # The accessor paths are pre-generated for checking commit state.
        self.accessor_path = accessor_path
        # Posix representation of the path.
        self.posix_path = self.path.as_posix()

    def create_extraction_source(self, session: aiohttp.ClientSession, ec: IndexerConfig) -> FileExtractionSource:
        return FileExtractionSource(session, ec, self.name, self.path, self.stat)


class SyncFilesystemParent(object):
    """
    Collect and prepare items in a directory for synchronization.
    """

    __slots__ = ("root_path", "rel", "path", "accessor_path", "posix_path", "items", "bucket_selector")

    def __init__(self, root_path: str, rel: str, path: Path):
        # Root is resolved from a chroot.
        self.root_path = root_path
        # The relative path that this object is listing the children of.
        self.rel = rel
        # Resolved path of root+rel that is a directory.
        self.path = path
        # Accessor path of the parent/directory.
        self.accessor_path = create_accessor_path(self.root_path, self.rel)
        # Posix representation of the path.
        self.posix_path = self.path.as_posix()
        # NOTE: could use SortedList but memory conservation seems more beneficial
        self.items: List[SyncFilesystemItem] = []
        # The bucket selector is dynamically initialised depending on the type of the update.
        self.bucket_selector: Optional[ImageBucketSelector] = None

    def add_item(self, name: str, path: Path, s: os.stat_result) -> SyncFilesystemItem:
        assert not stat.S_ISLNK(s.st_mode)
        item = SyncFilesystemItem(name, path, s, create_accessor_path(self.root_path, self.rel, name))
        self.items.append(item)
        return item

    def free_bucket(self, b: Optional[int]) -> None:
        if b and self.bucket_selector:
            self.bucket_selector.free_bucket(b)

    def get_child_dirs(self) -> List[str]:
        rel = self.rel
        if rel:
            return [f"{rel}/{item.name}" for item in self.items if stat.S_ISDIR(item.stat.st_mode)]
        return [item.name for item in self.items if stat.S_ISDIR(item.stat.st_mode)]

    def get_child_dirs_and_already_synchronized(
        self, queued_count: int, queued_paths: Iterable[str], minimal_items_to_start_scan: int = 64
    ) -> Tuple[List[str], List[str]]:
        """
        The caller is responsible to make sure the synchronization of this FS item is successful, it is for the
        root_path+rel and all paths are relative paths under said root.
        Iterate over the entries in the provided iterator under the root, collect the child directories for follow-ups
        and merge-check it to the synced items. The iterator must yield the items in ascending order. If there is a
        match that is currently still matches the state on the storage, that is collected to be discarded.
        """
        items = self.items  # local ref for speed
        # Do not start the scan if there are not many items or entries. The more entries there are the longer the
        # sync process can go and the more items are queued the more benefit there is for doing the scan.
        if len(items) > minimal_items_to_start_scan < queued_count:
            i = 0
            next_names = []
            paths_to_discard = []
            rel = self.rel  # local ref for speed
            rel_len = len(rel)
            for p in queued_paths:
                if rel:
                    if not p.startswith(rel):
                        break  # no more entries that could be hierarchically under rel
                    elif rel_len == len(p):
                        continue  # the exact match is skipped
                    elif p[rel_len] != "/":
                        continue  # item is not hierarchically under rel
                    elif p.find("/", rel_len + 1) != -1:
                        continue  # skip sub-path with deep components, only the direct children are checked
                    name = p[rel_len + 1 :]
                elif not p:
                    continue  # the exact match is skipped
                else:
                    if "/" in p:
                        continue  # skip item with deep components, only the direct children are checked
                    name = p
                # At this point name is a direct child of rel. Thankfully the sorting of Unicode in UTF-8 bytes
                # or by codepoints is results in the same order, so we can merge-check for matches.
                while i < len(items):
                    item = items[i]
                    if stat.S_ISDIR(item.stat.st_mode):  # collect directory items separately for follow-ups
                        next_names.append(item.name)
                        i += 1
                    elif item.name < name:
                        i += 1  # skip fs-item not in queue
                    elif item.name > name:
                        break  # stop on current fs-item if the queued item is before it (not in fs)
                    else:
                        try:
                            if stat_equals(resolve_path(item.posix_path, strict=True).lstat(), item.stat):
                                paths_to_discard.append(p)
                        except OSError:
                            pass
                        i += 1  # match processed
                        if i < len(items):
                            break
                        # else allow fallback to the loop's else to break out of the queue processing.
                else:  # no more items that could match, cancel iterating over the rest of queued items
                    break
            while i < len(items):  # collect directory items separately for follow-ups from remaining
                item = items[i]
                if stat.S_ISDIR(item.stat.st_mode):
                    next_names.append(item.name)
                i += 1
            if rel:
                return [f"{rel}/{next_name}" for next_name in next_names], paths_to_discard
            return next_names, paths_to_discard
        # No merge-scan was run to collect paths to be discarded.
        return self.get_child_dirs(), []
