#!/usr/bin/env python3
import argparse
import os
import platform
import random
import re
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
from abc import ABC, abstractmethod, abstractproperty
from copy import deepcopy
from enum import Enum
from functools import lru_cache
from pathlib import Path
from typing import Dict, List, Optional
from uuid import uuid4

try:
    from tqdm import tqdm
except ModuleNotFoundError:

    def tqdm(iterable, *args, **kwargs):
        return iterable

    if os.isatty(sys.stdout.fileno()) and random.randint(0, 10) <= 1:
        print('TIP: run `pip install -m tqdm` to get a nice progress bar')

project_root = Path(__file__).resolve().parent.parent
dependencies_dir = project_root / '.dependencies'


def bootstrap(*args, interactive=False, check=False):
    """Run the bootstrap script."""
    bootstrap_py = project_root / 'utils' / 'bootstrap.py'
    result = subprocess.run([sys.executable, str(bootstrap_py)] + list(args),
                            check=False,
                            encoding='utf-8',
                            stdout=None if interactive else subprocess.PIPE,
                            stderr=None if interactive else subprocess.PIPE)
    return result


def project_version():
    """Return current project version (e. g. "2.0.0")"""
    with open(project_root / 'version.txt', 'r') as f:
        return f.read().strip()


@lru_cache()
def get_dependency(name):
    install_dir = Path(
        bootstrap('--print-dependency-directory', name,
                  check=True).stdout.strip())
    suffix = '.exe' if platform.system() == 'Windows' else ''
    if name == 'ninja':
        return install_dir / ('ninja' + suffix)
    elif name == 'cmake':
        return install_dir / 'bin' / ('cmake' + suffix)
    else:
        return install_dir


class BuildType(Enum):
    """Represents the -DCONFIG CMake option."""

    DEBUG = 'DEBUG'
    RELEASE = 'RELEASE'


class BuildConfiguration(ABC):
    @abstractmethod
    def get_cmake_cache_entries(self):
        """Convert the build configuration to CMake cache entries."""

    @abstractmethod
    def get_cmake_flags(self, build_dir: Path) -> List[str]:
        """Return all CMake command-line flags required to build this configuration."""

    @abstractproperty
    def name(self):
        """Name of the configuration."""

    def __hash__(self):
        return hash(self.name)


class FirmwareBuildConfiguration(BuildConfiguration):
    def __init__(self,
                 build_type: BuildType,
                 toolchain: Path = None,
                 generator: str = None,
                 version_suffix: str = None,
                 version_suffix_short: str = None,
                 custom_entries: List[str] = None):
        self.build_type = build_type
        self.toolchain = toolchain or FirmwareBuildConfiguration.default_toolchain(
        )
        self.generator = generator
        self.version_suffix = version_suffix
        self.version_suffix_short = version_suffix_short
        self.custom_entries = custom_entries or []

    @staticmethod
    def default_toolchain() -> Path:
        return Path(__file__).resolve().parent.parent / 'cmake/AvrGcc.cmake'

    def get_cmake_cache_entries(self):
        entries = []
        if self.generator.lower() == 'ninja':
            entries.append(('CMAKE_MAKE_PROGRAM', 'FILEPATH',
                            str(get_dependency('ninja'))))
        entries.extend([
            ('CMAKE_MAKE_PROGRAM', 'FILEPATH', str(get_dependency('ninja'))),
            ('CMAKE_TOOLCHAIN_FILE', 'FILEPATH', str(self.toolchain)),
            ('AVR_TOOLCHAIN_DIR', 'DIRPATH', str(get_dependency('avr-gcc'))),
            ('CMAKE_BUILD_TYPE', 'STRING', self.build_type.value.title()),
            ('PROJECT_VERSION_SUFFIX', 'STRING', self.version_suffix or ''),
            ('PROJECT_VERSION_SUFFIX_SHORT', 'STRING',
             self.version_suffix_short or ''),
        ])
        entries.extend(self.custom_entries)
        return entries

    def get_cmake_flags(self, build_dir: Path) -> List[str]:
        cache_entries = self.get_cmake_cache_entries()
        flags = ['-D{}:{}={}'.format(*entry) for entry in cache_entries]
        flags += ['-G', self.generator or 'Ninja']
        flags += ['-S', str(Path(__file__).resolve().parent.parent)]
        flags += ['-B', str(build_dir)]
        return flags

    @property
    def name(self):
        components = [
            self.build_type.value,
        ]
        return '_'.join(components)


