# SPDX-FileCopyrightText: Portions Copyright (c) 2023 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Definitions of commands and actions that can be run in the garak toolkit"""

import logging
import json
import random

HINT_CHANCE = 0.25


def hint(msg, logging=None):
    # sub-optimal, but because our logging setup is thin & uses the global
    # default, placing a top-level import can break logging - so we can't
    # assume `logging` is imported at this point.
    msg = f"⚠️  {msg}"
    if logging is not None:
        logging.info(msg)
    if random.random() < HINT_CHANCE:
        print(msg)


def deprecation_notice(deprecated_item: str, version: str, logging=None):
    msg = f"DEPRECATION: {deprecated_item} is deprecated since version {version}"
    visible_msg = f"✋ {msg}"
    if logging is not None:
        logging.info(msg)
    print(visible_msg)


def start_logging():
    from garak import _config

    log_filename = _config.transient.log_filename

    logging.info("invoked")

    return log_filename


def start_run():
    import logging
    import os
    import uuid

    from pathlib import Path
    from garak import _config

    logging.info("run started at %s", _config.transient.starttime_iso)
    # print("ASSIGN UUID", args)
    if _config.system.lite and "probes" not in _config.transient.cli_args and _config.transient.cli_args.list_probes is None and not _config.transient.cli_args.list_detectors and not _config.transient.cli_args.list_generators and not _config.transient.cli_args.list_buffs and not _config.transient.cli_args.list_config and not _config.transient.cli_args.plugin_info and not _config.run.interactive:  # type: ignore
        hint(
            "The current/default config is optimised for speed rather than thoroughness. Try e.g. --config full for a stronger test, or specify some probes.",
            logging=logging,
        )
    _config.transient.run_id = str(uuid.uuid4())  # uuid1 is safe but leaks host info
    report_path = Path(_config.reporting.report_dir)
    if not report_path.is_absolute():
        logging.debug("relative report dir provided")
        report_path = _config.transient.data_dir / _config.reporting.report_dir
    if not os.path.isdir(report_path):
        try:
            report_path.mkdir(mode=0o740, parents=True, exist_ok=True)
        except PermissionError as e:
            raise PermissionError(
                f"Can't create reporting directory {report_path}, quitting"
            ) from e

    filename = f"garak.{_config.transient.run_id}.report.jsonl"
    if not _config.reporting.report_prefix:
        filename = f"garak.{_config.transient.run_id}.report.jsonl"
    else:
        filename = _config.reporting.report_prefix + ".report.jsonl"
    _config.transient.report_filename = str(report_path / filename)
    _config.transient.reportfile = open(
        _config.transient.report_filename, "w", buffering=1, encoding="utf-8"
    )
    setup_dict = {"entry_type": "start_run setup"}
    for k, v in _config.__dict__.items():
        if k[:2] != "__" and type(v) in (
            str,
            int,
            bool,
            dict,
            tuple,
            list,
            set,
            type(None),
        ):
            setup_dict[f"_config.{k}"] = v
    for subset in "system transient run plugins reporting".split():
        for k, v in getattr(_config, subset).__dict__.items():
            if k[:2] != "__" and type(v) in (
                str,
                int,
                bool,
                dict,
                tuple,
                list,
                set,
                type(None),
            ):
                setup_dict[f"{subset}.{k}"] = v

    _config.transient.reportfile.write(
        json.dumps(setup_dict, ensure_ascii=False) + "\n"
    )
    _config.transient.reportfile.write(
        json.dumps(
            {
                "entry_type": "init",
                "garak_version": _config.version,
                "start_time": _config.transient.starttime_iso,
                "run": _config.transient.run_id,
            },
            ensure_ascii=False,
        )
        + "\n"
    )
    logging.info("reporting to %s", _config.transient.report_filename)


