"""
File management wrappers on os.path, shutil, and useful non-standard modules

"Apparent drive" refers to the term before the first os.sep in an object's given path.
    if the given path is relative
        then the ADrive may be ".." or the name of the File/Directory
    else
        the drive is the name/letter of the disk partition on which the content at the address is stored.
"Apparent Directory" similarly refers first term before the last os.sep in an object's given path
    if the given path is relative
        then the ADir may be ".." or the name of the File/Directory
    else
        the drive is the name/letter of the disk partition on which the content at the address is stored.


TODO
    Add relative path support
    Classes for specific mimes
    A version of @cached_property which checks the date modified before determining whether or not to recompute
    Caches for new directories which include objects to add to them upon creation
    Strict searching
    Caches for pickling (backup simplification)
    Remove Size from repr for Directories. Large ones take too long to initialize
"""
__all__ = 'Address Directory File'.split()

from itertools import chain
from typing import Iterable, List, Dict
import os, re, sys, shutil

import filetype as ft, audio_metadata as am
from send2trash import send2trash

from .utils import *

generator = type(i for i in range(0))
function = type(ft.match)

formats = {
    'pics': "bmp png jpg jpeg tiff".split(),
    'music': "mp3 m4a wav ogg wma flac aiff alac".split(),
    'videos': "mp4 wmv".split(),
    'docs': "doc docx pdf xlsx pptx ppt xls csv".split(),
}
formats['all'] = [*chain.from_iterable(formats.values())]

class MemorySize(int):
    """
    Why should you have to sacrifice utility for readability?
    """
    def __repr__(self):
        return nice_size(self)

