#! /usr/bin/env python3
import sys
import getopt
import os.path
import platform
import pathlib
import typing as T
import configparser
from zenlog import log as logging

import accendino

from accendino.builditems import BuildArtifact, AutogenBuildArtifact, CMakeBuildArtifact, DepsBuildArtifact, \
    MesonBuildArtifact, QMakeBuildArtifact, CustomCommandBuildArtifact
from accendino.localdeps import getPkgManager
from accendino.sources import LocalSource, GitSource, RemoteArchiveSource
from accendino.utils import ConditionalDep, DepsAdjuster, checkVersionCondition, checkAccendinoVersion, \
    NativePath, RunInShell, mergePkgDeps, is_exact_instance
from accendino.toolchain import getToolchain



def doHelp(args, is_error) -> int:
    ''' '''
    print(f"usage: {args[0]} [--help] [--prefix=<prefix>] [--debug] [--targets=<targets>] [--build-type=<type>] <file>")
    print("\t--help: shows this help")
    print("\t--version: prints the version")
    print("\t--debug: show verbose information of building")
    print("\t--prefix=<prefix>: where to install")
    print("\t--targets=<targets>: a list of comma separated targets to build (by default full-ogon-freerdp2)")
    print("\t--build-type=[release|debug]: type of build (defaults to release)")
    print("\t--work-dir=<path>: a path to the working directory where sources are checked out and built")
    print("\t--options=<path>: a path to the build options file")
    if is_error:
        return 1

    return 0

def detectPlatform() -> None:
    ''' '''
    distrib_id = None
    distrib_version = None

    plat_system = platform.system()
    if plat_system == 'Linux':
        # try reading LSB file
        try:
            for l in open("/etc/lsb-release", "rt", encoding='utf8').readlines():
                pos = l.find("DISTRIB_ID=")
                if pos == 0:
                    distrib_id = l[len('DISTRIB_ID='):-1]
                    continue

                pos = l.find('DISTRIB_RELEASE=')
                if pos == 0:
                    distrib_version = l[len('DISTRIB_RELEASE='):-1]
                    continue

            return (distrib_id, distrib_version)
        except:
            logging.debug(" * failed to detect platform using /etc/lsb-release (not an error)")

        # Debian based systems
        try:
            content = open("/etc/debian_version", "rt", encoding='utf8').readline()
            distrib_id = "Debian"
            tokens = content.split('.', 2)
            distrib_version = f"{int(tokens[0]):02d}.{int(tokens[1]):02d}"
            return (distrib_id, distrib_version)
        except:
            logging.debug(" * failed reading /etc/debian_version  (not an error)")

        # Fedora based systems
        try:
            content = open("/etc/fedora-release", "rt", encoding='utf8').readline()
            distrib_id = "Fedora"
            tokens = content.split(' ', 4)
            distrib_version = tokens[2]
            return (distrib_id, distrib_version)
        except:
            logging.debug(" * failed reading /etc/fedora-version  (not an error)")

    elif plat_system in ('Linux', 'FreeBSD',):
        # Arch / FreeBSD
        try:
            distrib_id = None
            distrib_ver = None
            for l in open("/etc/os-release", "rt", encoding='utf8').readlines():
                l = l.strip()
                if l.startswith('ID='):
                    distrib_id = l[3:]
                elif l.starstwith('BUILD_ID='):
                    distrib_ver = l[9:]
                elif l.startswith('VERSION_ID="'):
                    distrib_ver = l[len('VERSION_ID="') : -1]

                if distrib_id is not None and distrib_ver is not None:
                    return (distrib_id, distrib_ver)
        except:
            logging.debug(" * failed reading /etc/os-release")

    elif plat_system in ("Windows", "Darwin",):
        distrib_id = plat_system
        if plat_system == "Windows":
            distrib_version = platform.win32_ver()[0]
        else:
            distrib_version = platform.mac_ver()[0]

    return (distrib_id, distrib_version)