def end_run():
    import datetime
    import logging

    from garak import _config

    logging.info("run complete, ending")
    end_object = {
        "entry_type": "completion",
        "end_time": datetime.datetime.now().isoformat(),
        "run": _config.transient.run_id,
    }
    _config.transient.reportfile.write(
        json.dumps(end_object, ensure_ascii=False) + "\n"
    )
    _config.transient.reportfile.close()

    print(f"📜 report closed :) {_config.transient.report_filename}")
    if _config.transient.hitlogfile:
        _config.transient.hitlogfile.close()

    timetaken = (datetime.datetime.now() - _config.transient.starttime).total_seconds()

    digest_filename = _config.transient.report_filename.replace(".jsonl", ".html")
    print(f"📜 report html summary being written to {digest_filename}")
    try:
        write_report_digest(_config.transient.report_filename, digest_filename)
    except Exception as e:
        msg = "Didn't successfully build the report - JSON log preserved. " + repr(e)
        logging.exception(e)
        logging.info(msg)
        print(msg)

    msg = f"garak run complete in {timetaken:.2f}s"
    print(f"✔️  {msg}")
    logging.info(msg)


def _tier_name(tier_value):
    """Convert a tier int value to its enum name string."""
    try:
        from garak.probes._tier import Tier
        return Tier(int(tier_value)).name
    except (ValueError, TypeError):
        return ""


def _truncate(text, max_len=80):
    """Truncate text to max_len, appending ellipsis if needed."""
    if len(text) > max_len:
        return text[:max_len - 1] + "…"
    return text


# Column definitions per plugin type for verbose table output.
# Each entry is (column_name, extractor_fn(info_dict) -> str).
# "name" and "active" are always included and handled separately.
_PLUGIN_TABLE_COLUMNS = {
    "probes": [
        ("tier", lambda info: _tier_name(info.get("tier")) if info.get("tier") is not None else ""),
        ("description", lambda info: _truncate(info.get("description", ""))),
    ],
    # Future plugin types can define their own extra columns here, e.g.:
    # "detectors": [
    #     ("description", lambda info: _truncate(info.get("description", ""))),
    # ],
}


def print_plugins(prefix: str, color, selected_plugins=None, verbose: int=0):
    """
    Print plugins for a category (probes/detectors/generators/buffs).

    Args:
        prefix: Plugin category (probes/detectors/generators/buffs)
        color: Color for output formatting
        selected_plugins: Optional list of specific plugins to show. If None, shows all.
        verbose: Verbosity level. 0 = plain list, >=1 = markdown table with metadata.
    """
    from colorama import Style
    from garak._plugins import enumerate_plugins, plugin_info as get_plugin_info, PLUGIN_TYPES

    if prefix not in PLUGIN_TYPES:
        raise ValueError(f"Requested prefix '{prefix}' is not a valid plugin type")

    # enumerate with activation flags
    rows = enumerate_plugins(
        category=prefix
    )  # [("probes.dan.AntiDAN", active_bool), ...]
    if selected_plugins is not None:
        if len(selected_plugins) > 0 and prefix in selected_plugins[0]:
            rows = zip(selected_plugins, [True] * len(selected_plugins))
        else:
            print(f"No {prefix} match the provided filter")
            return

    short = [(p.replace(f"{prefix}.", ""), a, p) for p, a, *_ in [(pn, ac, pn) for pn, ac in rows]]
    if selected_plugins is None:
        module_names = {(m.split(".")[0], True, None) for m, a, _ in short}
        short += module_names

    sorted_items = sorted(short, key=lambda x: x[0])

    if verbose >= 1 and prefix in _PLUGIN_TABLE_COLUMNS:
        _print_plugins_table(sorted_items, prefix)
    else:
        # plain text output (default)
        for item in sorted_items:
            plugin_name, active = item[0], item[1]
            print(f"{Style.BRIGHT}{color}{prefix}: {Style.RESET_ALL}", end="")
            print(plugin_name, end="")
            if "." not in plugin_name:
                print(" 🌟", end="")
            if not active:
                print(" 💤", end="")
            print()


def _print_plugins_table(sorted_items, prefix):
    """Render plugins as a markdown table with name, active, and type-specific columns."""
    from py_markdown_table.markdown_table import markdown_table
    from garak._plugins import plugin_info as get_plugin_info

    extra_columns = _PLUGIN_TABLE_COLUMNS.get(prefix, [])

    table_data = []
    for item in sorted_items:
        plugin_name, active = item[0], item[1]
        full_name = item[2] if len(item) > 2 else None

        is_module_header = "." not in plugin_name

        row = {"name": plugin_name}

        if is_module_header:
            row["active"] = "🌟"
            for col_name, _ in extra_columns:
                row[col_name] = ""
        else:
            row["active"] = "✅" if active else "💤"
            info = get_plugin_info(full_name) if full_name else {}
            for col_name, extractor in extra_columns:
                row[col_name] = extractor(info)

        table_data.append(row)

    print(f"{prefix}:")
    print(
        markdown_table(table_data)
        .set_params(row_sep="markdown", padding_width=1, padding_weight="centerleft", quote=False)
        .get_markdown()
    )


