import collections
import re
import os
import shlex
import shutil
import glob
import stat
import tempfile
import tarfile
import subprocess
import contextlib
import logging

import mini_buildd
import mini_buildd.misc
import mini_buildd.call


LOG = logging.getLogger(__name__)


class DebianName:
    """
    Debian package-related file names, based on package ``name`` (usually source name) and ``version``.

    Always strip epoch from version, and handles special
    mini-buildd types.

    >>> dfn = DebianName("mypkg", "7:1.2.3-1")
    >>> dfn.dsc()
    'mypkg_1.2.3-1.dsc'
    >>> dfn.changes("amd64")
    'mypkg_1.2.3-1_amd64.changes'
    >>> dfn.changes("mips")
    'mypkg_1.2.3-1_mips.changes'
    >>> dfn.changes("mips", apx="my-custom-apx")
    'mypkg_1.2.3-1_my-custom-apx_mips.changes'
    >>> dfn.buildlog("mips")
    'mypkg_1.2.3-1_mips.buildlog'
    """

    def __init__(self, package, version):
        self.base = f"{package}_{mini_buildd.misc.strip_epoch(version)}"

    @classmethod
    def uext(cls, ext):
        return f"_{ext}" if ext else ""

    def gen(self, type_, apx=None, arch=None):
        return f"{self.base}{self.uext(apx)}{self.uext(arch)}.{type_}"

    def dsc(self):
        return self.gen("dsc")

    def changes(self, arch, apx=None):
        return self.gen("changes", apx=apx, arch=arch)

    def upload(self, arch, apx=None):
        return self.gen("upload", apx=apx, arch=arch)

    def buildlog(self, arch):
        return self.gen("buildlog", arch=arch)

    def json(self, apx, arch=None):
        return self.gen("json", apx=apx, arch=arch)


class Tar():
    def __init__(self):
        self.files = []

    def add(self, file_paths, arcdir=""):
        self.files.append((arcdir, file_paths))
        return self

    def save_as(self, file_path):
        with contextlib.closing(tarfile.open(file_path, "w")) as t:
            for arcdir, files in self.files:
                for f in files:
                    t.add(f, arcname=os.path.join(arcdir, os.path.basename(f)))

    @classmethod
    def extract(cls, file_path, dest_path):
        with contextlib.closing(tarfile.open(file_path, "r")) as t:
            t.extractall(path=dest_path)


class Path:
    """
    PATH string with some convenience functionality.

    >>> p = Path("/tmp", ["uff", None, "tata"])
    >>> p.base
    '/tmp'
    >>> p.sub
    'uff/tata'
    >>> p.full
    '/tmp/uff/tata'
    >>> p.join("more", "sub", "dirs")
    '/tmp/uff/tata/more/sub/dirs'

    >>> p = Path("/tmp")
    >>> p.sub
    ''
    """

    def __init__(self, base, subdirs=None, create=False):
        if len(base) < 4:
            raise Exception(f"Possibly dangerous path '{base}'")  # Be a bit paranoid since path.join() results are used to expire-purge things
        self.base = base
        self.sub = os.path.join("", *[s for s in ([] if subdirs is None else subdirs) if s is not None])
        self.full = os.path.join(self.base, self.sub)
        if create:
            self.create()
        self.exists = os.path.isdir(self.full)

    def create(self):
        os.makedirs(self.full, exist_ok=True)

    def new_sub(self, subdirs=None, create=False):
        return self.__class__(self.full, subdirs, create=create)

    def __str__(self):
        return self.full

    def join(self, *args):
        return os.path.join(self.full, *args)

    def sub_join(self, *args):
        return os.path.join(self.sub, *args)

    def expire(self, older_than):
        """Expire directories 'older than'."""
        valid_until = mini_buildd.misc.Datetime.now() - older_than
        LOG.debug(f"FS expire items in path '{self.full}' older than {older_than}...")
        for item in glob.glob(self.join("**"), recursive=True):
            if os.path.isdir(item):
                LOG.debug(f"FS expire check older than {older_than} on dir: {item}")
                if mini_buildd.misc.Datetime.from_path(item) < valid_until:
                    shutil.rmtree(item)
                    LOG.info(f"Directory older than {older_than} expired: {item}")

    def ifiles(self, path, extension, after=None, before=None):
        """Fast recursive file finder using os.scandir()."""
        for entry in os.scandir(self.join(path)):
            if entry.is_file() and entry.name.endswith(extension):
                stamp = mini_buildd.misc.Datetime.from_stamp(entry.stat().st_mtime)
                if (after is None or stamp > after) and (before is None or stamp < before):
                    yield entry
            elif entry.is_dir(follow_symlinks=False):
                yield from self.ifiles(entry.path, extension, after, before)

    def removeprefix(self, path):
        return mini_buildd.PyCompat.removeprefix(path, self.full)