BUILD_TYPES = ('release', 'debug',)
ARCHS = ('i686', 'x86_64')

ARCHS_MAP = {
    'AMD64': 'x86_64', # Windows
    'amd64': 'x86_64', # FreeBSD
}

class AccendinoConfig:
    ''' Accendino configuration '''

    def __init__(self) -> None:
        ''' '''
        self.debug = False
        self.buildWithPowershell = True
        self.buildType = 'release'
        self.projectName = None
        self.workDir = pathlib.PurePath(os.getcwd())
        self.prefix = None
        self.projectDir = None
        self.sourcesDir = None
        self.buildsDir = None
        self.toolsDir = None
        self.targets = None
        self.buildDefs = []
        self.distribId = None
        self.distribVersion = None
        self.checkPackages = True
        self.doBuild = True
        self.libdir = 'lib'
        self.resumeFrom = None
        self.maxJobs = 5
        self.crossCompilation = False
        self.toolchain = 'default'
        self.toolchainObj = None

        localMachine = platform.machine()
        self.localArch = ARCHS_MAP.get(localMachine, localMachine)
        self.targetArch = None
        self.targetDistrib = None
        self.pocketDir = pathlib.Path(__file__).parent / 'pocket'
        self.crossFileDir = pathlib.Path(__file__).parent / 'cross'
        self.getCrossPlatformFile = None
        self.optionsFile = None
        self.options = None
        self.includesStack = []

        #
        # populate a search path that has:
        #   * first paths provided in ACCENDINO_PATH,
        #   * then in the accendino pocket
        self.pocketSearchPaths = os.environ.get('ACCENDINO_PATH', '').split(':') + [ self.pocketDir ]
        self.includedFiles = []

        def includeFn(fname: str, include_once: bool = True) -> bool:
            ''' '''
            if not fname.endswith('.accendino'):
                fname = fname + '.accendino'

            logging.debug(f"including file '{fname}'")
            ret = self.readSource(fname, include_once)
            if ret:
                self.includedFiles.append(fname)
            return ret


        def pickDeps(name: str) -> T.List[T.Any]:
            ''' '''
            targets = self.context.get("ARTIFACTS", [])
            for t in targets:
                if t.name == name:
                    return t.deps[:]
            return None

        def pickPkgDeps(name: str, extra = None, override : bool = False) -> T.Dict[str, T.List[str]]:
            ''' '''
            ret = None
            targets = self.context.get("ARTIFACTS", [])
            for t in targets:
                if t.name == name:
                    ret = t.pkgs.copy()
                    break

            if ret is None:
                logging.error(f'error picking package dependencies of {name}')

            if extra:
                for k, v in extra.items():
                    for subk in k.split('|'):
                        if override:
                            base_v = []
                        else:
                            base_v = ret.get(subk, [])

                        ret[subk] = base_v + v

            return ret

        def checkDistrib(cond: str) -> bool:
            return checkVersionCondition(cond, self.distribId, self.distribVersion)

        def checkAccendinoVersionFn(cond: str) -> bool:
            return checkAccendinoVersion(cond, accendino.__version__)

        def getOption(optName: str, defaultVal=None):
            if self.options is None:
                return defaultVal

            tokens = optName.split('.', 2)
            if len(tokens) == 2:
                (section, name) = tokens
            else:
                section = configparser.UNNAMED_SECTION
                name = optName

            if not section in self.options.sections():
                return defaultVal

            ret = self.options[section].get(name, defaultVal)
            if isinstance(ret, str):
                if ret.lower() in ('true', 'on', 'yes',):
                    return True

                if ret.lower() in ('false', 'off', 'no',):
                    return False

            return ret


        self.sources = []
        self.context = {
            'ARTIFACTS': [],
            'DEFAULT_TARGETS': None,
            'LocalSource': LocalSource,
            'GitSource': GitSource,
            'RemoteArchiveSource': RemoteArchiveSource,
            'DepsBuildArtifact': DepsBuildArtifact,
            'CMakeBuildArtifact': CMakeBuildArtifact,
            'QMakeBuildArtifact': QMakeBuildArtifact,
            'AutogenBuildArtifact': AutogenBuildArtifact,
            'MesonBuildArtifact': MesonBuildArtifact,
            'CustomCommandBuildArtifact': CustomCommandBuildArtifact,
            'BuildArtifact': BuildArtifact,
            'include': includeFn,
            'getOption': getOption,
            'checkDistrib': checkDistrib,
            'pickDeps': pickDeps,
            'pickPkgDeps': pickPkgDeps,
            'mergePkgDeps': mergePkgDeps,
            'logging': logging,
            'DepsAdjuster': DepsAdjuster,
            'NativePath': NativePath,
            'RunInShell': RunInShell,
            'accendinoVersion': accendino.__version__,
            'UBUNTU_LIKE': 'Debian|Ubuntu',
            'REDHAT_LIKE': 'Fedora|Redhat',
            'checkAccendinoVersion': checkAccendinoVersionFn,
        }

    def findSourceFile(self, fname: str, include_once: bool = True) -> str:
        searchPaths = ['.', 'pocket'] + self.pocketSearchPaths

        # also search in the directory of the last included file
        if len(self.includesStack) > 0:
            searchPaths.append( os.path.dirname(self.includesStack[-1]) )

        # if the requested file is a path then just try to open it
        if fname.startswith('.') or fname.startswith('/'):
            searchPaths = ['']

        for p in searchPaths:
            fpath = os.path.join(p, fname)
            if os.path.exists(fpath) and os.path.isfile(fpath):
                if include_once and fpath in self.includedFiles:
                    logging.debug(f"file '{fpath}' already included")
                    return True
                return fpath

        return None

    def cmakeBuildType(self) -> str:
        ''' '''
        if self.buildType == 'release':
            return 'Release'

        if self.buildType == 'debug':
            return 'Debug'

        raise Exception(f"{self.buildType} build type not supported for cmake")

    def mesonBuildType(self) -> str:
        ''' '''
        if self.buildType in ['release', 'debug']:
            return self.buildType
        raise Exception(f"{self.buildType} build type not supported for meson")

    def getBuildItem(self, name: str) -> BuildArtifact:
        ''' '''
        for item in self.buildDefs:
            if item.name == name or name in item.provides:
                return item
        return None

    def default_getCrossPlatformFile(self, builder: str, localDistrib: str, targetDistrib : str, arch: str):
        ''' '''
        if targetDistrib == 'mingw':
            bits = '64' if arch == 'x86_64' else '32'

            if localDistrib in ('Redhat', 'Fedora',):
                return f'/usr/share/mingw/toolchain-mingw{bits}.{builder}'

            if localDistrib in ('Ubuntu', 'Debian',):
                return str(self.crossFileDir / f'debian-mingw{bits}.{builder}')

        logging.error(f'no cross platform file available for {targetDistrib}/{arch}')
        return None

    def setPlatform(self, distribId: str, distribVersion: str) -> None:
        self.distribId = distribId
        self.distribVersion = distribVersion
        if not self.targetDistrib:
            self.targetDistrib = distribId

        self.crossCompilation = (self.distribId != self.targetDistrib) or (self.localArch != self.targetArch)

        if self.distribId in ['Redhat', 'Fedora']:
            self.libdir = 'lib64'

        extraKeys = {
            'distribId': distribId,
            'distribVersion': distribVersion,
            'targetArch': self.targetArch,
            'targetDistribId': self.targetDistrib,
            'crossCompilation': self.crossCompilation,
            'buildType': self.buildType,
            'toolchain': self.toolchain,
            'libdir': self.libdir,
        }

        self.context.update(extraKeys)


    def treatPlatformPackages(self, pkgManager, packagesToCheck, buildItems) -> None:
        ''' '''
        # let's compute platform package requirements
        shortName = f"{self.distribId}"
        longName = f"{self.distribId} {self.distribVersion}"
        if self.targetDistrib != self.distribId:
            # cross compiling adjusting the searched names
            shortName += f'->{self.targetDistrib}@{self.targetArch}'
            longName += f'->{self.targetDistrib}@{self.targetArch}'

        toolchainArtifacts = []
        for item in buildItems:
            if isinstance(item, str):
                continue

            toolchainArtifacts += item.toolchainArtifacts

            if longName in item.pkgs:
                pkgs = item.pkgs[longName]
            elif shortName in item.pkgs:
                pkgs = item.pkgs[shortName]
            else:
                if not self.crossCompilation:
                    logging.debug(f" * warning, {item.name} has no package dependency for the current platform")
                continue

            packagesToCheck += pkgs[:]

        packagesToCheck = list(set(packagesToCheck))
        toolchainArtifacts = list(set(toolchainArtifacts))

        if pkgManager:
            if not self.toolchainObj.packagesCheck(pkgManager, toolchainArtifacts, True):
                logging.error(" * package requirements not met")
                return 5

            toInstall = pkgManager.checkMissing(packagesToCheck)
            if toInstall is None:
                logging.error(" * package requirements not met")
                return 5

            if toInstall:
                if not pkgManager.installPackages(toInstall):
                    logging.error(" * error during package installation")
                    return 4

        return 0

    def createBuildPlan(self, itemsToBuild, buildPlan) -> None:
        ''' '''

        def addBuildItems(items: T.List[str], buildPlan: T.List[str], provided: T.List[str]):
            for item in items:
                if item in buildPlan + provided:
                    continue

                itemObj = self.getBuildItem(item)
                if itemObj:
                    if itemObj.deps:
                        addBuildItems(itemObj.deps, buildPlan, provided)

                    buildPlan.append(itemObj.name)
                    if itemObj.provides:
                        if isinstance(itemObj.provides, list):
                            provided += itemObj.provides
                        else:
                            provided.append(itemObj.provides)

                else:
                    logging.error(f"unable to find build dependency {item}")

        plan = []
        provided = []
        addBuildItems(itemsToBuild, plan, provided)

        for itemStr in plan:
            item = self.getBuildItem(itemStr)
            if item:
                buildPlan.append(item)
            else:
                buildPlan.append(itemStr)


    def readSource(self, fname: str, include_once: bool) -> bool:
        ''' '''
        fpath = self.findSourceFile(fname, include_once)
        if isinstance(fpath, bool):
            # already included
            return True

        if fpath is None:
            logging.error(f'file {fname} not found')
            sys.exit(-1)

        self.includesStack.append(fpath)
        with open(fpath, "rt", encoding="utf8") as f:
            code = compile(f.read(), os.path.basename(fpath), "exec")
            exec(code, {}, self.context)
        self.includesStack.pop()
        return True

    def finalizeConfig(self) -> bool:
        ''' '''
        #
        for buildItem in self.context.get('ARTIFACTS', []):
            newDeps = []
            for dep in buildItem.deps:
                if isinstance(dep, str):
                    newDeps.append(dep)
                elif isinstance(dep, ConditionalDep):
                    newDeps = dep.apply(self, newDeps)
                else:
                    logging.error(f'don\'t know how to treat artifact dependency with type {type(dep)}')
            buildItem.deps = newDeps

            newPkg = {}
            for k, v in buildItem.pkgs.items():
                newList = []
                for dep in v:
                    if isinstance(dep, str):
                        newList.append(dep)
                    elif isinstance(dep, ConditionalDep):
                        newList = dep.apply(self, newList)
                    else:
                        logging.error(f'don\'t know how to treat package dependency with type {type(dep)}')

                newPkg[k] = newList
            buildItem.pkgs = newPkg

            self.buildDefs.append(buildItem)

        if not self.targets:
            defaultTargets = self.context.get('DEFAULT_TARGETS', 'ogon')
            if defaultTargets:
                self.targets = defaultTargets.split(",")

        if not self.projectName:
            self.projectName = self.context.get('PROJECT', 'work')

        self.projectDir = pathlib.PurePath(self.workDir, self.projectName)
        if not self.prefix:
            self.prefix = self.projectDir / 'deploy'

        self.sourcesDir = self.projectDir / 'sources'
        self.buildsDir = self.projectDir / 'build'
        self.toolsDir = self.projectDir / 'tools'

        self.getCrossPlatformFile = self.context.get('CROSS_PLATFORM_FILE_CHOOSER', self.default_getCrossPlatformFile)
        return True


