# Copyright (C) 2014 New York University
# This file is part of ReproZip which is released under the Revised BSD License
# See file LICENSE for full license details.

"""Utility functions.

These functions are shared between reprozip and reprounzip but are not specific
to this software (more utilities).
"""

import codecs
import contextlib
from datetime import datetime
import email.utils
import io
import itertools
import locale
import logging
import operator
import os
from pathlib import Path, PurePosixPath
import requests
import shutil
import stat
import subprocess
import time
import yaml


logger = logging.getLogger('reprozip_core')


class StreamWriter(object):
    def __init__(self, stream):
        writer = codecs.getwriter(locale.getpreferredencoding())
        self._writer = writer(stream, 'replace')
        self.buffer = stream

    def writelines(self, lines):
        self.write(''.join(lines))

    def write(self, obj):
        if isinstance(obj, bytes):
            self.buffer.write(obj)
        else:
            self._writer.write(obj)

    def __getattr__(self, name,
                    getattr=getattr):

        """ Inherit all other methods from the underlying stream.
        """
        return getattr(self._writer, name)


def flatten(n, iterable):
    """Flattens an iterable by repeatedly calling chain.from_iterable() on it.

    >>> a = [[1, 2, 3], [4, 5, 6]]
    >>> b = [[7, 8], [9, 10, 11, 12, 13, 14, 15, 16]]
    >>> l = [a, b]
    >>> list(flatten(0, a))
    [[1, 2, 3], [4, 5, 6]]
    >>> list(flatten(1, a))
    [1, 2, 3, 4, 5, 6]
    >>> list(flatten(1, l))
    [[1, 2, 3], [4, 5, 6], [7, 8], [9, 10, 11, 12, 13, 14, 15, 16]]
    >>> list(flatten(2, l))
    [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
    """
    for _ in range(n):
        iterable = itertools.chain.from_iterable(iterable)
    return iterable


class UniqueNames(object):
    """Makes names unique amongst the ones it's already seen.
    """
    def __init__(self):
        self.names = set()

    def insert(self, name):
        assert name not in self.names
        self.names.add(name)

    def __call__(self, name):
        nb = 1
        attempt = name
        while attempt in self.names:
            nb += 1
            attempt = '%s_%d' % (name, nb)
        self.names.add(attempt)
        return attempt


def escape(s):
    """Escapes backslashes and double quotes in strings.

    This does NOT add quotes around the string.
    """
    return s.replace('\\', '\\\\').replace('"', '\\"')


class YamlIndentedListDumper(yaml.SafeDumper):
    def __init__(self, *args, initial_indent=0, **kwargs):
        super(YamlIndentedListDumper, self).__init__(*args, **kwargs)
        for _ in range(initial_indent):
            self.increase_indent()

    # https://stackoverflow.com/a/39681672
    def increase_indent(self, flow=False, indentless=False):
        return super(YamlIndentedListDumper, self).increase_indent(flow, False)


def yaml_dumps(x, *, initial_indent=0):
    """Version of yaml.safe_dump() that indents lists.

    Indenting items of a list plays better with comments. In addition, this
    allows setting the initial indentation before dumping.
    """
    stream = io.StringIO()
    dumper = YamlIndentedListDumper(
        stream,
        allow_unicode=True,
        initial_indent=initial_indent,
    )
    try:
        dumper.open()
        dumper.represent(x)
        dumper.close()
    finally:
        dumper.dispose()
    return stream.getvalue().rstrip(' ')


