import subprocess
import tarfile
import tempfile

# pylint bug: https://github.com/PyCQA/pylint/issues/640
import py.path  # pylint: disable=no-name-in-module


"""Build test Debian source packages as data structures

Represent a Debian source package as a data structure. For testing purposes,
often all that is needed is an available source package which is the most basic
source package possible; sometimes with just a simple tweak to adjust some
attribute under test.

Create an abstract specification of what attributes the source package should
have with SourceSpec(). Attributes such as the package version string, or
whether the package is native, can be adjusted using keyword arguments to the
constructor.

Instantiate the source package on disk using Source() or Source(spec). This
object can be used as a context manager (returning a path to the dsc file), or
the write() and cleanup() methods can be called directly.

Example:

    with source_builder.Source() as dsc_path:
        # use dsc_path here for some test
"""



CONTROL_TEXT = """Source: source-builder-package
Maintainer: ubuntu-distributed-devel@lists.ubuntu.com

Package: source-builder-package
Architecture: all
Description: test source-builder-package
 An autogenerated test package.
"""


RULES_TEXT = """#!/usr/bin/make -f
clean:	true
"""


CHANGELOG_TEMPLATE = """source-builder-package ({version}) unstable; urgency=low
    * Test build from source_builder.

 -- git ubuntu <ubuntu-distributed-devel@lists.ubuntu.com>  {date}
"""


DEFAULT_CHANGELOG_DATE = 'Thu, 1 Jan 1970 00:00:00 +0000'


NEW_FILE_PATCH_TEMPLATE = """
--- /dev/null
+++ b/{new_path}
@@ -0,0 +1 @@
+{new_line}
"""


class SourceSpec:
    """A high level abstraction of the attributes of a test source package"""
    version = '1-1'
    native = False
    has_patches = False
    changelog_versions = None
    file_contents = None
    mutate = False
    changelog_date = DEFAULT_CHANGELOG_DATE
    reserved_files = [
        'debian/changelog',
        'debian/control',
        'debian/rules',
    ]

    def __init__(self, **kwargs):
        """Instantiate a new SourceSpec class instance

        :param str version: the Debian package version string
        :param bool native: True for a native package, False for non-native
        :param bool has_patches: if this package should have quilt patches
        :param list(str) changelog_versions: if specified, version is ignored,
            and the changelog is generated with this list of versions instead.
            changelog_versions[0] is taken to be the version string of the
            package itself.
        :param dict(str -> str) file_contents: a dictionary of string
            relative file path names to string contents. The source
            package should contain the specified file names with the
            specified contents.  This is used to control the tree hashes
            of generated source packages.
        :param mutate: if bool(mutate) is True, then this will add a file
            called debian/mutate containing the data str(mutate).

        Keyword arguments to the constructor map directly to class instances
        properties. Properties may be manipulated after construction.
        """
        for k, v in kwargs.items():
            setattr(self, k, v)

        if self.changelog_versions:
            self.version = self.changelog_versions[0]

            if self.native and any(
                '-' in version for version in self.changelog_versions
            ):
                raise ValueError("Version must not have a '-' in a native package")

            if not self.native and any(
                '-' not in version for version in self.changelog_versions
            ):
                raise ValueError("Version must have a '-' in a non-native package")
        else:
            self.changelog_versions = []

            # If the version was not explicitly set, toggle the default for
            # non-native packages
            if 'version' not in kwargs and self.native:
                self.version = '1'

            if self.native and '-' in self.version:
                raise ValueError("Version must not have a '-' in a native package")

            if not self.native and '-' not in self.version:
                raise ValueError("Version must have a '-' in a non-native package")

        if self.file_contents is None:
            self.file_contents = {}

        reserved_files_specified = [
            f for f in self.file_contents
            if f in self.reserved_files
        ]
        if reserved_files_specified:
            raise ValueError(
                "The file names %s are reserved" %
                ", ".join(reserved_files_specified)
            )

        absolute_path_files_specified = [
            f for f in self.file_contents
            if f.startswith('/')
        ]
        if absolute_path_files_specified:
            raise ValueError(
                "File names %s must be relative" %
                ", ".join(absolute_path_files_specified)
            )

