# derivative-dist-compare, Debian package tracker for derivative distributions
# Copyright (C) 2024-2026 IKUS Software <patrik@ikus-soft.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
import argparse
import json
import logging
import os
import re
import shutil
import sys
from collections import defaultdict, namedtuple
from dataclasses import asdict, dataclass, field, is_dataclass
from datetime import datetime
from functools import cmp_to_key
from itertools import count
from pathlib import Path

import apt_pkg
import yaml
from apt.progress.text import AcquireProgress
from jinja2 import Environment, PackageLoader

from . import __version__

DEFAULT_CACHE_DIR = "/var/cache/derivative-dist-compare"

logger = logging.getLogger(__name__)

Status = namedtuple('Status', 'value,code,description,order')
Status.not_found = Status('1', 'not_found', 'Not found in parent distribution', 5)  # blue
Status.greater_version = Status('2', 'greater_version', 'Greater then parent distribution', 6)  # purple
Status.greater_revision = Status('3', 'greater_revision', 'Greater revision then parent distribution', 6)  # purple
Status.uptodate = Status('4', 'uptodate', 'Up-to-date with parent distribution', 1)  # green
Status.lesser_revision = Status('5', 'lesser_revision', 'Lesser revision then parent distribution', 2)  # yellow
Status.lesser_version = Status('6', 'lesser_version', 'Lesser version then parent distribution', 3)  # red
Status.not_provided = Status('7', 'not_provided', 'Not found in derivative distribution', 4)  # dark red


@dataclass
class PackageSetInfo:
    name: str
    depth: int
    order: int


@dataclass
class SourceInfo:
    source_name: str
    homepage: str | None = None
    vcs_browser: str | None = None
    package_set: PackageSetInfo | None = None
    dist: dict = field(default_factory=dict)
    duplicate: bool = False
    tracker_url: str | None = None
    bugs_url: str | None = None
    download: str | None = None


@dataclass
class SourcePkg:
    dist: str
    source_name: str
    source_ver: str
    homepage: str
    vcs_browser: str
    download: str


@dataclass
class BinaryPkg:
    dist: str
    name: str
    arch: str
    udeb: bool
    version: str
    source_name: str
    source_ver: str


@dataclass
class DistInfo:
    source_ver: str
    source_ver_parent: str
    compare: Status
    arch: dict = field(default_factory=dict)
    vcs_browser: str | None = None


@dataclass
class ArchInfo:
    compare: Status
    packages: list = field(default_factory=list)


@dataclass
class PkgInfo:
    name: str
    version: str
    version_parent: str
    compare: Status


@dataclass
class PackageSet:
    name: str
    depth: int
    order: int