def print_probes(selected_probes=None, verbose=0):
    """Print available probes.

    Args:
        selected_probes: Optional list of specific probes to show.
        verbose: Verbosity level. 0 = plain list, >=1 = markdown table.
    """
    from colorama import Fore

    print_plugins("probes", Fore.LIGHTYELLOW_EX, selected_probes, verbose=verbose)


def print_detectors(selected_detectors=None):
    from colorama import Fore

    print_plugins("detectors", Fore.LIGHTBLUE_EX, selected_detectors)


def print_generators():
    from colorama import Fore

    print_plugins("generators", Fore.LIGHTMAGENTA_EX)


def print_buffs(selected_buffs=None):
    from colorama import Fore

    print_plugins("buffs", Fore.LIGHTGREEN_EX, selected_buffs)


# describe plugin
def plugin_info(plugin_name):
    from garak._plugins import plugin_info

    info = plugin_info(plugin_name)
    if len(info) > 0:
        print(f"Configured info on {plugin_name}:")
        priority_fields = ["description"]
        for k in priority_fields:
            if k in info:
                print(f"{k:>35}:", info[k])
        for k, v in info.items():
            if k in priority_fields:
                continue
            print(f"{k:>35}:", v)
    else:
        print(
            f"Plugin {plugin_name} not found. Try --list_probes, or --list_detectors."
        )


# TODO set config vars - debug, threshold
# TODO load generator
# TODO set probe config string


def _selection_has_intent_probe(probe_names) -> bool:
    """True if any selected probe (``probes.<module>.<Class>``) is an IntentProbe
    subclass. Resolves the class without instantiating; short-circuits on the
    first match."""
    import importlib
    from garak.probes.base import IntentProbe

    for name in probe_names:
        module_path, _, klass_name = name.rpartition(".")
        if not module_path.startswith("probes."):
            continue
        try:
            module = importlib.import_module(f"garak.{module_path}")
            klass = getattr(module, klass_name)
        except (ImportError, AttributeError):
            continue
        if isinstance(klass, type) and issubclass(klass, IntentProbe):
            return True
    return False


def warn_unconsumed_intents(probe_names) -> None:
    """Warn once when ``intent:`` was given explicitly but no IntentProbe is in the
    selection to consume it. The intent axis does not select probes, so without an
    IntentProbe the intents are never exercised."""
    from garak import _config

    if not getattr(_config.transient, "intents_explicit", False):
        return
    if _selection_has_intent_probe(probe_names):
        return
    msg = (
        "intent: selector(s) given but no IntentProbe is selected; intents will "
        "not be exercised (select an IntentProbe, e.g. probes.grandma.GrandmaIntent)"
    )
    logging.warning(msg)
    print(f"⚠️  {msg}")


# do a run
def probewise_run(generator, probe_names, evaluator, buffs):
    import garak.harnesses.probewise

    probewise_h = garak.harnesses.probewise.ProbewiseHarness()
    probewise_h.run(generator, probe_names, evaluator, buffs)


def pxd_run(generator, probe_names, detector_names, evaluator, buffs):
    import garak.harnesses.pxd

    pxd_h = garak.harnesses.pxd.PxD()
    pxd_h.run(
        generator,
        probe_names,
        detector_names,
        evaluator,
        buffs,
    )


def _enumerate_obj_values(o):
    for i in dir(o):
        if i[:2] != "__" and not callable(getattr(o, i)):
            print(f"    {i}: {getattr(o, i)}")


def list_config():
    from garak import _config

    print("_config:")
    _enumerate_obj_values(_config)

    for section in "system transient run plugins reporting".split():
        print(f"{section}:")
        _enumerate_obj_values(getattr(_config, section))


def write_report_digest(report_filename, html_report_filename):
    from garak.analyze import report_digest

    digest = report_digest.build_digest(report_filename)
    with open(report_filename, "a+", encoding="utf-8") as report_file:
        report_digest.append_report_object(report_file, digest)
    html_report = report_digest.build_html(digest)
    with open(html_report_filename, "w", encoding="utf-8") as htmlfile:
        htmlfile.write(html_report)
