#!/usr/bin/env python3
"""Convert files between HESTIA format and other formats.

This script knows nothing about any individual converter. Formats, loaders and
per-converter options all come from the converter registry
(``hestia_earth.converters.base.registry``), which discovers them from each
converter's own ``converter.py``. Adding a converter therefore needs no edit
here -- see the converter blueprint.
"""
import argparse
import logging
import os
import sys

from hestia_earth.converters.base.chain import find_chain, run_chain
from hestia_earth.converters.base.missing_flows import (
    DEFAULT_REPORT_FILENAME,
    FLOWMAPS_ISSUES_URL,
    recording,
)
from hestia_earth.converters.base.registry import (
    HESTIA,
    discover,
    find,
    formats,
    load_callable,
)

SPECS = discover()
INPUT_FORMATS, OUTPUT_FORMATS = formats(SPECS)

DEFAULT_MAPPING_FILES_DIRECTORY = 'hestia-flowmaps'


def _add_general_arguments(parser):
    parser.add_argument('--input-file', type=str, help='Input file')
    parser.add_argument('--output-folder', type=str, required=True,
                        help='Output files folder')
    parser.add_argument('--input-format', type=str, required=True, choices=INPUT_FORMATS,
                        help='Input file format')
    parser.add_argument('--output-format', type=str, required=True, choices=OUTPUT_FORMATS,
                        help='Output file format')
    parser.add_argument('--mapping-files-directory', type=str,
                        default=DEFAULT_MAPPING_FILES_DIRECTORY,
                        help='Folder containing the mapping files in .csv format')
    parser.add_argument('--update-flowmaps', action='store_true',
                        help='Download the flowmaps when a newer version is published, '
                             'instead of only warning about it.')
    parser.add_argument('--skip-existing', action='store_true',
                        help='Do not overwrite existing converted file.')
    parser.add_argument('--missing-flowmaps-file', type=str,
                        default=DEFAULT_REPORT_FILENAME,
                        help='Where to record the flows this run could not map.')
    parser.add_argument('--verbose', action='store_true', help='Enables verbose mode.')
    parser.add_argument('--debug-file', action='store_true',
                        help='Outputs conversion logs to debug file.')
    parser.add_argument('--filter-by-name', type=str, nargs='+', default=[],
                        help='Optional list of names to filter results on. Must be in quotes. '
                             'Can be used multiple times.')
    parser.add_argument('--hestia-impact-id', type=str, nargs='+',
                        help='Run conversion from HESTIA ImpactAssessment.')


def _option_kwargs(option):
    """Translate a registry Option into argparse keyword arguments."""
    kwargs = {'help': option.help, 'default': option.default}
    if option.action:
        kwargs['action'] = option.action
        return kwargs
    kwargs['type'] = option.type
    if option.choices:
        kwargs['choices'] = list(option.choices)
    if option.nargs:
        kwargs['nargs'] = option.nargs
    return kwargs


def _add_converter_arguments(parser):
    """Give every converter its own ``--<name>-<option>`` argument group."""
    for name, spec in sorted(SPECS.items()):
        if not spec.options:
            continue
        group = parser.add_argument_group(f'{spec.label} options')
        for option in spec.options:
            group.add_argument(f'--{name}-{option.name}', **_option_kwargs(option))