class Config:
    def __init__(self, input):
        if isinstance(input, dict):
            self.data = input
            self.config_file = 'test'
        else:
            self.config_file = input
            with open(input, 'r') as f:
                self.data = yaml.safe_load(f)

    def all_dist(self):
        """Return list distribution."""
        return list(self.data.get('distros').keys())

    def dist_pairs(self):
        """Return list of derivative distribution (where a parent is defined)"""
        return [
            {'dist': dist, 'parent': value['parent'], 'vendor_suffix': value.get('vendor-suffix', False)}
            for dist, value in self.data.get('distros', {}).items()
            if 'parent' in value
        ]

    def get_archs(self):
        """Return list of archs."""
        if 'archs' in self.data:
            return self.data['archs']
        return None

    def get_cache_dir(self):
        """Return path to be used as a cache."""
        if 'cache-dir' in self.data:
            return Path(self.data['cache-dir'])
        return Path(os.path.join(DEFAULT_CACHE_DIR, os.path.basename(self.config_file)))

    def get_sources(self, dist):
        """Retrieve the 'source' entries for a distribution."""
        if dist not in self.data.get('distros'):
            raise ValueError(f"Distribution '{dist}' not found in the config file.")
        # Get sources as string
        sources = self.data['distros'][dist].get('sources')
        if sources:
            return sources
        # Get source.list as string
        sources_list = self.data['distros'][dist].get('sources.list')
        if sources_list:
            return sources_list
        raise ValueError(f"`sources` or `sources.list` not found in config file for '{dist}'.")

    def get_preferences(self, dist):
        """Retrieve the 'preferences' entries for a distribution."""
        if dist not in self.data.get('distros'):
            raise ValueError(f"Distribution '{dist}' not found in the config file.")
        # Get sources as string
        preferences = self.data['distros'][dist].get('preferences')
        if preferences:
            return str(preferences)
        return ""

    def get_package_sets(self):
        """Flatten the package set."""

        # The yaml configuration file accept a combination of string, list and dict to
        # ease the configuration. But our data structure should only contains dict of dict.
        def _normalize(node):
            """Normalize YAML-friendly shapes into dict[str, dict]."""
            if node is None:
                return {}
            if isinstance(node, str):
                return {node: {}}
            if isinstance(node, list):
                out = {}
                for elem in node:
                    out.update(_normalize(elem))
                return out
            if isinstance(node, dict):
                return {k: _normalize(v) for k, v in node.items()}
            raise TypeError(f"Unsupported type in package-sets: {type(node)!r}")

        def _flatten(tree, set_name, counter, depth=0):
            """Yield (source_name, PackageSet) in pre-order DFS with depth and order."""
            for source_name, deps in tree.items():
                yield (
                    source_name,
                    PackageSet(
                        name=set_name,
                        depth=depth,
                        order=next(counter),
                    ),
                )
                if deps:
                    yield from _flatten(deps, set_name, counter, depth + 1)

        flat = []
        package_sets = _normalize(self.data.get("package-sets", []))
        for set_name, packages in package_sets.items():
            counter = count()  # order resets per package set (same as your original)
            flat.extend(_flatten(packages, set_name, counter))
        return flat

    def get_tracker_url(self, dist):
        """Return the tracker URL, if defined, for a distribution."""
        if dist not in self.data.get('distros'):
            raise ValueError(f"Distribution '{dist}' not found in the config file.")
        # Get sources as string
        url = self.data['distros'][dist].get('tracker-url')
        if url:
            return str(url)
        return None

    def get_bugs_url(self, dist):
        """Return the bugs URL, if defined, for a distribution."""
        if dist not in self.data.get('distros'):
            raise ValueError(f"Distribution '{dist}' not found in the config file.")
        # Get sources as string
        url = self.data['distros'][dist].get('bugs-url')
        if url:
            return str(url)
        return None

    def get_template(self):
        """Return location of a custom template relative to config file."""
        if 'template' in self.data:
            if self.config_file:
                return Path(os.path.normpath(os.path.join(self.config_file, '..', self.data['template'])))
            return Path(self.data['template'])
        return None


def _format_url(url, pkg_name):
    """
    Format tracker-url and bugs-url.
    """
    if '%s' in url:
        return url % pkg_name
    return url + pkg_name


def _group_by(iterable, key_func):
    groups = defaultdict(list)
    for item in iterable:
        groups[key_func(item)].append(item)
    return dict(groups)


def _get_parser():
    """Parse command line arguments."""
    parser = argparse.ArgumentParser(description='Debian package tracker for derivative distributions')

    # Add arguments
    parser.add_argument('-v', '--version', action='version', version=f'%(prog)s {__version__}')
    parser.add_argument('-d', '--debug', action='store_true', help='Enable debug mode')
    parser.add_argument('-l', '--log-file', type=str, help='Log file location')
    parser.add_argument(
        '-f', '--config-file', type=Path, help='Config file', required=True, default='/etc/derivative-dist-compare.conf'
    )
    parser.add_argument('-j', '--json-output', type=Path, help='Json output file')
    parser.add_argument('-o', '--html-output', type=Path, help='HTML output file')
    return parser


def _parse_args(args):
    # Parse the arguments
    args = sys.argv[1:] if args is None else args
    return _get_parser().parse_args(args)


def _setup_logging(debug=False, logfile=None):
    """Configure logging output either to a file or stdout."""
    root = logging.getLogger()
    root.setLevel(logging.DEBUG if debug else logging.INFO)

    # Configure stdout
    # With non_interactive mode (like cronjob), only print errors.
    try:
        interactive = sys.stdout and os.isatty(sys.stdout.fileno())
    except Exception:
        interactive = False
    default_level = logging.INFO if interactive else logging.ERROR
    console = logging.StreamHandler(stream=sys.stdout)
    console.setFormatter(logging.Formatter("%(message)s"))
    console.setLevel(logging.DEBUG if debug else default_level)
    root.addHandler(console)


def _split_version(value):
    """Split version in multiple parts."""
    if '-' in value:
        return value.rsplit('-', 1)
    return (value, '')