class BuildResult:
    """Represents a result of an attempt to build the project."""

    def __init__(self, config_returncode: int, build_returncode: Optional[int],
                 stdout: Path, stderr: Path, products: List[Path]):
        self.config_returncode = config_returncode
        self.build_returncode = build_returncode
        self.stdout = stdout
        self.stderr = stderr
        self.products = products

    @property
    def configuration_failed(self):
        return self.config_returncode != 0

    @property
    def build_failed(self):
        return self.build_returncode != 0 and self.build_returncode is not None

    @property
    def is_failure(self):
        return self.configuration_failed or self.build_failed

    def __str__(self):
        return '<BuildResult config={self.config_returncode} build={self.build_returncode}>'.format(
            self=self)


def build(configuration: BuildConfiguration,
          build_dir: Path,
          configure_only=False,
          output_to_file=True) -> BuildResult:
    """Build a project with a single configuration."""
    flags = configuration.get_cmake_flags(build_dir=build_dir)

    # create the build directory
    build_dir.mkdir(parents=True, exist_ok=True)
    products = []

    if output_to_file:
        # stdout and stderr are saved to a file in the build directory
        stdout_path = build_dir / 'stdout.txt'
        stderr_path = build_dir / 'stderr.txt'
        stdout = open(stdout_path, 'w')
        stderr = open(stderr_path, 'w')
    else:
        stdout_path, stderr_path = None, None
        stdout, stderr = None, None

    # prepare the build
    config_process = subprocess.run([str(get_dependency('cmake'))] + flags,
                                    stdout=stdout,
                                    stderr=stderr,
                                    check=False)
    if not configure_only and config_process.returncode == 0:
        cmd = [
            str(get_dependency('cmake')), '--build',
            str(build_dir), '--config',
            configuration.build_type.value.lower()
        ]
        build_process = subprocess.run(cmd,
                                       stdout=stdout,
                                       stderr=stderr,
                                       check=False)
        build_returncode = build_process.returncode
        products.extend(build_dir / fname for fname in [
            'firmware', 'firmware.bin', 'firmware.bbf', 'firmware.dfu',
            'firmware.map'
        ] if (build_dir / fname).exists())
    else:
        build_returncode = None

    if stdout:
        stdout.close()
    if stderr:
        stderr.close()

    # collect the result and return
    return BuildResult(config_returncode=config_process.returncode,
                       build_returncode=build_returncode,
                       stdout=stdout_path,
                       stderr=stderr_path,
                       products=products)


def store_products(products: List[Path], build_config: BuildConfiguration,
                   products_dir: Path):
    """Copy build products to a shared products directory."""
    products_dir.mkdir(parents=True, exist_ok=True)
    for product in products:
        is_firmware = isinstance(build_config, FirmwareBuildConfiguration)
        has_custom_suffix = is_firmware and (build_config.version_suffix !=
                                             '<auto>')
        if has_custom_suffix:
            version = project_version()
            name = build_config.name.lower(
            ) + '_' + version + build_config.version_suffix
        else:
            name = build_config.name.lower()
        destination = products_dir / (name + product.suffix)
        shutil.copy(product, destination)


def list_of(EnumType):
    """Create an argument-parser for comma-separated list of values of some Enum subclass."""

    def convert(val):
        if val == '':
            return []
        values = [p.lower() for p in val.split(',')]
        if 'all' in values:
            return list(EnumType)
        else:
            return [EnumType(v.upper()) for v in values]

    convert.__name__ = EnumType.__name__
    return convert