class Address:
    """
    Base class for a non-descript path-string. 
    Relative paths are not currently supported
    Methods return self unless otherwise stated
    """
    def __init__(self, path:str):
        self.path = path = normalize(path)
        # if not os.path.exists(path):
            # print(Warning(f'Warning: Path "{path}" does not exist'))
    def __str__(self):
        return self.path
    def __hash__(self):
        return hash(self.path)
    def __repr__(self):
        # return self.path
        # tipo = "Directory File".split()[self.isfile]
        return f"{tipo(self)}(name={self.name}, up={self.up.name})"
    
    def create(self, content:[str, bytes, bytearray]=None, raw:bool=False):
        """
        Create an entry in the file-system. If the address is not vacant no action will be taken.
        The raw handle only works for Files and enables writing bytes/bytearrays
        """
        if self.exists:
            return self
        elif content:
            os.makedirs(self.up.path, exist_ok=True)
            if raw:
                if isinstance(content, (bytes, bytearray)):
                    fobj = open(self.path, 'wb')
                else:
                    fobj = open(self.path, 'w')
                fobj.write(content)
                fobj.close()
            else:
                if isinstance(content, str):
                    content = bytes(content, encoding='utf-8')
                else:
                    content = bytes(content)
                with open(self.path, 'wb') as fobj:
                    fobj.write(content)
        else:
            os.makedirs(self.up.path, exist_ok=True)
            if likefile(self.path):
                with open(self.path, 'x') as fobj:
                    pass
            else:
                os.makedirs(self.path)
        return self
    
    @property
    def exists(self):
        return os.path.exists(self.path)
    @property
    def isdir(self):
        if self.exists:
            return os.path.isdir(self.path)
        elif type(self) == type(Directory('.')):
            return True
        elif type(self) == type(File('a')):
            return False
        return not likefile(self.path)
    @property
    def isfile(self):
        if self.exists:
            return os.path.isfile(self.path)
        elif type(self) == type(File('a')):
            return True
        elif type(self) == type(Directory('.')):
            return False
        return likefile(self.path)
    
    @property
    def obj(self):
        """
        Determine if self.path points to a file or folder and create the corresponding object
        """
        if self.exists:
            return File(self.path) if os.path.isfile(self.path) else Directory(self.path)
        else:
            return File(self.path) if likefile(self.path) else Directory(self.path)
    @property
    def up(self):
        """
        Return the ADir
        """
        return Address(delevel(self.path)).obj
    @property
    def name(self):
        """
        Return the name of the referent
        """
        return os.path.split(self.path)[1]
    
    @property
    def ancestors(self):
        """
        Return consecutive ADirs until the ADrive is reached
        """
        level = []
        p = self.path[:]
        while p != delevel(p):
            p = delevel(p)
            level.append(p)
        return tuple(Address(i).obj for i in level)[::-1]
    @property
    def colleagues(self):
        """
        Every member of the same Directory whose type is the same as the referent
        """
        return (i for i in self.up if isinstance(i, type(self)))
    @property
    def neighbours(self):
        """
        Everything in the same Directory
        """
        return self.up.content
    @property
    def depth(self):
        """
        Number of ancestors
        """
        return len(self.ancestors)
    @property
    def top(self):
        """
        The apparent drive. Will not be helpful if self.path is relative
        """
        return self.ancestors[0]
    @property
    def stat(self):
        """
        return os.stat(self.path) 
        """
        return os.stat(self.path)
        
    def delevel(self, steps:int=1, path:bool=False) -> str:
        """
        Go up some number of levels in the File system
        """
        return delevel(self.path, steps) if path else Directory(delevel(self.path, steps))
    @property
    def ancestry(self):
        """
        A fancy representation of the tree from the apparent drive up to the given path
        """
        print(f'ancestry({self.name})')
        ancs = list(self.ancestors[1:])
        # ancs = self.ancestors[1:]
        ancs.append(self.path)
        # print(ancs)
        for i, anc in enumerate(ancs):
            print('\t' + ('', '.'*i)[i>0] + i*'  ' + [i for i in str(anc).split(os.sep) if i][-1] + '/')
        return self
    def touch(self):
        """
        Implements the unix command 'touch', which updates the 'date modified' of the content at the path
        """
        p = self.path
        pathlib.Path(p).touch()
        self = Address(p).obj
        return self
    def erase(self, recycle:bool=True):
        """
        Send a File to the trash, or remove it without recycling.
        """
        send2trash(self.path) if recycle else os.remove(self.path)
        return self
    def clone(self, folder:str=None, name:str=None, cwd:bool=False, sep:str='_', touch=False):
        """
        Returns a clone of the referent at a given Directory-path
        The given path will be created if it doesn't exist
        Will copy in the File's original folder if no path is given
        The cwd switch will always copy to the current working Directory
        """
        copier = (shutil.copy2, shutil.copytree)[self.isdir]
        if cwd:
            new = os.path.join(os.getcwd(), name if name else self.name)
        elif folder:
            new = os.path.join(folder, name if name else self.name)
        else:
            new = self.path
        new = nameSpacer(new, sep=sep)
        os.makedirs(delevel(new), exist_ok=True)
        copier(self.path, new)
        out = Address(new).obj
        return out.touch() if touch else out
    def move(self, folder:str=None, name:str=None, dodge:bool=False, sep:str='_', recycle:bool=True):
        """
        addy.move(folder) -> move to the given Directory ()
        addy.move(name) -> move to the given path (relative paths will follow from the objects existing path)
        addy.move(folder, name) -> move to the given Directory 
        
        :param dodge: 
            enables automatic evasion of file-system collisions
        :param sep: 
            chooses the separator you use between the object's name and numerical affix in your directory's namespace
            **inert if dodge is not True
        :param recycle: 
            enables you to avoid the PermissionError raised by os.remove (if you have send2trash installed)
            ** the PermissionError is due to the object being in use at the time of attempted deletion
       """
        if folder and name:
            os.makedirs(folder, exist_ok=True)
            new = os.path.join(folder, name)
        elif folder:
            os.makedirs(folder, exist_ok=True)
            new = os.path.join(folder, self.name)
        elif name:
            new = os.path.join(self.up.path, name)
        else:
            raise ValueError(f'The file couldn\'t be moved because {name=} and {folder=}. Set either one or both')
        new = nameSpacer(new, sep=sep) if dodge else new
        folder, name = os.path.split(new)
        self.clone(folder=folder, name=name)
        # os.remove(self.path)
        self.erase()
        self.path = new
        return self
    def rename(self, name:str):
        """
        Change the name of the referent
        """
        return self.move(name=name)
    def expose(self):
        """
        Reveal the referent in the system's file explorer (will open the containing Directory if the referent is a File)
        """
        if self.isdir:
            os.startfile(self.path)
        else:
            os.startfile(self.up.path)
        return self


class File(Address):
    """
    Create a new File object for context management and ordinary operations
    """
    def __init__(self, path:str='NewFile'):
        path = os.path.abspath(trim(path))
        super(File, self).__init__(path)
        self._stream = None
    def __enter__(self):
        return self.open()
    def __exit__(self, exc_type, exc_value, traceback):
        self.close()
    def __repr__(self):
        # return self.path
        # tipo = "Directory File".split()[self.isfile]
        return f"{tipo(self)}(name={self.name}, up={self.up.name}, size={self.size})"
        
    @property
    def size(self):
        return MemorySize(os.stat(self.path).st_size)
    @property
    def mime(self):
        return match.MIME if (match:=ft.guess(self.path)) else None
    @property
    def kind(self):
        return self.mime.split('/')[0] if (match:=ft.guess(self.path)) else None
    @property
    def ext(self):
        return os.path.splitext(self.name)[1]
    @property
    def title(self):
        """
        return the File's name without the extension
        """
        return os.path.splitext(self.name)[0]
    def open(self, mode:str='r', scrape:bool=False):
        """
        Return the File's byte or text stream.
        Scrape splits the text at all whitespace and returns the content as a string
        """
        if scrape:
            with open(self.path, mode) as fobj:
                return ' '.join(fobj.read().split())
        with open(self.path, mode) as fobj:
            self._stream = fobj
            return self._stream
    def close(self):
        if self._stream:
            self._stream.close()
        return self
    @property
    def run(self):
        os.startfile(self.path)
        return self