def highest_version(versions):
    filtered_version = {v for v in versions if v}
    if not filtered_version:
        return False
    if len(filtered_version) == 1:
        return filtered_version.pop()
    return max(filtered_version, key=cmp_to_key(apt_pkg.version_compare))


def compare_version(derivative_version, parent_version, vendor_suffix, source=False):
    """Compare version in a slightly different way to handle backports of native and non-native version scheme."""
    if not parent_version:
        return Status.not_found  # blue
    if not derivative_version:
        return Status.not_provided

    # Split version into upstream and revision
    derivative_upstream, derivative_revision = _split_version(derivative_version)
    parent_upstream, parent_revision = _split_version(parent_version)

    # Then compare upstream version directly.
    derivative_upstream_no_suffix = (
        re.sub(f'[~+]?{vendor_suffix}.*', '', derivative_upstream) if vendor_suffix else derivative_upstream
    )
    if apt_pkg.version_compare(derivative_upstream_no_suffix, parent_upstream) > 0:
        return Status.greater_version  # purple
    elif apt_pkg.version_compare(derivative_upstream_no_suffix, parent_upstream) < 0:
        return Status.lesser_version  # red

    # Take account of bin NMU (+b1)
    if source:
        derivative_revision = re.sub(r'\+b\d', '', derivative_revision)

    # Then compare revisions.
    # We need to consider versions as "uptodate" when removing the vendor suffix make them equals.
    if (
        derivative_revision == parent_revision
        or re.sub(f'{vendor_suffix}.*', '', derivative_revision) == parent_revision
        or re.sub(f'-?\\d{vendor_suffix}.*', '', derivative_revision) == parent_revision
        or re.sub(f'[~+]{vendor_suffix}.*', '', derivative_revision) == parent_revision
        or re.sub(f'\\d.\\d~{vendor_suffix}.*', '', derivative_revision) == parent_revision
    ):
        return Status.uptodate  # green
    elif apt_pkg.version_compare(derivative_revision, parent_revision) > 0:
        return Status.greater_revision  # purple
    else:
        return Status.lesser_revision  # yellow