class File(collections.deque):
    r"""
    Line based text file (config or script).

    >>> pm = PerlModule("perl").add("# set foo\n$bar = undef;")
    >>> pm.get()
    '# set foo\n$bar = undef;\n1;\n'
    >>> pm.get(snippet=True)
    '# set foo\n$bar = undef;\n'
    >>> pm.get(snippet=True, comments=False)
    '$bar = undef;\n'

    >>> bs = BashScript("bash").add("# echo foo\necho bar")
    >>> bs.get()
    '#!/bin/bash -e\n# echo foo\necho bar\n'
    >>> bs.get(snippet=True)
    '# echo foo\necho bar\n'
    >>> bs.get(snippet=True, comments=False)
    'echo bar\n'

    >>> test_file = "/tmp/mini_buildd_test_conf_file"
    >>> if os.path.exists(test_file): os.remove(test_file)
    >>> File("test_file").add("my_option=7\n").update_as(test_file)
    True
    >>> File("test_file").add("my_option=7\n").update_as(test_file)
    False
    >>> File("test_file").add("my_option=8\n").update_as(test_file)
    True

    >>> dos = File("dos.txt").add_file("test-data/dos.txt")
    >>> with mini_buildd.fopen("test-data/unix.txt", newline="") as unix: unix.read() == dos.get()
    True
    >>> with mini_buildd.fopen("test-data/dos.txt", newline="") as dos_file: dos_file.read() == dos.get()
    False

    # rmtree: Enable with caution
    # >>> dir = Dir("./test-data/dirtest").add(File("file1.txt", snippet="test1 data")).add(File("file2.txt", snippet="test2 data"))
    # >>> import shutil
    # >>> shutil.rmtree(dir.path, ignore_errors=True)
    # >>> dir.update()
    # True
    # >>> dir.update()
    # False
    # >>> dir.update()
    # False
    # >>> dummy = dir["file1.txt"].add("nonne zeile")
    # >>> dir.update()
    # True
    # >>> shutil.rmtree(dir.path)
    """

    @classmethod
    def shebang_from_data(cls, data):
        first_line = data.partition("\n")[0]
        if first_line[:2] == "#!":
            return first_line.rstrip()
        raise Exception(f"Not a shebang: '{first_line}'")

    @classmethod
    def shebang_from_path(cls, path):
        with mini_buildd.fopen(path) as f:
            return cls.shebang_from_data(f.readline())

    def __init__(self, name, description=None, snippet=None, comment="#", shebang=None, finalizer=None, placeholders=None):
        super().__init__()
        self.name, self.description, self.comment, self.shebang, self.finalizer, self.placeholders = name, description, comment, shebang, finalizer, placeholders
        if description:
            self.append(self.banner(description))
        if snippet:
            self.add(snippet)

    def __str__(self):
        return f"{self.name}: {self.description}"

    def get(self, snippet=False, comments=True):
        result = ""
        if not snippet and self.shebang:
            result += self.shebang + "\n"
        for line in self:
            if line and line[0] == self.comment and not comments:
                continue
            result += line + "\n"
        if not snippet and self.finalizer:
            result += self.finalizer + "\n"

        return mini_buildd.misc.subst_placeholders(result, self.placeholders) if self.placeholders is not None else result

    def banner(self, description):
        return f"{self.comment} {self.name}: {description}"

    def add(self, data, snippet=True, description=None):
        if description:
            self.append(self.banner(description))

        lines = data.splitlines()
        if lines:
            if not snippet and self.shebang is not None:
                if lines[0] != self.shebang:
                    raise Exception(f"Wrong shebang '{lines[0]}' (needs '{self.shebang}')")
                del lines[0]
            if not snippet and self.finalizer is not None:
                if lines[-1] != self.finalizer:
                    raise Exception(f"Wrong finalizer '{lines[-1]}' (needs '{self.finalizer}')")
                del lines[-1]
            for line in lines:
                self.append(line)
        return self

    def add_file(self, path, **kwargs):
        with mini_buildd.fopen(path) as f:
            self.add(f.read(), **kwargs)
        return self

    def save_as(self, path, **kwargs):
        with mini_buildd.fopen(path, "w+") as f:
            content = self.get(**kwargs)
            f.write(content)
            if self.shebang is not None:
                os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC)
            return content

    def update_as(self, path, **kwargs):
        """Like save_as(), but return bool whether file has changed on disk."""
        content_pre = None
        if os.path.exists(path):
            with mini_buildd.fopen(path, "r") as f:
                content_pre = f.read()
        content = self.save_as(path, **kwargs)
        return content_pre != content

    def save(self, dir_, **kwargs):
        return self.save_as(os.path.join(dir_, self.name), **kwargs)

    def update(self, dir_, **kwargs):
        return self.update_as(os.path.join(dir_, self.name), **kwargs)

    def run(self, cli=False):
        with tempfile.TemporaryDirectory() as tmpdir:
            script_file = os.path.join(tmpdir, self.name)
            LOG.info(f"Executing script '{self.name}' from '{script_file}'")
            self.save_as(script_file)
            if cli:
                subprocess.check_call([script_file])
            else:
                mini_buildd.call.Call([script_file]).check()