class SourceFiles:
    """Representation of a Debian source package in terms of its files

    Instantiated with a SourceSpec, this class presents a set of attributes
    which map to the files in a Debian source package. It thus maps a
    SourceSpec to the files that should be present in a Debian source package
    that meets that spec.

    Where the property types are str, it is expected that the caller will
    encode them to UTF-8 as necessary.
    """
    def __init__(self, spec):
        """Instantiate a new SourceFiles instance

        :param SourceSpec spec: the specification the files will meet
        """
        self.spec = spec

    @property
    def control(self):
        """The contents of debian/control

        :rtype: str
        """
        return CONTROL_TEXT

    @property
    def rules(self):
        """The contents of debian/rules

        :rtype: str
        """
        return RULES_TEXT

    @property
    def changelog(self):
        """The contents of debian/changelog

        :rtype: str
        """
        versions = self.spec.changelog_versions or [self.spec.version]

        return "\n".join(
            CHANGELOG_TEMPLATE.format(
                version=version,
                date=self.spec.changelog_date,
            )
            for version in versions
        )

    @property
    def spec_files(self):
        """The contents of any files explicitly specified in self.spec

        :returns: a mapping of filename to content
        :rtype: dict(str, str)
        """
        result = dict(self.spec.file_contents)
        if self.spec.mutate:
            result['debian/mutate'] = str(self.spec.mutate)
        return result

    @property
    def source_format(self):
        """The contents of debian/source/format

        :rtype: str
        """
        if self.spec.native:
            return "3.0 (native)\n"
        else:
            return "3.0 (quilt)\n"

    @property
    def patches(self):
        """The contents of debian/patches/

        :returns: a mapping of filename to content
        :rtype: dict(str, str)

        The filenames are basenames only (without a path).
        """
        if self.spec.has_patches:
            a = NEW_FILE_PATCH_TEMPLATE.format(new_path='a', new_line='a')
            b = NEW_FILE_PATCH_TEMPLATE.format(new_path='b', new_line='b')
            return {
                'series': b"a\nb\n",
                'a': a,
                'b': b,
            }
        else:
            return None


class Source:
    """A Pythonic representation of a test Debian source package

    An instance is a context manager that, when invoked, creates the source
    package in a temporary directory and returns the full filesystem path to
    the dsc file, cleaning up on exit. Alternatively, use the write() and
    cleanup() methods directly."""
    def __init__(self, spec=None):
        """Construct a Source instance

        :param SourceSpec spec: what the package should contain
        """
        self.spec = spec or SourceSpec()
        self.files = SourceFiles(self.spec)

        self.tmpdir = None
        self.dsc_path = self.changes_path = None

    def cleanup(self):
        """Remove temporary directory and source package files after write()

        This method is idempotent and does nothing if write() has never been
        called or if a write() has already had cleanup() called on it.
        """
        if self.tmpdir is not None:
            self.tmpdir.cleanup()
            self.tmpdir = None
            self.dsc_path = self.changes_path = None

    def __del__(self):
        self.cleanup()

    def __enter__(self):
        return self.write()

    def __exit__(self, exc_type, exc_value, tb):
        self.cleanup()

    def write(self):
        """Write the source package to a temporary directory

        :rtype: str
        :returns: full filesystem path to a dsc file

        Once this method is called, it must not be called again unless
        cleanup() has been called first.
        """
        assert self.tmpdir is None
        self.tmpdir = tempfile.TemporaryDirectory()
        # pylint bug: https://github.com/PyCQA/pylint/issues/640
        top = py.path.local(self.tmpdir.name)  # pylint: disable=no-member
        tree = top.join('srcpkg')
        debian = tree.join('debian')
        for filename in ['control', 'rules', 'changelog']:
            debian.join(filename).write_text(
                getattr(self.files, filename),
                encoding='utf-8',
                ensure=True,
            )
        for (filename, contents) in self.files.spec_files.items():
            tree.join(filename).write_text(
                contents,
                encoding='utf-8',
                ensure=True,
            )
        debian.join('source/format').write_text(
            self.files.source_format,
            encoding='utf-8',
            ensure=True,
        )
        debian.join('rules').chmod(0o755)
        patches = self.files.patches
        if patches is not None:
            patch_path = debian.join('patches')
            patch_path.ensure_dir()
            for basename, content in patches.items():
                patch_path.join(basename).write(content)
        if not self.spec.native:
            orig_version, _ = self.spec.version.rsplit('-', maxsplit=1)
            orig_basename = (
                'source-builder-package_%s.orig.tar.gz' % orig_version
            )
            tarfile.open(str(top.join(orig_basename)), 'w:gz').close()
        subprocess.check_call(
            ['dpkg-source', '--build', tree.basename],
            cwd=str(top),
        )

        for entry in top.listdir():
            if entry.basename.endswith('.dsc'):
                self.dsc_path = str(entry)

        assert self.dsc_path is not None
        return self.dsc_path