def createWorkTree(config) -> int:
    ''' '''
    dirs = [
        (config.prefix, 'root directory'),
        (config.sourcesDir, 'sources directory'),
        (config.buildsDir, 'builds directory'),
    ]

    for dirItem in dirs:
        if not os.path.exists(dirItem[0]):
            logging.debug(f" * creating {dirItem[1]} {dirItem[0]}")
            os.makedirs(dirItem[0])
        else:
            logging.debug(f" * {dirItem[1]} {dirItem[0]} already exists")

        if not os.path.isdir(dirItem[0]):
            logging.error(f"{dirItem[1]} {dirItem[0]} is not a directory")
            return 1

    return 0

_ARGS_CONTINUE, _ARGS_HELP, _ARGS_VERSION, _ARGS_ERROR = range(4)

def treatArgOrOption(config, option, value, fromCmdLine) -> int:
    if not fromCmdLine:
        option = '--' + option

    if option in ('-h', '--help',):
        return _ARGS_HELP

    if option in ('-v', '--version',):
        print(accendino.__version__)
        return _ARGS_VERSION

    if option in ('--prefix',):
        config.prefix = pathlib.PurePath(os.path.abspath(value))
    elif option in ('-d', '--debug',):
        logging.level('debug')
        config.debug = True
    elif option in ('--build-type',):
        config.buildType = value
        if config.buildType not in BUILD_TYPES:
            print(f"invalid build type {config.buildType}")
            return _ARGS_ERROR
    elif option in ('--targets',):
        config.targets = value.split(',')
    elif option in ('--buildWithPowershell',):
        config.buildWithPowershell = True
    elif option in ('--no-packages',):
        config.checkPackages = False
    elif option in ('--build-deps',):
        config.doBuild = False
    elif option in ('--work-dir', ):
        config.workDir = pathlib.PurePath(os.path.abspath(value))
    elif option in ("--resume-from", ):
        config.resumeFrom = value
    elif option in ('--options',):
        if not fromCmdLine:
            logging.error("options file can't be given in an options file")
            return _ARGS_ERROR
        config.optionsFile = value
    elif option in ("--targetArch", ):
        if not value in ARCHS:
            logging.error(f'arch {value} not yet supported')
            return _ARGS_ERROR
        config.targetArch = value
    elif option in ("--targetDistrib", ):
        if value == 'mingw32':
            config.targetDistrib = 'mingw'
            config.targetArch = 'i686'
            config.toolchain = 'mingw'
        elif value == 'mingw64':
            config.targetDistrib = 'mingw'
            config.targetArch = 'x86_64'
            config.toolchain = 'mingw'
        else:
            config.targetDistrib = value
    elif option in ('--toolchain',):
        config.toolchain = value
    elif option in ("--project", ):
        config.projectName = value
    else:
        logging.error(f"unknown option {value}")
        return _ARGS_ERROR

    return _ARGS_CONTINUE