def _build_parser():
    parser = argparse.ArgumentParser(
        'Convert files between HESTIA format and other formats.',
        epilog='Supported conversions:\n' + '\n'.join(
            f'  {conversion.source} -> {conversion.target}  ({spec.summary})'
            for spec in sorted(SPECS.values(), key=lambda s: s.name)
            for conversion in spec.conversions
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    _add_general_arguments(parser)
    _add_converter_arguments(parser)
    return parser


def _load_hestia_impacts(impact_ids):
    from hestia_earth.converters.base.loaders import load_hestia_model_from_id
    return [load_hestia_model_from_id(impact_id) for impact_id in impact_ids]


def _load_hestia_jsonld(filepath):
    from hestia_earth.converters.base.loaders import load_hestia_model_from_file
    from pathlib import Path
    path = Path(filepath)
    return [load_hestia_model_from_file(path.parent, path)]


def _load_data(args, conversion, spec):
    """Load the input, by whichever route the arguments describe.

    ``--hestia-impact-id`` and a single ``.jsonld`` are HESTIA-specific shortcuts
    the registry does not model; everything else goes through the loader the
    conversion declares.
    """
    if args.input_format == HESTIA and args.hestia_impact_id:
        return _load_hestia_impacts(args.hestia_impact_id)
    if not args.input_file:
        raise SystemExit('--input-file is required (or --hestia-impact-id for HESTIA input)')
    if args.input_format == HESTIA and args.input_file.endswith('.jsonld'):
        return _load_hestia_jsonld(args.input_file)
    loader_options = (
        {'mapping_files_directory': args.mapping_files_directory}
        if conversion.load_needs_mapping_files else {}
    )
    try:
        return load_callable(conversion.load)(args.input_file, **loader_options)
    except ModuleNotFoundError as err:
        raise SystemExit(
            f"Please install 'hestia-earth-converters[{spec.extra}]' first ({err})."
        )


def _bundles_for_installed_extras(version):
    """The flowmap bundles this install needs, or None to fetch everything.

    Someone who installed only `[FCC]` has no use for the SimaPro or openLCA
    maps. `None` covers every case where the question cannot be answered --
    no version to read the bundle list for, an unreadable list, or a source
    checkout with no extras installed -- and means the whole archive.
    """
    if not version:
        return None
    from hestia_earth.converters.utils.flowmaps import (
        default_bundle_names,
        read_bundles,
    )
    try:
        names = default_bundle_names(read_bundles(version))
    except Exception as err:
        logging.warning('Could not read the flowmap bundle list (%s), '
                        'downloading the whole archive', err)
        return None
    if names:
        logging.info('Downloading the bundles for the installed extras: %s',
                     ', '.join(names))
    return names or None


def _installed_extras():
    from hestia_earth.converters.utils.flowmaps import installed_extras
    return installed_extras()


def _download_flowmaps_if_required(folder):
    if os.path.exists(folder):
        return False
    logging.error('Flowmaps directory not found, downloading latest available')
    from hestia_earth.converters.utils.flowmaps import (
        download_flowmaps,
        latest_version_or_none,
    )
    version = latest_version_or_none()
    bundles = _bundles_for_installed_extras(version)
    download_flowmaps(folder, version, bundles,
                      _installed_extras() if bundles else None)
    return True


def _report_outdated(installed, latest):
    logging.warning(
        'Flowmaps in use are %s, but %s is published. Term mappings, and so the '
        'conversion, may differ from the current ones. Pass --update-flowmaps to '
        'download it, or keep this copy to reproduce an earlier run.',
        installed or 'of an unrecorded version', latest)


def _update_flowmaps(folder, latest):
    logging.info('Downloading flowmaps %s...', latest)
    from hestia_earth.converters.utils.flowmaps import replace_flowmaps
    bundles = _bundles_for_installed_extras(latest)
    replace_flowmaps(folder, latest, bundles,
                     _installed_extras() if bundles else None)


def _check_flowmaps_version(folder, update):
    """Warn when the flowmaps on disk are not the published version.

    Only ever advisory. The version is read in one short attempt and any
    failure means "carry on with what is on disk": a conversion must not depend
    on the CDN being reachable, and a run pinned to an older version on purpose
    is a legitimate thing to be doing.
    """
    from hestia_earth.converters.utils.flowmaps import (
        installed_version,
        latest_version_or_none,
    )

    latest = latest_version_or_none()
    installed = installed_version(folder)
    if latest is None or installed == latest:
        return

    if update:
        _update_flowmaps(folder, latest)
    else:
        _report_outdated(installed, latest)


def _add_missing_bundles(folder):
    """Fetch the bundles this install has gained since the folder was built.

    Installing a second extra does not re-run the download -- the folder is
    still there -- so without this the new converter's maps are simply absent,
    and the failure is a lookup returning nothing rather than anything that
    names the cause.
    """
    from hestia_earth.converters.utils.flowmaps import add_bundles, missing_bundles
    try:
        missing = missing_bundles(folder)
    except Exception as err:
        logging.warning('Could not check the flowmap bundles (%s)', err)
        return
    if missing:
        logging.info('Downloading newly needed flowmap bundles: %s',
                     ', '.join(missing))
        add_bundles(folder, missing, _installed_extras())


def _prepare_flowmaps(args):
    """Make sure the flowmaps are present, and say so when they are stale.

    Both checks are limited to the folder the CLI manages. A folder the user
    named holds their flowmaps -- possibly edited, possibly pinned -- and is
    not ours to report on, add to, or replace.
    """
    folder = args.mapping_files_directory
    if _download_flowmaps_if_required(folder):
        return
    if folder == DEFAULT_MAPPING_FILES_DIRECTORY:
        _check_flowmaps_version(folder, args.update_flowmaps)
        _add_missing_bundles(folder)


def _report_missing_flows(run, args):
    """Name the flows the run dropped, and where the fix goes.

    Only when there were any: a run that mapped everything says nothing, and
    leaves whatever is at that path alone.
    """
    from hestia_earth.converters.utils.flowmaps import installed_version
    path = run.write(
        args.missing_flowmaps_file,
        conversion=f'{args.input_format} -> {args.output_format}',
        flowmaps=installed_version(args.mapping_files_directory),
    )
    if path:
        logging.warning(
            'Some flows could not be mapped and were recorded in %s. Please open an '
            'issue on the flowmaps repository and attach that file: %s',
            path, FLOWMAPS_ISSUES_URL)


def _convert_callable(spec, conversion):
    try:
        return load_callable(conversion.convert)
    except ModuleNotFoundError as err:
        raise SystemExit(
            f"Please install 'hestia-earth-converters[{spec.extra}]' first ({err})."
        )


def _chained(source, target):
    """Reach `target` from `source` by way of HESTIA, when nothing does it directly."""
    (spec, first), (second_spec, second) = find_chain(SPECS, source, target)
    # up front, so a missing extra is reported before the first leg writes anything
    _convert_callable(spec, first)
    _convert_callable(second_spec, second)
    return spec, first, lambda data, **options: run_chain(first, second, data, **options)


def _resolve(args):
    """The conversion to run, and the loader-bearing pair that reads its input."""
    try:
        spec, conversion = find(SPECS, args.input_format, args.output_format)
    except LookupError:
        return _chained(args.input_format, args.output_format)
    return spec, conversion, _convert_callable(spec, conversion)


def main():
    args = _build_parser().parse_args()

    try:
        spec, conversion, convert = _resolve(args)
    except LookupError as err:
        raise SystemExit(str(err))

    _prepare_flowmaps(args)

    data = _load_data(args, conversion, spec)
    if not data:
        raise SystemExit(f'Could not load any data from {args.input_format} format.')

    with recording() as run:
        convert(data, **vars(args))
    _report_missing_flows(run, args)


if __name__ == '__main__':
    sys.exit(main())