class Script(File):
    """Load a complete script file as ``File`` (with automatic shebang recognition)."""

    def __init__(self, script_path):
        with mini_buildd.fopen(script_path) as f:
            content = f.read()
            super().__init__(os.path.basename(script_path), shebang=self.shebang_from_data(content))
            self.add(content, snippet=False)


class PerlModule(File):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, finalizer="1;", **kwargs)


class ShellScript(File):
    def banner(self, description):
        result = f"printf 'MINI_BUILDD: %s: %s\\n' {shlex.quote(self.name)} {shlex.quote(description)};"
        return result


class ShScript(ShellScript):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, shebang="#!/bin/sh -e", **kwargs)


class BashScript(ShellScript):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, shebang="#!/bin/bash -e", **kwargs)


class AutoScript(File):
    def __init__(self, path, **kwargs):
        super().__init__(os.path.basename(path), shebang=self.shebang_from_path(path), **kwargs)
        self.add_file(path, snippet=False)


class Dir(collections.OrderedDict):
    def __init__(self, path=None, enumerate_file_names=-1):
        super().__init__()
        self.path = path
        self.next_no = enumerate_file_names

    def add(self, file_):
        file_name = f"{self.next_no:03}_{file_.name}" if self.next_no > -1 else file_.name

        if file_name in self.keys():
            raise mini_buildd.HTTPBadRequest(f"File name {file_name} already exists in Dir object")

        self[file_name] = file_

        if self.next_no > -1:
            self.next_no += 1

        return self

    def add_dir(self, dir_, name_filter=None):
        """Add files from another 'Dir' (with optional filtering)."""
        for name, file_ in dir_.items():
            if name_filter is None or name in name_filter:
                self.add(file_)

    def _path(self, path=None):
        path = self.path if path is None else path
        if path is None:
            raise Exception(f"No directory given to save '{self}' to")
        os.makedirs(path, exist_ok=True)
        return path

    def save(self, path=None):
        path = self._path(path)
        for file_name, file_ in self.items():
            file_.save_as(os.path.join(path, file_name))

    def update(self, path=None):
        changed = set()
        path = self._path(path)
        for file_ in self.values():
            changed.add(file_.update(path))
        return True in changed


class AptLine:
    def __init__(self, mirror, path, suite, components, options=None, comment=None):
        """
        APT line.

        See ``man 5 sources.list`` (arg nomenclature taken from there; 'uri' is 'mirror + path').

        >>> l0 = AptLine("http://localhost", "debian", "dist", ["c1", "c3", "c2"])
        >>> l0.get()
        'deb [] http://localhost/debian dist c1 c2 c3'
        >>> l1 = AptLine("http://localhost", "debian", "dist", ["c1", "c2"], options={"trusted": "true", "check-valid-until": "false"})
        >>> l1.get()
        'deb [trusted=true check-valid-until=false] http://localhost/debian dist c1 c2'
        >>> l2 = AptLine("http://localhost", "debian", "dist", ["c1", "c2"])
        >>> l2.get("deb-src")
        'deb-src [] http://localhost/debian dist c1 c2'
        >>> l3 = AptLine("http://localhost", "debian", "dist", [])  # Test trailing "/" if no components
        >>> l3.get("deb")
        'deb [] http://localhost/debian dist/'
        >>> SourcesList([l0, l1, l2]).get(types=["deb", "deb-src"])  # doctest: +ELLIPSIS
        'deb ...
        """
        self.mirror = mirror
        self.path = path
        self.suite = suite
        self.components = " ".join(str(c) for c in sorted(components))
        self.options = options if isinstance(options, str) else " ".join((f'{o}={v}' for o, v in ({} if options is None else options).items()))
        self.comment = comment

    def get(self, type_="deb", mirror=None, options=None):
        return (f"{type_} "
                f"[{self.options}{' ' if options and self.options else ''}{'' if options is None else options}] "
                f"{os.path.join(mirror or self.mirror, self.path)} {self.suite}{' ' if self.components else '/'}{self.components}")


class SourcesList(list):
    def get(self, types=None, mirror=None, options=None, with_comment=False):
        return "\n".join((f"{f'# {line.comment}{mini_buildd.NEWLINE}' if with_comment else ''}{line.get(type_, mirror=mirror, options=options)}" for line in self for type_ in (["deb"] if types is None else types)))

    @classmethod
    def has_https(cls, sources_list_file):
        """Check sources.list file if there are any https sources in it."""
        with mini_buildd.fopen(sources_list_file) as sources_list:
            for line in sources_list:
                if re.match("deb.*https", line):
                    return True
        return False