def optional_return_type(req_args, other_args):
    """Sort of namedtuple but with name-only fields.

    When deconstructing a namedtuple, you have to get all the fields:

    >>> o = namedtuple('T', ['a', 'b', 'c'])(1, 2, 3)
    >>> a, b = o
    ValueError: too many values to unpack

    You thus cannot easily add new return values. This class allows it:

    >>> o2 = optional_return_type(['a', 'b'], ['c'])(1, 2, 3)
    >>> a, b = o2
    >>> c = o2.c
    """
    if len(set(req_args) | set(other_args)) != len(req_args) + len(other_args):
        raise ValueError

    # Maps argument name to position in each list
    req_args_pos = dict((n, i) for i, n in enumerate(req_args))
    other_args_pos = dict((n, i) for i, n in enumerate(other_args))

    def cstr(cls, *args, **kwargs):
        if len(args) > len(req_args) + len(other_args):
            raise TypeError(
                "Too many arguments (expected at least %d and no more than "
                "%d)" % (len(req_args),
                         len(req_args) + len(other_args)))

        args1, args2 = args[:len(req_args)], args[len(req_args):]
        req = dict((i, v) for i, v in enumerate(args1))
        other = dict(zip(other_args, args2))

        for k, v in kwargs.items():
            if k in req_args_pos:
                pos = req_args_pos[k]
                if pos in req:
                    raise TypeError("Multiple values for field %s" % k)
                req[pos] = v
            elif k in other_args_pos:
                if k in other:
                    raise TypeError("Multiple values for field %s" % k)
                other[k] = v
            else:
                raise TypeError("Unknown field name %s" % k)

        args = []
        for i, k in enumerate(req_args):
            if i not in req:
                raise TypeError("Missing value for field %s" % k)
            args.append(req[i])

        inst = tuple.__new__(cls, args)
        inst.__dict__.update(other)
        return inst

    dct = {'__new__': cstr}
    for i, n in enumerate(req_args):
        dct[n] = property(operator.itemgetter(i))
    return type('OptionalReturnType', (tuple,), dct)


def tz_offset():
    offset = time.timezone if time.localtime().tm_isdst == 0 else time.altzone
    return -offset


def isodatetime():
    offset = tz_offset()
    sign = '+'
    if offset < 0:
        sign = '-'
        offset = -offset
    if offset % 60 == 0:
        offset = '%02d:%02d' % (offset // 3600, (offset // 60) % 60)
    else:
        offset = '%02d:%02d:%02d' % (offset // 3600, (offset // 60) % 60,
                                     offset % 60)
    # Remove microsecond
    now = datetime.now()
    now = datetime(year=now.year, month=now.month, day=now.day,
                   hour=now.hour, minute=now.minute, second=now.second)
    return '%s%s%s' % (now.isoformat(),
                       sign,
                       offset)


def hsize(nbytes):
    """Readable size.
    """
    if nbytes is None:
        return "unknown"

    KB = 1 << 10
    MB = 1 << 20
    GB = 1 << 30
    TB = 1 << 40
    PB = 1 << 50

    nbytes = float(nbytes)

    if nbytes < KB:
        return "{0} bytes".format(int(nbytes))
    elif nbytes < MB:
        return "{0:.2f} KB".format(nbytes / KB)
    elif nbytes < GB:
        return "{0:.2f} MB".format(nbytes / MB)
    elif nbytes < TB:
        return "{0:.2f} GB".format(nbytes / GB)
    elif nbytes < PB:
        return "{0:.2f} TB".format(nbytes / TB)
    else:
        return "{0:.2f} PB".format(nbytes / PB)


def normalize_path(path):
    """Normalize a path obtained from the database.
    """
    # For some reason, os.path.normpath() keeps multiple leading slashes
    # We don't want this since it has no meaning on Linux
    path = PurePosixPath(path)
    if str(path).startswith('//'):
        path = PurePosixPath(str(path)[1:])
    return path


def find_all_links_recursive(filename, files):
    path = Path('/')
    for c in filename.parts[1:]:
        # At this point, path is a canonical path, and all links in it have
        # been resolved

        # We add the next path component
        path = path / c

        # That component is possibly a link
        if path.is_symlink():
            # Adds the link itself
            files.add(path)

            target = Path(os.readlink(path))
            target = (path.parent / target).absolute()
            # Here, target might contain a number of symlinks
            if target not in files:
                # Recurse on this new path
                find_all_links_recursive(target, files)
            # Restores the invariant; realpath might resolve several links here
            path = path.resolve()
    return path


def find_all_links(filename, include_target=False):
    """Dereferences symlinks from a path.

    If include_target is True, this also returns the real path of the final
    target.

    Example:
        /