def run(args: T.List[str]) -> int:
    ''' '''
    logging.level("info")

    config = AccendinoConfig()

    opts, extraArgs = getopt.getopt(args[1:], "hdv", [
        "prefix=", "help", "debug", "no-packages", "build-deps", "targets=", "build-type=", "options=",
        "work-dir=", "resume-from=", "project=", "targetDistrib=", "targetArch=", "toolchain=",
        "buildWithPowershell", "version"
    ])

    for option, value in opts:
        argRes = treatArgOrOption(config, option, value, True)
        if argRes in (_ARGS_ERROR, _ARGS_HELP,):
            return doHelp(args[1:], argRes == _ARGS_ERROR)
        if argRes == _ARGS_VERSION:
            return 0

    logging.info("=== accendino ===")

    if config.optionsFile:
        logging.debug(f'reading build options from {config.optionsFile}')
        config.options = configparser.ConfigParser()
        config.options.optionxform = str
        config.options.read(config.optionsFile)

        if 'accendino' in config.options.sections():
            # inject key/values in the accendino section as command line args
            for option, value in config.options['accendino'].items():
                argRes = treatArgOrOption(config, option, value, False)
                if argRes in (_ARGS_ERROR, _ARGS_HELP, _ARGS_VERSION):
                    logging.error(f'invalid item {option}={value} in accendino section')
                    return 1


    config.sources += extraArgs
    if not config.sources: # defaults to build ogon
        logging.info('no source file provided using default ogon.accendino')
        config.sources += ['ogon.accendino']

    (distribId, distribVersion) = detectPlatform()

    if config.targetArch is None:
        config.targetArch = config.localArch

    logging.debug(f"target installation: {distribId} {distribVersion}")
    config.setPlatform(distribId, distribVersion)

    # grab a toolchain
    config.toolchainObj = getToolchain(config.toolchain, config)
    if not config.toolchainObj:
        logging.error(f"unable to find toolchain {config.toolchain}")
        return 2

    for f in config.sources:
        if not config.readSource(f, True):
            logging.error(f"error interpreting {f}")
            return 2
    config.finalizeConfig()

    retCode = createWorkTree(config)
    if retCode:
        return retCode

    buildList = []
    for t in config.targets:
        item = config.getBuildItem(t)
        if item:
            buildList.append(item)

    buildPlan = []
    config.createBuildPlan(config.targets, buildPlan)
    if config.debug:
        items = []
        for i in buildPlan:
            items.append(i.name)

        logging.debug(f"build plan: [{', '.join(items)}]")

    if config.checkPackages:
        packagesToCheck = []
        pkgManager = getPkgManager(config.distribId, packagesToCheck)

        retCode = config.treatPlatformPackages(pkgManager, packagesToCheck, buildPlan)
        if retCode:
            return retCode

    logging.debug(f'==> activating toolchain {config.toolchainObj.description}')
    if not config.toolchainObj.activate():
        logging.error('error activating toolchain')
        return 6


    def buildModule(buildItem) -> bool:
        ''' '''
        logging.debug('==> preparing')
        if not buildItem.prepare(config):
            logging.error(f"prepare error for {buildItem.name}")
            return False

        logging.debug('==> building')
        if not buildItem.build(config):
            logging.error(f"build error for {buildItem.name}, check logs in {buildItem.logFile}")
            return False
        return True

    exitCode = 0
    if config.doBuild:
        for item in buildPlan:
            isDepsBuildArtifact = is_exact_instance(item, DepsBuildArtifact)

            extra = ' is only deps' if isDepsBuildArtifact else ''
            logging.info(f' * module {item.name}{extra}')

            if not isDepsBuildArtifact:
                logging.debug('==> init')
                if not item.init(config):
                    logging.error(f"error initializing {item.name}")
                    return 1

                logging.debug('==> checking out')
                if not item.checkout(config):
                    logging.error(f"checkout error for {item.name}")
                    return 1

            if config.resumeFrom:
                if item.name != config.resumeFrom:
                    continue

                # We've found the item to resume the build from
                config.resumeFrom = None

            if not isDepsBuildArtifact:
                if not buildModule(item):
                    return 1

    logging.info("=== finished ===")
    return exitCode

def main() -> int:
    ''' '''
    return run(sys.argv)

if __name__ == '__main__':
    if platform.python_version_tuple()[0] != '3':
        print("expecting python 3, exiting")
        sys.exit(1)

    sys.exit( main() )