class Comparator:

    def __init__(self, cfg: Config):
        assert cfg
        self.cfg = cfg
        self.source_pkgs = []
        self.binary_pkgs = []

    def _add_source_pkg(
        self, dist: str, source_name: str, source_ver: str, homepage: str, vcs_browser: str, download: str
    ) -> bool:
        """Add a source package to the database."""
        self.source_pkgs.append(
            SourcePkg(
                dist=dist,
                source_name=source_name,
                source_ver=source_ver,
                homepage=homepage,
                vcs_browser=vcs_browser,
                download=download,
            )
        )

    def _add_binary_pkg(
        self, dist: str, name: str, arch: str, udeb: bool, version: str, source_name: str, source_ver: str
    ) -> bool:
        """Add a binary package to the database."""
        self.binary_pkgs.append(
            BinaryPkg(
                dist=dist,
                name=name,
                arch=arch,
                udeb=udeb,
                version=version,
                source_name=source_name,
                source_ver=source_ver,
            )
        )

    def _reduce_source_info(self):
        """
        Reduce the binary packages into a list of sources package to be compared.
        """
        # Identity derivative
        derivatives = {pair['dist'] for pair in self.cfg.dist_pairs()}

        # Only need to analyse source_pkg from derivative.
        source_names = {binary_pkg.source_name for binary_pkg in self.binary_pkgs if binary_pkg.dist in derivatives}

        # Gather list of all sources packages to analyse
        binary_pkg_group_by_source_name = _group_by(
            (binary_pkg for binary_pkg in self.binary_pkgs if binary_pkg.source_name in source_names),
            lambda pkg: pkg.source_name,
        )

        # Process each source package.
        source_infos = {}
        for source_name, binary_pkgs in sorted(binary_pkg_group_by_source_name.items()):
            # Run the comparaison using binary pkgs.
            source_infos[source_name] = SourceInfo(source_name=source_name, dist=self._reduce_dist(binary_pkgs))

        # Complete all information using source packages: homepage, vcs_browser, tracker_url, bugs_url
        self._update_urls(source_infos)

        # Fill-in the package set data
        for source_name, package_set in self.cfg.get_package_sets():
            # Create an empty entry if it doesn't exists
            if source_name not in source_infos:
                source_infos[source_name] = SourceInfo(source_name=source_name)
            # emplote package-set information if missing
            if source_infos[source_name].package_set is None:
                source_infos[source_name].package_set = package_set
            else:
                # If it already exists, the same source name exists multiple time in the package set.
                # Duplicate the entries
                orig = source_infos[source_name]
                source_infos[f"{package_set.name}.{package_set.depth}.{package_set.order}"] = SourceInfo(
                    source_name=source_name,
                    homepage=orig.homepage,
                    vcs_browser=orig.vcs_browser,
                    package_set=package_set,
                    dist=orig.dist,
                    duplicate=True,
                )
        return list(source_infos.values())

    def _reduce_dist(self, binary_pkgs):
        """
        Reduce binary packages comparaison by derivative distro.
        """
        binary_pkgs_group_by_dist = _group_by(binary_pkgs, lambda x: x.dist)
        return {
            pair['dist']: self._reduce_dist_info(
                dist_pkgs=binary_pkgs_group_by_dist.get(pair['dist'], []),
                parent_pkgs=binary_pkgs_group_by_dist.get(pair['parent'], []),
                vendor_suffix=pair['vendor_suffix'],
            )
            for pair in self.cfg.dist_pairs()
            if pair['dist'] in binary_pkgs_group_by_dist
        }

    def _reduce_dist_info(self, dist_pkgs, parent_pkgs, vendor_suffix):
        """
        Return comparaison for a single derivative distro.
        """
        # Identify the higest version in both distribution
        dist_source_ver = highest_version(pkg.source_ver for pkg in dist_pkgs)
        parent_source_ver = highest_version(pkg.source_ver for pkg in parent_pkgs)
        # Then compare
        compare = compare_version(dist_source_ver, parent_source_ver, vendor_suffix=vendor_suffix, source=True)
        arch_dict = {}
        if compare in [
            Status.uptodate,
            Status.lesser_revision,
            Status.lesser_version,
            Status.greater_version,
            Status.greater_revision,
        ]:
            arch_dict = self._reduce_arch_dict(dist_source_ver, parent_source_ver, dist_pkgs, parent_pkgs)
        return DistInfo(
            source_ver=dist_source_ver,
            source_ver_parent=parent_source_ver,
            compare=compare,
            arch=arch_dict,
        )

    def _reduce_arch_dict(self, dist_source_ver, parent_source_ver, dist_pkgs, parent_pkgs):
        """
        Compare packages between a Debian derivative and its parent distribution,
        grouped by architecture.

        For each architecture (including udeb / debian installer), identifies
        packages that are either missing from the derivative or have a version
        discrepancy compared to the parent distribution.
        """
        # Group packages by architectures.
        dist_pkg_group_by_arch = _group_by(dist_pkgs, lambda pkg: (pkg.name, pkg.arch, pkg.udeb))
        parent_pkg_group_by_arch = _group_by(parent_pkgs, lambda pkg: (pkg.name, pkg.arch, pkg.udeb))

        # Identify list of architectures for recent packages.
        archs = {(pkg.arch, pkg.udeb) for pkg in dist_pkgs if pkg.source_ver == dist_source_ver}
        archs.update({(pkg.arch, pkg.udeb) for pkg in parent_pkgs if pkg.source_ver == parent_source_ver})

        # Identify the highest version of every packages.
        all_packages = {pkg.name for pkg in dist_pkgs}

        # Let run a comparaison for each architecture and identify the packages with issues.
        arch_dict = {}
        for arch, udeb in archs:
            packages = []
            for name in all_packages:
                dist_pkg = dist_pkg_group_by_arch.get((name, arch, udeb))
                parent_pkg = parent_pkg_group_by_arch.get((name, arch, udeb))
                if not dist_pkg and parent_pkg:
                    # If package doesn't exists in derivative, but exists in parent. It's an issue.
                    packages.append(
                        PkgInfo(
                            name=name, version=None, version_parent=parent_pkg[0].version, compare=Status.not_provided
                        )
                    )
                elif dist_pkg:
                    # If derivarive package exists, make usre it's version matches
                    # Expected version in any other architecture.
                    expected_version = highest_version(pkg.version for pkg in dist_pkgs if pkg.name == name)
                    compare = compare_version(
                        derivative_version=dist_pkg[0].version,
                        parent_version=expected_version,
                        vendor_suffix='',
                    )
                    if compare != Status.uptodate:
                        packages.append(
                            PkgInfo(
                                name=name,
                                version=dist_pkg[0].version,
                                version_parent=parent_pkg[0].version if parent_pkg else None,
                                compare=compare,
                            )
                        )
            if packages:
                compare = max([pkg_info.compare for pkg_info in packages])
                arch_key = 'udeb-' + arch if udeb else arch
                arch_dict[arch_key] = ArchInfo(compare=compare, packages=packages)

        # Run the comparaison for ech arch
        return arch_dict

    def _update_urls(self, source_infos):
        """
        Using package metadata, update URLs of all source info.
        """
        # Identitfy parent distribution
        parents = {pair['parent'] for pair in self.cfg.dist_pairs()}

        # Loop on all source pkg.
        for source_pkg in self.source_pkgs:
            source_name = source_pkg.source_name
            if source_name not in source_infos:
                continue
            source_info = source_infos[source_name]
            dist = source_pkg.dist
            # Update Homepage
            if not source_info.homepage:
                source_info.homepage = source_pkg.homepage
            # Update Vcs-Browser
            if not source_info.vcs_browser:
                source_info.vcs_browser = source_pkg.vcs_browser
            # Update Vcs-Browser of derivatives
            if dist in source_info.dist:
                source_info.dist[dist].vcs_browser = source_pkg.vcs_browser
            # Define XSBC-Download
            download = source_pkg.download
            if download:
                source_info.download = download
            # Update tracker url (only for parent)
            if dist in parents:
                tracker_url = self.cfg.get_tracker_url(dist)
                if tracker_url:
                    source_info.tracker_url = _format_url(tracker_url, source_name)
                # Update bug url
                bugs_url = self.cfg.get_bugs_url(dist)
                if bugs_url:
                    source_info.bugs_url = _format_url(bugs_url, source_name)

    def compare(self):
        """
        Build index of all packages for comparaison.
        """
        # Create package index for each distro
        for dist in self.cfg.all_dist():
            self.create_pkg_index(dist)

        # Reduce list of source packages
        return self._reduce_source_info()

    def create_pkg_index(self, dist):
        """
        Use apt to fetch the package list from a specific source.

        This implementation uses apt_pkg low-level API only for better control
        of the cache since we are using alternate root and for performance reason.
        """
        # Create chroot for apt.
        cache_path = self.cfg.get_cache_dir() / dist
        cache_path.mkdir(parents=True, exist_ok=True)

        # Reset and Initialize APT within a chroot
        apt_pkg.config.clear("")
        for key in apt_pkg.config.list():
            apt_pkg.config.clear(key)
        os.unsetenv("APT_CONFIG")

        # Configure APT with different root
        apt_pkg.config["Dir"] = str(cache_path)
        apt_pkg.init_config()
        apt_pkg.config["APT::Get::AllowUnauthenticated"] = "true"
        apt_pkg.config["Acquire::AllowInsecureRepositories"] = "true"
        apt_pkg.config["Acquire::PDiffs"] = "false"
        apt_pkg.config["Acquire::by-hash"] = "false"
        apt_pkg.config["Acquire::Languages"] = "none"

        # Define supported arch
        if self.cfg.get_archs():
            apt_pkg.config.set('APT::Architectures', ','.join(self.cfg.get_archs()))

        # Create required directories
        log_dir = apt_pkg.config.find_file("Dir::Log")
        etc_dir = apt_pkg.config.find_file("Dir::Etc")
        state_dir = apt_pkg.config.find_file("Dir::State")
        list_dir = os.path.join(apt_pkg.config.find_file("Dir::State::lists"), 'partial')
        archives_dir = os.path.join(apt_pkg.config.find_file("Dir::Cache::archives"), 'partial')
        sourcesparts = apt_pkg.config.find_file("Dir::Etc::sourceparts")
        shutil.rmtree(sourcesparts, ignore_errors=True)
        preferencesparts = apt_pkg.config.find_file("Dir::Etc::preferencesparts")
        shutil.rmtree(preferencesparts, ignore_errors=True)
        dkpg_dir = cache_path / "var/lib/dpkg"
        for dir in [log_dir, etc_dir, state_dir, list_dir, archives_dir, dkpg_dir, sourcesparts, preferencesparts]:
            os.makedirs(dir, exist_ok=1)

        # Also touch required files.
        (Path(dkpg_dir) / "status").write_text("")

        # Write a custom source.list file
        # Handle list of source and append `allow-insecure=true` to avoid GPG signature verification.
        sources = self.cfg.get_sources(dist)
        if 'URIs:' in sources:
            with open(os.path.join(sourcesparts, 'temp.sources'), "w") as f:
                f.write(sources)
        else:
            with open(os.path.join(sourcesparts, 'temp.list'), "w") as f:
                f.write(sources)

        # Write custom apt preferences
        with open(os.path.join(preferencesparts, 'temp'), "w") as f:
            preferences = self.cfg.get_preferences(dist)
            f.write(f"Package: *\nPin: release a=*\nPin-Priority: 500\n\n{preferences}")

        # Load sources config
        source_list = apt_pkg.SourceList()
        if not source_list.read_main_list():
            raise ValueError('invalid source')

        # Run apt update
        apt_pkg.init_system()
        progress = AcquireProgress(sys.stdout)
        cache = apt_pkg.Cache()
        cache.update(progress, source_list)

        # Reopen cache
        cache = apt_pkg.Cache()
        records = apt_pkg.PackageRecords(cache)
        policy = apt_pkg.Policy(cache)
        policy.read_pindir(preferencesparts)

        # Loop on all source package to keep some metadata: homepage, vcs_browser
        try:
            source_records = apt_pkg.SourceRecords()
            source_records.restart()
            while source_records.step():
                tag_section = apt_pkg.TagSection(source_records.record)
                self._add_source_pkg(
                    dist=dist,
                    source_name=source_records.package,
                    source_ver=source_records.version,
                    homepage=tag_section.get('Homepage'),
                    vcs_browser=tag_section.get('Vcs-Browser'),
                    download=tag_section.get('Download'),
                )
        except apt_pkg.Error as e:
            print(e)

        # Loop on all binary package
        packages = {}
        for pkg in cache.packages:
            # skip virtual package
            if not pkg.has_versions:
                continue
            candidate = policy.get_candidate_ver(pkg)
            # Base on apt preferences / policy. Some packages may be excluded.
            if candidate is None:
                continue
            records.lookup(candidate.file_list[0])
            source_pkg = records.source_pkg or pkg.name
            source_ver = records.source_ver or candidate.ver_str
            udeb = records.filename.endswith('.udeb')
            self._add_binary_pkg(
                dist=dist,
                name=pkg.name,
                arch=candidate.arch,
                udeb=udeb,
                version=candidate.ver_str,
                source_name=source_pkg,
                source_ver=source_ver,
            )
        return packages

    def render_json(self, data, json_output):
        def _default(obj):
            if hasattr(obj, '_asdict'):  # namedtuple
                return obj.asdict()
            if is_dataclass(obj):
                return asdict(obj)
            raise ValueError('cannot serialize to json: %s' % obj)

        with open(json_output, mode='w') as fp:
            json.dump(data, fp, indent=2, default=_default, sort_keys=True)

    def render_html(self, data, html_output):
        # Initialize jinja2 engine
        env = Environment(
            loader=PackageLoader('derivative_dist_compare', '.'),
            auto_reload=False,
            extensions=['jinja2.ext.do'],
            autoescape=True,
        )
        env.trim_blocks = True
        env.lstrip_blocks = True
        # Load default template.
        template = env.get_template('template.html')
        # Load custom template if any.
        custom_template = self.cfg.get_template()
        if custom_template:
            if not custom_template.is_file():
                print('Cannot find custom template %s' % custom_template)
                exit(2)
            custom_template_src = custom_template.read_text()
            template = env.from_string(custom_template_src)
        with open(html_output, mode='w') as fp:
            fp.write(
                template.render(
                    {
                        'current_date': datetime.now().astimezone(),
                        'dist_pairs': self.cfg.dist_pairs(),
                        'data': data,
                    }
                )
            )


def main(sysargs=None):
    """
    Main entry point of the script.
    """

    # Read configuration option from arguments, configuration file or environment variable.
    args = _parse_args(sysargs)

    # Configure logging system
    _setup_logging(debug=args.debug, logfile=args.log_file)

    # Check output file
    if args.json_output is None and args.html_output is None:
        logger.error('at least one of --json-output or --html-output must be define')
        exit(2)

    # Open config-file
    if not args.config_file.exists():
        logger.error('config file `%s` not found' % args.config_file)
        exit(3)

    # Start comparing
    comparator = Comparator(Config(args.config_file))
    data = comparator.compare()

    # Json output
    if args.json_output:
        comparator.render_json(data, args.json_output)

    # HTML output
    if args.html_output:
        comparator.render_html(data, args.html_output)


if __name__ == "__main__":
    main()