class Items:
    """
    A wrapper on a directory's content which makes it easier to access by turning elements into attributes
    """
    def __init__(self, path):
        self.path = normalize(path)
    def __getattr__(self, attr):
        return Directory(self.path)[attr]


class Directory(Address):
    """
    Directory('.') == Directory(os.getcwd())
    """
    def __init__(self, path:str='NewDirectory'):
        if path=='.':
            path = os.getcwd()
        elif path == 'NewDirectory':
            path = nameSpacer(path)
        elif path == '~':
            path = os.path.expanduser(path)
        path = os.path.abspath(trim(path))
        self.path = normalize(path)
        super(Directory, self).__init__(path)
        self.index = -1
    def __repr__(self):
        # return self.path
        # tipo = "Directory File".split()[self.isfile]
        return f"{tipo(self)}(name={self.name}, up={self.up.name})"
    def __len__(self):
        return len(os.listdir(self.path))
    def __bool__(self):
        """
        Check if the Directory is empty or not
        """
        return len(os.listdir(self.path)) > 0
    def __iter__(self):
        return self
    def __next__(self):
        if self.index<len(self)-1:
            self.index += 1
            return self.content[self.index]
        self.index = -1
        raise StopIteration
    def __getitem__(self, item:str):
        """
        Return an object whose name is an exact match for the given item
        """
        if any(re.search(f'^{item}$', i.name, re.I) for i in self.content):
            return Address(os.path.join(self.path, item)).obj
        raise ValueError(f'The folder "{self.name}" does not contain anything called "{item}"')
    def __truediv__(self, other:str):
        if isinstance(other, str):
            return Address(os.path.join(self.path, other)).obj
        raise TypeError(f"Other must be a string")
    def __call__(self, keyword:str, sort:bool=False, case:bool=False, **kwargs) -> Iterable:
        """
        See help(self.search)
        """
        return self.search(keyword, sort, case, **kwargs)
    
    @property
    def items(self):
        """
        This extension allows you to call folder contents as if they were attributes. 
        Will not work if your file system does not use a python-viable naming convention
        example:
            >>> folder.items.subfolder
        """
        return Items(self.path)
    @property
    def children(self):
        """
        Return "os.listdir" but filtered for directories
        """
        return (addy.obj for i in os.listdir(self.path) if (addy:=Address(os.path.join(self.path, i))).isdir)
    @property
    def files(self):
        """
        Return "os.listdir" but filtered for Files
        """
        return (addy.obj for i in os.listdir(self.path) if (addy:=Address(os.path.join(self.path, i))).isfile)
    @property
    def content(self):
        """
        Return address-like objects from "os.listdir"
        """
        return tuple(Address(os.path.join(self.path, i)).obj for i in os.listdir(self.path))
    @property
    def leaves(self):
        """
        Return All Files from all branches
        """
        # return tuple(self.gather())
        return map(lambda x: Address(x).obj, self.gather())
    @property
    def branches(self):
        """
        Return Every Directory whose path contains "self.path"
        """
        return tuple(set(File(i).delevel() for i in self.gather()))
    @property
    def size(self):
        """
        Return the sum of sizes of all files in self and branches
        """
        return MemorySize(sum(file.size for file in self.leaves))
    @property
    def mimes(self):
        """
        Return File mimes for all Files from all branches
        """
        return tuple(set(file.mime for file in self.gather()))
    @property
    def kinds(self):
        """
        Return File types for all Files from branches
        """
        return tuple(set(m.split('/')[0] for m in self.mime))
    @property
    def exts(self):
        """
        Return extensions for all Files from all branches
        """
        return tuple(set(f.ext for f in self.gather()))
    @property
    def isroot(self):
        """
        Return check if the Directory is at the highest level of the File system
        """
        return not self.depth
    
    def add(self, other:Address, copy:bool=False):
        """
        Introduce new elements. Send an address-like object to self.
        """
        if not self.exists:
            raise OSError(f"Cannot add to Directory({self.name}) because it doesn't exist")
        elif not other.exists:
            if issubclass(type(other), Address):
                tipo = 'Directory File'.split()[other.isfile]
            raise OSError(f"{other.name.title()} could not be added to {self.name} because it doesn't exist")
        new = os.path.join(self.path, os.path.split(other.path)[-1])
        other.clone(folder=self.up.path) if copy else other.rename(new)
        return self
    
    def enter(self):
        """
        Set referent as current working Directory
        """
        os.chdir(self.path)
        
    def gather(self, titles:bool=False, walk:bool=True, ext:str='') -> generator:
        """
        Generate an iterable of the files rooted in a given folder. The results will be strings, not File objects
        It is possible to search for multiple File extensions if you separate each one with a space, comma, asterisk, or tilde. 
        Only use one symbol per gathering though.
        
        :param titles: if you only want to know the names of the files gathered, not their full paths
        :param walk: if you want to recursively scan subdiretories
        :param ext: if you want to filter for particular extensions
        """
        folder = self.path
        if walk:
            if ext:
                ext = ext.replace('.', '')
                sep = [i for i in ',`* ' if i in ext]
                pattern = '|'.join(f'\.{i}$' for i in ext.split(sep[0] if sep else None))
                pat = re.compile(pattern, re.I)
                for root, folders, names in os.walk(folder):
                    for name in names:
                        if os.path.isfile(p:=os.path.join(root, name)) and pat.search(name) and name!='NTUSER.DAT':
                            yield name if titles else p
            else:
                for root, folders, names in os.walk(folder):
                    for name in names:
                        if os.path.exists(p:=os.path.join(root, name)):
                            yield name if titles else p
        else:
            if ext:
                ext = ext.replace('.', '')
                sep = [i for i in ',`* ' if i in ext]
                pattern = '|'.join(f'\.{i}$' for i in ext.split(sep[0] if sep else None))
                pat = re.compile(pattern, re.I)
                for name in os.listdir(folder):
                    if os.path.isfile(p:=os.path.join(folder, name)) and pat.search(name) and name!='NTUSER.DAT':
                        yield name if titles else p
            else:
                for name in os.listdir(folder):
                    if os.path.isfile(p:=os.path.join(folder, name)):
                        yield name if titles else p
    
    def search(self, keyword:str, sort:bool=False, case:bool=False, prescape:bool=False, strict:bool=True, **kwargs) -> Iterable:
        """
        Return an iterator of Files whose path match the given keyword within a Directory.
        The search is linear and the sorting is based on the number of matches. If sorted, a list will be returned.
        Case pertains to case-sensitivity
        Prescape informs the method that kewords do not need to be escaped
        For kwargs see help(self.gather)
        
        :param keyword: terms you wish to match, separated by spaces
        :param sort: if you would like to sort the results by number of matches
        :param case: if you would like to make the search case sensitive
        :param prescape: if you have already re.escape-d the terms you would like to search
        :param strict: if you only want to see results which match every term in the keyword string
        """
        casesensitivity = (re.I, 0)[case]
        escaper = (re.escape, lambda x: x)[prescape]
        if isinstance(keyword, str):
            keyword = keyword.split()
        if not isinstance(keyword, str):
            keyword = '|'.join(map(escaper, keyword))
        if strict:
            return filter(
                lambda x: len([*re.finditer(keyword, x, casesensitivity)]) >= len(keyword.split('|')),
                self.gather(**kwargs),
            )
        elif sort:
            return sorted(
                filter(
                    lambda x: len([*re.finditer(keyword, x, casesensitivity)]) == len(keyword.split('|')),
                    self.gather(**kwargs),
                ),
                key=lambda x: len([*re.finditer(keyword, x, casesensitivity)]),
                reverse=True
            )
        else:
            return filter(
                lambda x: re.search(keyword, x, casesensitivity),
                self.gather(**kwargs)
            )


# class Archive(Address):
    # def __init__(self, path:str='NewFile'):
        # path = os.path.abspath(trim(path))
        # super(File, self).__init__(path)

if __name__ == '__main__':
    show(locals().keys())
    
    
    fp = r'c:\users\kenneth\pyo_rec.wav'
    dp = r'c:\users\kenneth\videos'
    
    d = Directory(dp)
    f = File(fp)
    tf = File('testfile.ext')
    td = Directory('testdir')
    
    system = (d, f)
    # show(d('slowthai', sort=True))
    # show(d('alix melanie', sort=True))
    # show(d('melanie alix', sort=True))
    
    # print(formats['all'])
    # for v in formats.values():
        # print(' '.join(v))
        
    