def cmake_cache_entry(arg):
    match = re.fullmatch(r'(.*):(.*)=(.*)', arg)
    if not match:
        raise ValueError('invalid cmake entry; must be <NAME>:<TYPE>=<VALUE>')
    return (match.group(1), match.group(2), match.group(3))


def main():
    parser = argparse.ArgumentParser()
    # yapf: disable
    parser.add_argument(
        '--build-type',
        type=list_of(BuildType),
        default='release',
        help=('Build type (debug or release; default: release; '
              'default for --generate-cproject: debug,release).'))
    parser.add_argument(
        '--version-suffix',
        type=str,
        default='<auto>',
        help='Version suffix (e.g. -BETA+1035.PR111.B4)')
    parser.add_argument(
        '--version-suffix-short',
        type=str,
        default='<auto>',
        help='Version suffix (e.g. +1035)')
    parser.add_argument(
        '--final',
        action='store_true',
        help='Set\'s --version-suffix and --version-suffix-short to empty string.')
    parser.add_argument(
        '--build-dir',
        type=Path,
        help='Specify a custom build directory to be used.')
    parser.add_argument(
        '--products-dir',
        type=Path,
        help='Directory to store built firmware (default: <build-dir>/products).')
    parser.add_argument(
        '-G', '--generator',
        type=str,
        default='Ninja',
        help='Generator to be used by CMake (default=Ninja).')
    parser.add_argument(
        '--toolchain',
        type=Path,
        help='Path to a CMake toolchain file to be used.')
    parser.add_argument(
        '--no-build',
        action='store_true',
        help='Do not build, configure the build only.'
    )
    parser.add_argument(
        '--no-store-output',
        action='store_false',
        help='Do not write build output to files - print it to console instead.'
    )
    parser.add_argument(
        '-D', '--cmake-def',
        action='append', type=cmake_cache_entry,
        help='Custom CMake cache entries (e.g. -DCUSTOM_COMPILE_OPTIONS:STRING=-Werror)'
    )
    args = parser.parse_args(sys.argv[1:])
    # yapf: enable

    build_dir_root = args.build_dir or Path(
        __file__).resolve().parent.parent / 'build'
    products_dir_root = args.products_dir or (build_dir_root / 'products')

    if args.final:
        args.version_suffix = ''
        args.version_suffix_short = ''

    # Check all dependencis are installed
    if bootstrap(interactive=True).returncode != 0:
        print('bootstrap.py failed.')
        sys.exit(1)

    # prepare configurations
    configurations = [
        FirmwareBuildConfiguration(
            build_type=build_type,
            version_suffix=args.version_suffix,
            version_suffix_short=args.version_suffix_short,
            generator=args.generator,
            custom_entries=args.cmake_def) for build_type in args.build_type
    ]

    # build everything
    configurations_iter = tqdm(configurations)
    results: Dict[BuildConfiguration, BuildResult] = dict()
    for configuration in configurations_iter:
        build_dir = build_dir_root / configuration.name.lower()
        description = 'Building ' + configuration.name.lower()
        if hasattr(configurations_iter, 'set_description'):
            configurations_iter.set_description(description)
        else:
            print(description)
        result = build(configuration,
                       build_dir=build_dir,
                       configure_only=args.no_build,
                       output_to_file=args.no_store_output is not False)
        store_products(result.products, configuration, products_dir_root)
        results[configuration] = result

    # print results
    print()
    print('Building finished: {} success, {} failure(s).'.format(
        sum(1 for result in results.values() if not result.is_failure),
        sum(1 for result in results.values() if result.is_failure)))
    failure = False
    max_configname_len = max(len(config.name) for config in results)
    for config, result in results.items():
        if result.configuration_failed:
            status = 'project configuration FAILED'
            failure = True
        elif result.build_failed:
            status = 'build FAILED'
            failure = True
        else:
            status = 'SUCCESS'

        print(' {} {}'.format(
            config.name.lower().ljust(max_configname_len, ' '), status))
    if failure:
        sys.exit(1)


if __name__ == "__main__":
    main()
