# Licensed under a 3-clause BSD style license - see LICENSE.rst
import datetime
import multiprocessing
import os
import shutil
from collections import defaultdict

from importlib_metadata import version as get_version

from asv import _stats, util
from asv.benchmarks import Benchmarks
from asv.commands import Command
from asv.console import log
from asv.graph import GraphSet
from asv.machine import iter_machine_files
from asv.publishing import OutputPublisher
from asv.repo import get_repo
from asv.results import iter_results


def check_benchmark_params(name, benchmark):
    """
    Check benchmark params and param_keys items, so that the javascript can
    assume this data is valid. It is checked in benchmark.py already when it
    is generated, but best to double check in any case.
    """
    if 'params' not in benchmark:
        # Old-format benchmarks.json
        benchmark['params'] = []
        benchmark['param_names'] = []

    msg = f"Information in benchmarks.json for benchmark {name} is malformed"
    if not isinstance(benchmark['params'], list) or not isinstance(benchmark['param_names'], list):
        raise ValueError(msg)
    if len(benchmark['params']) != len(benchmark['param_names']):
        raise ValueError(msg)
    for item in benchmark['params']:
        if not isinstance(item, list):
            raise ValueError(msg)


class Publish(Command):
    @classmethod
    def setup_arguments(cls, subparsers):
        parser = subparsers.add_parser(
            "publish",
            help="Collate results into a website",
            description="""
            Collate all results into a website.  This website will be
            written to the ``html_dir`` given in the ``asv.conf.json``
            file, and may be served using any static web server.""",
        )
        parser.add_argument(
            '--no-pull', action='store_true', dest='no_pull', help="Do not pull the repository"
        )
        parser.add_argument(
            'range', nargs='?', default=None, help="""Optional commit range to consider"""
        )
        parser.add_argument(
            '--html-dir',
            '-o',
            default=None,
            help=("Optional output directory. Default is 'html_dir' from asv config"),
        )

        parser.set_defaults(func=cls.run_from_args)

        return parser

    @classmethod
    def run_from_conf_args(cls, conf, args):
        if args.html_dir is not None:
            conf.html_dir = args.html_dir
        return cls.run(conf=conf, range_spec=args.range, pull=not args.no_pull)

    @staticmethod
    def iter_results(conf, repo, range_spec=None):
        if range_spec is not None:
            if isinstance(range_spec, list):
                hashes = range_spec
            else:
                hashes = repo.get_hashes_from_range(range_spec)
        else:
            hashes = None
        for result in iter_results(conf.results_dir):
            if hashes is None or result.commit_hash in hashes:
                yield result

    @classmethod
    def run(cls, conf, range_spec=None, pull=True):
        params = {}
        env_vars = defaultdict(set)
        graphs = GraphSet()
        machines = {}
        benchmark_names = set()

        log.set_nitems(6 + len(list(util.iter_subclasses(OutputPublisher))))

        if os.path.exists(conf.html_dir):
            util.long_path_rmtree(conf.html_dir)

        repo = get_repo(conf)
        benchmarks = Benchmarks.load(conf)

        def copy_ignore(src, names):
            # Copy only *.js and *.css in vendor dir
            ignore = [
                fn
                for fn in names
                if (
                    os.path.basename(src).lower() == 'vendor'
                    and not fn.lower().endswith('.js')
                    and not fn.lower().endswith('.css')
                )
            ]
            return ignore

        template_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'www')
        shutil.copytree(template_dir, conf.html_dir, ignore=copy_ignore)

        # Ensure html_dir is writable even if template_dir is on a read-only FS
        os.chmod(conf.html_dir, 0o755)
        for pre, ds, fs in os.walk(conf.html_dir):
            for x in fs:
                os.chmod(os.path.join(pre, x), 0o644)
            for x in ds:
                os.chmod(os.path.join(pre, x), 0o755)

        log.step()
        log.info("Loading machine info")
        with log.indent():
            for path in iter_machine_files(conf.results_dir):
                d = util.load_json(path)
                machines[d['machine']] = d

        log.step()
        log.info("Getting params, commits, tags and branches")
        with log.indent():
            # Determine first the set of all parameters and all commits
            hash_to_date = {}
            for results in cls.iter_results(conf, repo, range_spec):
                hash_to_date[results.commit_hash] = results.date
                for key, val in results.params.items():
                    if val is None:
                        # Backward compatibility -- null means ''
                        val = ''

                    params.setdefault(key, set())
                    params[key].add(val)

                for name, val in results.env_vars.items():
                    # Prefix them in case of name collision
                    env_vars[f"env-{name}"].add(val)

            params.update(env_vars)

            if pull:
                repo.pull()
            tags = repo.get_tags()
            revisions = repo.get_revisions(set(hash_to_date.keys()) | set(tags.values()))

            for tag, commit_hash in list(tags.items()):
                # Map to revision number instead of commit hash and add tags to hash_to_date
                tags[tag] = revisions[tags[tag]]
                hash_to_date[commit_hash] = repo.get_date_from_name(commit_hash)

            revision_to_date = {r: hash_to_date[h] for h, r in revisions.items()}

            branches = {branch: repo.get_branch_commits(branch) for branch in conf.branches}

        log.step()
        log.info("Loading results")
        with log.indent():
            # Generate all graphs
            for results in cls.iter_results(conf, repo, range_spec):
                log.dot()

                branches_for_commit = [
                    branch
                    for branch, commits in branches.items()
                    if results.commit_hash in commits
                ]

                # Print a warning message if the commit isn't from a tag
                if not len(branches_for_commit):
                    # Assume that these must be tags
                    repo_tags = repo.get_tags()
                    branches_for_commit = [
                        branch
                        for branch, commits in branches.items()
                        if results.commit_hash in repo_tags.values()
                    ]
                    if not len(branches_for_commit):
                        # Not tags, print a warning
                        msg = "Couldn't find {} in branches ({})"
                        log.warning(
                            msg.format(
                                results.commit_hash[: conf.hash_length],
                                ", ".join(str(branch) for branch in branches.keys()),
                            )
                        )

                for key in results.get_result_keys(benchmarks):
                    b = benchmarks[key]
                    b_params = b['params']

                    result = results.get_result_value(key, b_params)
                    weight = [
                        _stats.get_weight(s) for s in results.get_result_stats(key, b_params)
                    ]
                    if not b_params:
                        result = result[0]
                        weight = weight[0]

                    benchmark_names.add(key)

                    for branch in branches_for_commit:
                        cur_params = dict(results.params)
                        cur_env = {f'env-{name}': val for name, val in results.env_vars.items()}
                        cur_params.update(cur_env)
                        cur_params['branch'] = repo.get_branch_name(branch)

                        # Backward compatibility, see above
                        for param_key, param_value in list(cur_params.items()):
                            if param_value is None:
                                cur_params[param_key] = ''

                        # Fill in missing params
                        for param_key in params.keys():
                            if param_key not in cur_params:
                                cur_params[param_key] = None
                                params[param_key].add(None)

                        # Create graph
                        graph = graphs.get_graph(key, cur_params)
                        graph.add_data_point(revisions[results.commit_hash], result, weight)

            # Get the parameter sets for all graphs
            graph_param_list = []
            for path, graph in graphs:
                if 'summary' not in graph.params:
                    if graph.params not in graph_param_list:
                        graph_param_list.append(graph.params)

        log.step()
        log.info("Detecting steps")
        with log.indent():
            n_processes = multiprocessing.cpu_count()
            pool = util.get_multiprocessing_pool(n_processes)
            try:
                graphs.detect_steps(pool, dots=log.dot)
                pool.close()
                pool.join()
            finally:
                pool.terminate()

        log.step()
        log.info("Generating graphs")
        with log.indent():
            # Save files
            graphs.save(conf.html_dir, dots=log.dot)

        pages = []
        classes = sorted(util.iter_subclasses(OutputPublisher), key=lambda cls: cls.order)
        for cls in classes:
            log.step()
            log.info(f"Generating output for {cls.__name__}")
            with log.indent():
                cls.publish(conf, repo, benchmarks, graphs, revisions)
                pages.append([cls.name, cls.button_label, cls.description])

        log.step()
        log.info("Writing index")
        benchmark_map = dict(benchmarks)
        for key in benchmark_map.keys():
            check_benchmark_params(key, benchmark_map[key])
        for key, val in params.items():
            val = list(val)
            val.sort(key=lambda x: '[none]' if x is None else str(x))
            params[key] = val
        params['branch'] = [repo.get_branch_name(branch) for branch in conf.branches]
        revision_to_hash = {r: h for h, r in revisions.items()}
        util.write_json(
            os.path.join(conf.html_dir, "index.json"),
            {
                'project': conf.project,
                'project_url': conf.project_url,
                'show_commit_url': conf.show_commit_url,
                'hash_length': conf.hash_length,
                'revision_to_hash': revision_to_hash,
                'revision_to_date': revision_to_date,
                'params': params,
                'graph_param_list': graph_param_list,
                'benchmarks': benchmark_map,
                'machines': machines,
                'tags': tags,
                'pages': pages,
            },
            compact=True,
        )

        util.write_json(
            os.path.join(conf.html_dir, "info.json"),
            {
                'asv-version': get_version("asv"),
                'timestamp': util.datetime_to_js_timestamp(
                    datetime.datetime.now(datetime.timezone.utc)
                ),
            },
        )
