'''
Provides class RivieraSimCoverageAddOn, intended to be a base class to ToolRiviera
to supply additional methods to assist in coverage
'''

# pylint: disable=too-many-lines

import glob
import json
import os
import pprint
import re
import shutil
import tempfile
import time

from opencos import util, files
from opencos.util import Colors
from opencos.utils.coverage_helpers import CoverageAddOn
from opencos.utils import slang_helpers
from opencos.utils.slang_helpers import check_slang_available
from opencos.utils.status_constants import EDA_COVERAGE_REQUIREMENT_NOT_MET
from opencos.utils.str_helpers import match_glob_patterns


def get_acdb_output_file_from_tcl_line(
        tcl_line: str, work_dir: str, add_to_artifacts: bool = False) -> dict:
    '''Tries to fish out the -o FILE from a line of: acdb report ... -o FILE

    Returns a dict: empty, or:
      { name: filename (str),
        typ: Artifacts type (str),
        description: str
      }
    Which is suitable to supply to Artifacts.add(**kwargs)
    '''
    if not tcl_line.strip().startswith('acdb'):
        return {}


    words = tcl_line.split()
    if words[0] == 'acdb' and words[1] == 'report' and '-o' in words:
        idx = words.index('-o')
        if idx + 1 < len(words):
            fname = words[idx + 1]
            ret = {'name': os.path.join(work_dir, fname)}
            # str versions of util.ArtifactTypes:
            if fname.endswith('.txt'):
                ret['typ'] = 'text'
                ret['description'] = 'Riviera ACDB Output Text File'
            elif fname.endswith('.html'):
                ret['typ'] = 'html'
                ret['description'] = 'Riviera ACDB Output HTML File'
            elif fname.endswith('.acdb'):
                ret['typ'] = 'coverage_database'
                ret['description'] = 'Riviera ACDB Output Coverage Database'
            else:
                ret['typ'] = 'unknown'
                ret['description'] = ''
            if add_to_artifacts:
                util.artifacts.add(**ret)
            util.info(f'get_acdb_output_file_from_tcl_line: adding artifact: {ret["name"]}')
            return ret

    return {}


class RivieraSimCoverageAddOn(CoverageAddOn):
    '''
    Add-on class for ToolRiviera, adds methods and other features
    for "eda sim --tool=riviera --coverage
    '''

    REQUIRED_HANDLER_METHODS: list = [
        'exec', 'error', 'run_argparser_on_list'
    ]

    handler_obj: object = None # set on constructor
    slang_ast: object = None   # set in add_coverage_args_to_handler

    # see: get_extra_covergroup_coverage_commands(), this is disabled, along with
    # --coverage-covergroups-* additional args.
    _ENABLE_SLANG_FOR_EXTRA_COVERGROUP_COMMNADS: bool = False

    coverage_args: dict = {
        'coverage-run-slang': False, # Force run th slang AST
        'coverage-toggle': False, # Requires slang AST
        'coverage-toggle-inputs': True,
        'coverage-toggle-outputs': True,
        'coverage-toggle-module': '',
        'coverage-toggle-hierpath': '',
        'coverage-toggle-internal': False,
        'coverage-toggle-depth': 1,
        'coverage-toggle-exclude': '*clk*,*clock*,*rst*,*reset*',
        'coverage-toggle-include': '',
        'coverage-covergroups': False,
        # These currently not used: see get_extra_covergroup_coverage_commands()
        #'coverage-covergroups-module': '',
        #'coverage-covergruops-hierpath': '',
        #'coverage-covergroups-include': '',
        #'coverage-covergroups-exclude': '',
        #'coverage-covergroups-depth': 10,
        'coverage-summary': True,
        'coverage-json': '',
        'coverage-html': '',
        'coverage-threshold-statement': '',
        'coverage-threshold-branch': '',
        'coverage-threshold-toggle': '',
        'coverage-threshold-covergroup': '',
        'coverage-threshold-fail': False,
        'coverage-baseline': '',
        'coverage-baseline-auto': False,
        'coverage-baseline-dir': '.coverage_baselines',
        'coverage-show-delta': False,
        'coverage-delta-json': '',
        'coverage-delta-html': '',
        'coverage-save-baseline': False,
        'coverage-delta-threshold-improve': '',
        'coverage-delta-threshold-regress': '',
    }

    coverage_args_help: dict = {
        'coverage-run-slang': (
            'Requires --coverage, run the slang AST despite other --coverage- args'
        ),
        'coverage-toggle': (
            'Enable toggle coverage for top-level module ports. Uses slang to extract'
            ' port information and generates TCL toggle commands. Requires --coverage.'
            ' Requires external tool slang to be installed.'
        ),
        'coverage-toggle-inputs': (
            'Include input ports in toggle coverage (default True). Use'
            ' --no-coverage-toggle-inputs to disable.'
        ),
        'coverage-toggle-outputs': (
            'Include output ports in toggle coverage (default True). Use'
            ' --no-coverage-toggle-outputs to disable.'
        ),
        'coverage-toggle-module': (
            'Module name to extract ports from for toggle coverage. Defaults to top module.'
            ' Use this when your top is a testbench with no ports, to specify the DUT module'
            ' name.'
        ),
        'coverage-toggle-hierpath': (
            'Hierarchical instance path prefix for toggle coverage signals.'
            ' E.g., --coverage-toggle-hierpath=testbench/u_dut will generate paths like'
            ' testbench/u_dut/port_name. If not specified, auto-discovers instance path.'
        ),
        'coverage-toggle-internal': (
            'Enable toggle coverage for internal signals (nets/variables), not just ports.'
            ' Requires --coverage-toggle. Use with --coverage-toggle-depth to control'
            ' hierarchy depth, and --coverage-toggle-exclude/include for filtering.'
        ),
        'coverage-toggle-depth': (
            'Hierarchy depth for internal signal coverage. Depth 0 means only'
            ' signals at the instance level, depth 1 includes one level of sub-instances, etc.'
            ' Only applies when --coverage-toggle-internal is enabled.'
        ),
        'coverage-toggle-exclude': (
            'Comma-separated glob patterns to exclude from toggle coverage (see default).'
            ' Example: --coverage-toggle-exclude=*clk*,*debug* will exclude clock and debug'
            ' signals. Patterns are case-insensitive. To disable, use'
            ' --coverage-toggle-exclude=_ OR --coverage-toggle-exclude=""'
        ),
        'coverage-toggle-include': (
            'Comma-separated glob patterns to include in toggle coverage (optional, default is'
            ' all signals). If specified, ONLY signals matching these patterns will be'
            ' included.'
            ' Example: --coverage-toggle-include="data*,valid*,ready*" will only cover'
            ' data/valid/ready signals.'
            ' Patterns are case-insensitive. Exclusions take precedence over inclusions.'
        ),
        'coverage-covergroups': (
            'Enable coverage for SystemVerilog covergroups.'
            ' (Currently disabled: Uses slang to extract'
            ' covergroup information. Requires external tool slang to be installed)'
        ),
        # These currently not used: see get_extra_covergroup_coverage_commands()
        #'coverage-covergroups-module': (
        #    '(Currently disabled) Module to search for covergroups (default: search all from top).'
        #),
        #'coverage-covergroups-hierpath': (
        #    '(Currently disabled) Hierarchical instance path prefix for covergroups.'
        #    ' E.g., --coverage-covergroups-hierpath=testbench/u_dut will generate paths like'
        #    ' testbench/u_dut/some_covergroup_inst. If not specified, auto-discovers instance'
        #    ' path.'
        #),
        #'coverage-covergroups-include': (
        #    '(Currently disabled) Comma-separated glob patterns for covergroup names to include.'
        #),
        #'coverage-covergroups-exclude': (
        #    '(Currently disabled) Comma-separated glob patterns for covergroup names to exclude.'
        #),
        #'coverage-covergroups-depth': (
        #    '(Currently disabled) Hierarchy depth for covergroup search (default: 2).'
        #),
        'coverage-summary': (
            'Auto-display formatted coverage summary after simulation'
            ' Use --no-coverage-summary to disable.'
        ),
        'coverage-json': (
            'Path to export coverage metrics in JSON format.'
            ' Example: --coverage-json=coverage.json'
        ),
        'coverage-html': (
            'Directory path for HTML coverage dashboard.'
            ' Uses Riviera acdb report to generate browsable HTML reports.'
            ' Example: --coverage-html=cov_html'
        ),
        'coverage-threshold-statement': (
            'Minimum statement coverage percentage required (0-100).'
            ' Used with --coverage-threshold-fail to fail build if not met.'
        ),
        'coverage-threshold-branch': (
            'Minimum branch coverage percentage required (0-100). '
            'Used with --coverage-threshold-fail to fail build if not met.'
        ),
        'coverage-threshold-toggle': (
            'Minimum toggle coverage percentage required (0-100). '
            'Used with --coverage-threshold-fail to fail build if not met.'
        ),
        'coverage-threshold-covergroup': (
            'Minimum covergroup coverage percentage required (0-100). '
            'Used with --coverage-threshold-fail to fail build if not met.'
        ),
        'coverage-threshold-fail': (
            'Exit with error if coverage thresholds are not met. '
            'Use with --coverage-threshold-* arguments.'
        ),
        'coverage-baseline': (
            'Path to baseline ACDB file for incremental coverage comparison. '
            'Compares current coverage against baseline and shows deltas. '
            'Example: --coverage-baseline=baseline.acdb'
        ),
        'coverage-baseline-auto': (
            'Automatically use the previous run as baseline. '
            'Searches for the most recent ACDB in --coverage-baseline-dir. '
            'Use with --coverage-save-baseline to build coverage history.'
        ),
        'coverage-baseline-dir': (
            'Directory for storing auto-saved baselines (default: .coverage_baselines). '
            'Used with --coverage-baseline-auto and --coverage-save-baseline.'
        ),
        'coverage-show-delta': (
            'Display coverage deltas in terminal output. '
            'Shows changes from baseline with +/- indicators. '
            'Requires --coverage-baseline or --coverage-baseline-auto.'
        ),
        'coverage-delta-json': (
            'Export coverage delta report as JSON. '
            'Includes baseline, current, and delta metrics. '
            'Example: --coverage-delta-json=delta.json'
        ),
        'coverage-delta-html': (
            'Generate HTML dashboard showing coverage deltas (future feature). '
            'Visual comparison with charts and tables.'
        ),
        'coverage-save-baseline': (
            'Save current coverage run as new baseline for future comparisons. '
            'Copies work.acdb to baseline directory with timestamp. '
            'Use with --coverage-baseline-auto for continuous tracking.'
        ),
        'coverage-delta-threshold-improve': (
            'Minimum percentage point improvement required (e.g., "2.0" means +2%). '
            'Used with --coverage-threshold-fail to enforce coverage growth. '
            'Example: --coverage-delta-threshold-improve=2.0'
        ),
        'coverage-delta-threshold-regress': (
            'Maximum percentage point regression allowed (e.g., "-1.0" means -1%). '
            'Negative values. Used with --coverage-threshold-fail to prevent coverage loss. '
            'Example: --coverage-delta-threshold-regress=-1.0'
        ),
    }

    coverage_args_kwargs: dict = {}
    have_added_coverage_args_to_handler: bool = False


    def add_coverage_args_to_handler(self):
        '''This is not called in __init__(...), so instead the handler_obj

        can decide if it wants to add these args. In the case of CommandSimRiviera, these
        args are conditionally added in override run_argparser_on_list(..), if --coverage
        is set

        We only want to do this once, otherwise DEPS applied args will reset back to defaults.
        '''

        if self.have_added_coverage_args_to_handler:
            return

        # update self.handler_obj.args, args_help, args_kwargs, etc:
        self.handler_obj.args.update(RivieraSimCoverageAddOn.coverage_args)
        self.handler_obj.args_help.update(RivieraSimCoverageAddOn.coverage_args_help)
        self.handler_obj.args_kwargs.update(RivieraSimCoverageAddOn.coverage_args_kwargs)

        self.have_added_coverage_args_to_handler = True

        # Create the ref for SlangAst, won't use it (yet)
        self.slang_ast = slang_helpers.SlangAst(
            # have to set uvm_home later, can use:
            # self.handler_obj.uvm_home_dirs[self.args['uvm-version']]
            handler_obj=self.handler_obj,
            filepath=None, hier_sep='/', uvm_home=''
        )

    def any_add_on_coverage_ast_args_set(self) -> bool:
        '''Checks if any of the important bool args in RivieraSimCoverageAddOn.coverage_arg are set

        This is a bit customized, checks a few coverage- args to decide if we need to run slang
        separately to get an AST
        '''

        if not self.handler_obj.args.get('coverage', False):
            return False

        # Any of these args set implies that --coverage was also set.
        vip_arg_list = [
            'coverage-run-slang',
            'coverage-toggle',
        ]
        assert all(x in self.handler_obj.args for x in vip_arg_list)
        util.debug('any_add_on_coverage_args_set():')
        for x in vip_arg_list:
            util.debug(f'    --{x}={self.handler_obj.args.get(x, None)}')
        return any(self.handler_obj.args.get(x, None) for x in vip_arg_list)




    def run_slang_for_ast_auto(self) -> None:
        '''Wrapper for: self.set_slang_ast_uvm_home, SlangAst.run_slang_for_ast_auto()'''
        # setup things for UVM:
        self.set_slang_ast_uvm_home()
        # run from our SlangAst obj to get AST json, and read-in data.
        self.slang_ast.run_slang_for_ast_auto()


    def set_slang_ast_uvm_home(self) -> None:
        '''Lets owner of this class handle set the requirement that slang_helpers.SlangAst

        needs both uvm, and a uvm_home source directory. For Riviera, self.slang_ast
        doesn't know anything about CommandSimRiviera or ToolRiviera, but we do, so we can
        populate based on self.handler_obj:
          -- args['uvm']
          -- uvm_versions, and uvm_home_dirs (ToolRiviera)
        '''
        # args: get if --uvm set
        if not self.handler_obj.args.get('uvm', False):
            return

        # args: get --uvm-version=VALUE (or default)
        uvm_ver = self.handler_obj.args.get('uvm-version', None)
        if not uvm_ver:
            return

        # ToolRiviera - get uvm_home dir based on version:
        uvm_home = getattr(self.handler_obj, 'uvm_home_dirs', {}).get(uvm_ver, None)
        if not uvm_home:
            return

        self.slang_ast.set_uvm_and_uvm_home(needs_uvm=True, uvm_home=uvm_home)


    def sim_post_simulate_coverage( # pylint: disable=too-many-branches
            self
    ) -> None:
        '''Called by CommandSimRiviera.simulate() if running --coverage'''

        # shortname(s) for CommandSimRiviera.args:
        args = self.handler_obj.args

        cov_txt = os.path.join(args['work-dir'], 'cov.txt')
        acdb = os.path.join(args['work-dir'], 'work.acdb')

        metrics = self.parse_coverage_metrics(cov_txt)
        if metrics and args.get('coverage-summary', True):
            self.display_coverage_summary(metrics)
        baseline_path = ''
        if args.get('coverage-baseline', ''):
            baseline_path = os.path.abspath(args['coverage-baseline'])
        elif args.get('coverage-baseline-auto', False):
            baseline_path = self.find_auto_baseline()

        if baseline_path:
            if os.path.exists(baseline_path):
                util.info(f"Comparing against baseline: {baseline_path}")
                temp_dir, baseline_report = self.generate_baseline_report(baseline_path)
                if baseline_report and os.path.exists(baseline_report):
                    baseline_metrics = self.parse_coverage_metrics(baseline_report)
                    if baseline_metrics:
                        delta_metrics = self.calculate_coverage_delta(baseline_metrics, metrics)
                        if args.get('coverage-show-delta', False):
                            self.display_coverage_delta(delta_metrics)
                        if delta_metrics and not self.check_delta_thresholds(delta_metrics):
                            self.handler_obj.error(
                                "Coverage delta thresholds not met",
                                error_code=EDA_COVERAGE_REQUIREMENT_NOT_MET
                            )
                        if args.get('coverage-delta-json', ''):
                            self.generate_delta_json(
                                delta_metrics, baseline_path, args['coverage-delta-json']
                            )
                temp_dir.cleanup()
            else:
                util.warning(f"Baseline ACDB not found: {baseline_path}")

        if metrics and not self.check_coverage_thresholds(metrics):
            self.handler_obj.error(
                "Coverage thresholds not met", error_code=EDA_COVERAGE_REQUIREMENT_NOT_MET
            )

        if args['coverage-json']:
            self.generate_coverage_json(cov_txt, args['coverage-json'])

        if args['coverage-html'] and os.path.exists(acdb):
            self.generate_coverage_html(acdb, args['coverage-html'])
        elif args['coverage-html']:
            util.warning(f"--coverage-html: Coverage database not found: {acdb}")

        if args.get('coverage-save-baseline', False) and os.path.exists(acdb):
            self.save_baseline(acdb)


    # pylint: disable=too-many-statements,too-many-branches,too-many-locals
    def get_toggle_coverage_commands(
            self
    ) -> list:
        """Generate TCL toggle coverage commands for module ports and/or internal signals.

        Intent is these would go in the vsim.do (generated in riviera.CommandSimRiviera),
        if certain --coverage-[*] args are present
        """
        if not check_slang_available():
            slang_helpers.warn_slang_not_available('for --coverage-toggle')
            return []

        args = self.handler_obj.args
        target_module = args.get('coverage-toggle-module', '')
        if not target_module:
            target_module = args.get('top', '')
        if not target_module:
            util.warning(
                "--coverage-toggle: No module specified (use --coverage-toggle-module or --top)"
            )
            return []

        instance_prefix = args.get('coverage-toggle-hierpath', '').lstrip('/')
        depth = max(0, int(args['coverage-toggle-depth']))
        util.debug(f'Will use slang - ast_items for {target_module=}, {instance_prefix=}, {depth=}')


        # We'll do some filtering as we walk the AST - only if target_module is in the
        # traversed path of modules (don't find orphans, this would skip SV packages too,
        # so we'd lose internals there)
        # Also filter on only paths starting with coverage-toggle-hierpath, to a max_depth
        ast_items = self.slang_ast.get_items(
            only_top_module=target_module,
            only_paths=[instance_prefix],
            max_depth=depth
        )

        if not ast_items:
            util.warning(
                'Unable to get a Slang AST from design, skipping specialized toggle coverage'
            )
            return []

        # ast_items should be a list of dicts, with ports and signals.
        signals = []
        get_signals = args.get('coverage-toggle-internal', False)
        get_input_ports = args.get('coverage-toggle-inputs', False)
        get_output_ports = args.get('coverage-toggle-outputs', False)
        exclude_patterns =  [
            p.strip() for p in args.get('coverage-toggle-exclude', '').split(',') if p.strip()
        ]
        include_patterns = [
            p.strip() for p in args.get('coverage-toggle-include', '').split(',') if p.strip()
        ]
        for item in ast_items:
            path = item['path']
            leaf_names = []
            if get_signals:
                leaf_names += item['signals']
            for entry in item.get('ports', []):
                for portname, info in entry.items():
                    if get_input_ports and 'in' in info.get('direction', '').lower():
                        leaf_names.append(portname)
                    if get_output_ports and 'out' in info.get('direction', '').lower():
                        leaf_names.append(portname)

            # filter - note that this is done on leaf names, not the full path/leaf_name:
            for leaf_name in leaf_names:
                if (exclude_patterns and match_glob_patterns(leaf_name, exclude_patterns)) or \
                   (include_patterns and not match_glob_patterns(leaf_name, include_patterns)):
                    continue
                signals.append(f'/{path}/{leaf_name}')


        # We actually only need 1 toggle command with space separated signals.
        # TODO(drew): support other styles than just: toggle PATH1 PATH2 PATH3 ...

        # We'll split the list into max 50 signal chunks:
        def chunk_list(data_list: list, chunk_size: int) -> list:
            """Yield successive n-sized chunks from a list."""
            return [data_list[i:i + chunk_size] for i in range(0, len(data_list), chunk_size)]

        toggle_commands = []
        for some_signals in chunk_list(signals, 50):
            toggle_commands.extend(['toggle ' + ' '.join(some_signals)])

        util.debug('AST Trace for toggle coverage -- ast_items:')
        util.debug(pprint.pformat(ast_items))
        util.info('AST Trace for toggle coverage -- signals:')
        util.info(pprint.pformat(signals))
        if not signals:
            util.warning(
                'Did not create any toggle coverage for this design, for:',
                f'{include_patterns=} {exclude_patterns=} {target_module=}',
                f'{instance_prefix=} max_depth={depth}'
            )
        else:
            util.info(f'--coverage-toggle: added toggle for {len(signals)} signals')

        return toggle_commands


    def get_extra_covergroup_coverage_commands(self) -> list:
        """Generate TCL coverage commands for SystemVerilog covergroups.

        Intent is these would go in the vsim.do (generated in riviera.CommandSimRiviera),
        if certain --coverage-[*] args are present.

        It turns out this is already done by vsim if we run with -cc. That is now included
        in the eda_config_defaults.yml for tools: riviera.
        """

        # Skip, see docstring above, left code in case needed in the future
        # for covergroup/point/bin.
        if not self._ENABLE_SLANG_FOR_EXTRA_COVERGROUP_COMMNADS:
            return []

        if not check_slang_available():
            slang_helpers.warn_slang_not_available('for --coverage-covergroups-* args')
            return []

        args = self.handler_obj.args
        target_module = args.get('coverage-covergroups-module', '')
        if not target_module:
            target_module = args.get('top', '')
        if not target_module:
            util.warning(
                "--coverage-toggle: No module specified (use --coverage-covergroups-module or",
                "--top)."
            )
            return []

        include_patterns = [
            p.strip() for p in args.get('coverage-covergroups-include', '').split(',') if p.strip()
        ]
        exclude_patterns = [
            p.strip() for p in args.get('coverage-covergroups-exclude', '').split(',') if p.strip()
        ]
        instance_prefix = args.get('coverage-covergroups-hierpath', '').lstrip('/')
        depth = max(0, int(args['coverage-covergroups-depth']))
        util.debug(f'Will use slang - ast_items for covergroups: {depth=}',
                   f'{include_patterns=}, {exclude_patterns=}')

        ast_items = self.slang_ast.get_items(
            only_top_module=target_module,
            only_paths=[instance_prefix],
            max_depth=depth
        )

        if not ast_items:
            util.warning(
                'Unable to get a Slang AST from design, skipping specialized covergroups coverage'
            )
            return []

        covergroups = set()
        for item in ast_items:
            path = item['path']
            leaf_names = []
            for entry in item.get('covergroups', []):
                cg_name = entry.get('name', '')
                points = entry.get('points', []) # points[coverpoint_name] = bins (list of names)
                # add cg_name.
                # for points, add all bins.
                leaf_names.append(cg_name)
                assert isinstance(points, dict), \
                    f'Problem with Slang AST {path=} {cg_name=} {points=}'
                for point_name, bins in points.items():
                    assert isinstance(bins, list), \
                        f'Problem with Slang AST {path=} {cg_name=} {point_name=} {bins=}'
                    if bins:
                        assert isinstance(bins[0], str), \
                            f'Problem with Slang AST {path=} {cg_name=} {point_name=} {bins=}'
                    leaf_names.append(f'{cg_name}/{point_name}') # do I need full path??
                    for x in bins:
                        leaf_names.append(f'{cg_name}/{point_name}/{x}') # do I need full path?

            # filter
            for leaf_name in leaf_names:
                if (exclude_patterns and match_glob_patterns(leaf_name, exclude_patterns)) or \
                   (include_patterns and not match_glob_patterns(leaf_name, include_patterns)):
                    continue
                covergroups.add(f'/{path}/{leaf_name}')

        # If you wanted to return anythin, we could return:
        # acdb [enable|include] -bin BINNAME -cvg COVERGROUPNAME
        commands = []
        #if commands:
        #    util.info(f"--coverage-covergroups: Generated {len(commands)} commands")
        #else:
        #    util.warning("--coverage-covergroups: did not generate any commands")

        return commands


    def check_coverage_thresholds(self, metrics: dict) -> bool:
        """Check if coverage meets thresholds. Returns False if any fail."""
        thresholds = {}
        args = self.handler_obj.args
        for cov_type in ['statement', 'branch', 'toggle', 'covergroup']:
            threshold_str = args.get(f'coverage-threshold-{cov_type}', '')
            if threshold_str:
                try:
                    thresholds[cov_type] = float(threshold_str)
                except ValueError:
                    util.warning(f"Invalid threshold value for {cov_type}: {threshold_str}")

        failures = [
            f"{cov_type}: {metrics[cov_type][2]:.1f}% < {threshold:.1f}%" \
            for cov_type, threshold in thresholds.items() if all((
                    threshold > 0, cov_type in metrics, metrics[cov_type][2] < threshold
            ))
        ]

        if failures:
            failure_msg = "Coverage thresholds not met:\n" \
                + "\n".join([f"  {f}" for f in failures])
            if args.get('coverage-threshold-fail', False):
                self.handler_obj.error(failure_msg)
                return False
            util.warning(failure_msg + "\n(--coverage-threshold-fail not set, continuing)")
        return True


    def generate_coverage_html(self, acdb_path: str, html_dir: str) -> None:
        """Generate HTML coverage dashboard using Riviera's acdb report"""
        html_dir_abs = os.path.abspath(html_dir)
        os.makedirs(html_dir_abs, exist_ok=True)
        html_file = os.path.join(html_dir_abs, 'coverage.html')
        work_dir_abs = os.path.abspath(self.handler_obj.args['work-dir'])
        tcl_script = os.path.join(work_dir_abs, 'gen_html.tcl')
        acdb_path_abs = os.path.abspath(acdb_path)

        # TODO(drew): It might be nice to save out a .sh script that runs the
        # command (vsim -c -do gen_html.tcl) as an additional artifact/breadcrumb
        # for debug, or so that can slot into all.sh
        with open(tcl_script, 'w', encoding='utf-8') as f:
            f.write(f'acdb report -html -i {acdb_path_abs} -o {html_file}\nexit\n')

        _, _, rc = self.handler_obj.exec(
            work_dir_abs, [self.handler_obj.sim_exe, '-c', '-do', 'gen_html.tcl']
        )
        if rc > 0 or os.path.exists(html_file):
            util.info(f"Coverage HTML: {html_file}")
        else:
            util.warning(f"HTML generation may have failed: {html_file} not found")


    #
    # -- Coverage helpers shared by CommandSimRiviera and CommandCoverageMergeRiviera.
    #

    def parse_coverage_metrics(self, cov_txt_path: str) -> dict:
        """Parse cov.txt and return coverage metrics dict."""
        if not os.path.exists(cov_txt_path):
            return {}

        metrics = {}
        try:
            with open(cov_txt_path, 'r', encoding='utf-8') as f:
                content = f.read()

            patterns = {
                'statement': (
                    r'\|\s*Statement Coverage\s*\|[^|]*\|\s*([\d.]+)%',
                    r'\|\s*Statements\s*\|[^|]*\|\s*(\d+)\s*/\s*(\d+)'
                ),
                'branch': (
                    r'\|\s*Branch Coverage\s*\|[^|]*\|\s*([\d.]+)%',
                    r'\|\s*Branch paths\s*\|[^|]*\|\s*(\d+)\s*/\s*(\d+)'
                ),
                'toggle': (
                    r'\|\s*Toggle Coverage\s*\|[^|]*\|\s*([\d.]+)%',
                    r'\|\s*Toggle bins\s*\|[^|]*\|\s*(\d+)\s*/\s*(\d+)'
                ),
                'covergroup': (
                    r'\|\s*Covergroup Coverage\s*\|[^|]*\|\s*([\d.]+)%',
                    r'\|\s*Types\s*\|[^|]*\|\s*(\d+)\s*/\s*(\d+)'
                ),
            }

            for cov_type, (pct_pat, hits_pat) in patterns.items():
                pct_match = re.search(pct_pat, content, re.IGNORECASE | re.MULTILINE)
                hits_match = re.search(hits_pat, content, re.IGNORECASE | re.MULTILINE)
                if pct_match and hits_match:
                    metrics[cov_type] = (
                        int(hits_match.group(1)), int(hits_match.group(2)),
                        float(pct_match.group(1))
                    )
        except Exception as e:
            util.warning(f"Failed to parse coverage summary: {e}")

        return metrics


    def display_coverage_summary(self, metrics: dict) -> None:
        """Display formatted coverage summary table."""
        if not metrics:
            return
        util.info(
            "\n" + "=" * 70 + f"\n{Colors.bold}Coverage Summary{Colors.off}\n" + "=" * 70
        )
        for cov_type, (hits, total, pct) in metrics.items():
            color = Colors.green if pct >= 80 else Colors.yellow if pct >= 50 else Colors.red
            util.info(
                f"  {cov_type.capitalize():12s}: {color}{pct:6.2f}%{Colors.off} ({hits}/{total})"
            )
        util.info("=" * 70 + "\n")


    def generate_baseline_report(self, acdb_path: str) -> (tempfile.TemporaryDirectory, str):
        """Generate temporary text report from ACDB file for comparison."""
        if not os.path.exists(acdb_path):
            self.handler_obj.error(f"Baseline ACDB not found: {acdb_path}")
            return ''

        # Note, the temp_dir is returned so the caller can cleanup()
        temp_dir = tempfile.TemporaryDirectory( # pylint: disable=consider-using-with
            dir=self.handler_obj.args['work-dir'],
            prefix='coverage_baseline_'
        )
        temp_report = os.path.join(temp_dir.name, 'baseline_report.txt')
        tcl_script = os.path.join(temp_dir.name, 'gen_baseline_report.tcl')

        with open(tcl_script, 'w', encoding='utf-8') as f:
            f.write(f'acdb report -i {os.path.abspath(acdb_path)} -txt -o {temp_report}\nexit\n')

        _, _, rc = self.handler_obj.exec(
            temp_dir.name,
            [self.handler_obj.sim_exe, '-c', '-do', os.path.abspath(tcl_script)],
            stop_on_error=False, ignore_error=True
        )
        if rc > 0:
            util.warning(f"Failed to generate baseline report using: {tcl_script}")
            return ''

        if not os.path.exists(temp_report):
            util.warning(f"Baseline report generation failed: {temp_report} not created")
            return ''
        return (temp_dir, temp_report)


    def calculate_coverage_delta(self, baseline_metrics: dict, current_metrics: dict) -> dict:
        """Calculate coverage deltas between baseline and current metrics."""
        delta = {}
        for cov_type in set(list(baseline_metrics.keys()) + list(current_metrics.keys())):
            if cov_type not in current_metrics:
                continue

            curr_hits, curr_total, curr_pct = current_metrics[cov_type]
            if cov_type not in baseline_metrics:
                delta[cov_type] = {
                    'baseline': {'hits': 0, 'total': 0, 'percentage': 0.0},
                    'current': {'hits': curr_hits, 'total': curr_total, 'percentage': curr_pct},
                    'delta': {'hits': curr_hits, 'total': curr_total, 'percentage': curr_pct},
                    'status': 'new',
                }
                continue

            base_hits, base_total, base_pct = baseline_metrics[cov_type]
            pct_delta = curr_pct - base_pct
            if pct_delta > 0.01:
                status_str = 'improved'
            elif pct_delta < -0.01:
                status_str = 'regressed'
            else:
                status_str = 'unchanged'
            delta[cov_type] = {
                'baseline': {'hits': base_hits, 'total': base_total, 'percentage': base_pct},
                'current': {'hits': curr_hits, 'total': curr_total, 'percentage': curr_pct},
                'delta': {
                    'hits': curr_hits - base_hits,
                    'total': curr_total - base_total,
                    'percentage': pct_delta
                },
                'status': status_str,
            }
        return delta

    def display_coverage_delta(self, delta_metrics: dict) -> None:
        """Display coverage delta summary in terminal with color coding."""
        if not delta_metrics:
            return

        improved = [t for t, d in delta_metrics.items() if d['status'] == 'improved']
        regressed = [t for t, d in delta_metrics.items() if d['status'] == 'regressed']
        if len(improved) > len(regressed):
            trend = f"{Colors.off_green}↑ Improved{Colors.off}"
        elif len(regressed) > len(improved):
            trend = f"{Colors.off_red}↓ Regressed{Colors.off}"
        else:
            trend = f"{Colors.off_yellow}→ Mixed{Colors.off}"

        util.info(
            "\n" + "=" * 70 + f"\n{Colors.bold}Coverage Delta (vs Baseline){Colors.off}\n" \
            + "=" * 70 + f"\n  Overall Trend: {trend}\n"
        )

        for cov_type in sorted(delta_metrics.keys()):
            d = delta_metrics[cov_type]
            pct_delta = d['delta']['percentage']
            if pct_delta > 0:
                pct_str = f"{Colors.off_green}+{pct_delta:5.2f}%{Colors.off}"
                sign = "↑"
            elif pct_delta < 0:
                pct_str = f"{Colors.off_red}{pct_delta:6.2f}%{Colors.off}"
                sign = "↓"
            else:
                pct_str = f"{Colors.yellow} {pct_delta:5.2f}% {Colors.off}"
                sign = "→"

            util.info(
                f"  {cov_type.capitalize():12s}: {d['current']['percentage']:6.2f}%",
                f"({d['current']['hits']}/{d['current']['total']}) {sign} {pct_str}",
                f"[baseline: {d['baseline']['percentage']:6.2f}%",
                f"({d['baseline']['hits']}/{d['baseline']['total']})]"
            )

        util.info("=" * 70)
        if improved:
            util.info(f"  {Colors.off_green}Improved{Colors.off}: {', '.join(improved)}")
        if regressed:
            util.info(f"  {Colors.off_red}Regressed{Colors.off}: {', '.join(regressed)}")
        if unchanged := [t for t, d in delta_metrics.items() if d['status'] == 'unchanged']:
            util.info(f"  {Colors.off_yellow}Unchanged{Colors.off}: {', '.join(unchanged)}")
        if new_types := [t for t, d in delta_metrics.items() if d['status'] == 'new']:
            util.info(f"  {Colors.off_cyan}New{Colors.off}: {', '.join(new_types)}")
        util.info("=" * 70 + "\n")


    def generate_delta_json(
            self, delta_metrics: dict, baseline_path: str, json_output: str
    ) -> None:
        """Generate JSON report with coverage delta information."""
        improved = [t for t, d in delta_metrics.items() if d['status'] == 'improved']
        regressed = [t for t, d in delta_metrics.items() if d['status'] == 'regressed']
        if len(improved) > len(regressed):
            overall_trend = 'improved'
        elif len(improved) < len(regressed):
            overall_trend = 'regressed'
        else:
            overall_trend = 'mixed'

        report = {
            'version': '2.0',
            'tool': 'Riviera-PRO',
            'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
            'target': self.handler_obj.target,
            'baseline': {
                'path': baseline_path,
                'coverage': {t: d['baseline'] for t, d in delta_metrics.items()}
            },
            'current': {
                'coverage': {t: d['current'] for t, d in delta_metrics.items()}
            },
            'delta': {
                t: {
                    'hits_delta': d['delta']['hits'],
                    'total_delta': d['delta']['total'],
                    'percentage_delta': d['delta']['percentage'],
                    'status': d['status']
                } for t, d in delta_metrics.items()
            },
            'summary': {
                'improved': improved,
                'regressed': regressed,
                'unchanged': [t for t, d in delta_metrics.items() if d['status'] == 'unchanged'],
                'new': [t for t, d in delta_metrics.items() if d['status'] == 'new'],
                'overall_trend': overall_trend
            },
        }

        out_file = files.safe_output_file(
            work_dir=self.handler_obj.args['work-dir'], default_fname='coverage_output.json',
            user_fpath=json_output
        )
        with open(out_file, 'w', encoding='utf-8') as f:
            json.dump(report, f, indent=2)
        util.info(f"Coverage delta JSON: {out_file}")


    def check_delta_thresholds(self, delta_metrics: dict) -> bool:
        """Check if coverage deltas meet improvement/regression thresholds."""

        args = self.handler_obj.args
        improve_threshold_str = args.get('coverage-delta-threshold-improve', '')
        regress_threshold_str = args.get('coverage-delta-threshold-regress', '')
        if not improve_threshold_str and not regress_threshold_str:
            return True

        regress_threshold = 0.0
        improve_threshold = 0.0
        try:
            if regress_threshold_str:
                regress_threshold = float(regress_threshold_str)
        except ValueError:
            util.warning(f"Invalid regression threshold: {regress_threshold_str}")

        try:
            if improve_threshold_str:
                improve_threshold = float(improve_threshold_str)
        except ValueError:
            util.warning(f"Invalid improvement threshold: {improve_threshold_str}")

        failures = []
        for cov_type, d in delta_metrics.items():
            pct_delta = d['delta']['percentage']
            if improve_threshold and pct_delta < improve_threshold:
                failures.append(
                    f"{cov_type}: {pct_delta:+.2f}% < required +{improve_threshold:.2f}%"
                    " improvement"
                )
            if regress_threshold and pct_delta < regress_threshold:
                failures.append(
                    f"{cov_type}: {pct_delta:+.2f}% < allowed {regress_threshold:.2f}%"
                    " regression"
                )

        if failures:
            failure_msg = (
                "Coverage delta thresholds not met:\n"
                "\n".join([f"  {f}" for f in failures])
            )
            if args.get('coverage-threshold-fail', False):
                self.handler_obj.error(failure_msg)
                return False
            util.warning(failure_msg + "\n(--coverage-threshold-fail not set, continuing)")
        return True


    def save_baseline(self, acdb_path: str) -> None:
        """Save current ACDB as baseline for future comparisons."""
        if not os.path.exists(acdb_path):
            util.warning(f"Cannot save baseline: ACDB not found: {acdb_path}")
            return

        args = self.handler_obj.args
        baseline_dir_abs = os.path.abspath(
            args.get('coverage-baseline-dir', '.coverage_baselines')
        )
        os.makedirs(baseline_dir_abs, exist_ok=True)
        target_name = self.handler_obj.target.replace('/', '_')
        baseline_name = f"{target_name}_{time.strftime('%Y%m%d_%H%M%S')}.acdb"
        baseline_path = os.path.join(baseline_dir_abs, baseline_name)

        try:
            # TODO(drew): We could probably do this with check permissions and warning ahead
            # of time, instead of try/except bombing out with a stacktrace?
            shutil.copy2(acdb_path, baseline_path)
            util.info(f"Saved baseline: {baseline_path}")
            latest_link = os.path.join(baseline_dir_abs, f"{target_name}_latest.acdb")
            if os.path.exists(latest_link) or os.path.islink(latest_link):
                os.remove(latest_link)
            os.symlink(baseline_name, latest_link)
            util.info(f"Updated latest baseline link: {latest_link}")
        except Exception as e:
            util.warning(f"Failed to save baseline: {e}")


    def find_auto_baseline(self) -> str:
        """Find the most recent baseline ACDB for auto-comparison."""

        args = self.handler_obj.args
        baseline_dir_abs = os.path.abspath(
            args.get('coverage-baseline-dir', '.coverage_baselines')
        )
        if not os.path.exists(baseline_dir_abs):
            util.warning(f"Baseline directory not found: {baseline_dir_abs}")
            return ''

        target_name = self.handler_obj.target.replace('/', '_')
        latest_link = os.path.join(baseline_dir_abs, f"{target_name}_latest.acdb")
        if os.path.exists(latest_link):
            resolved = os.path.realpath(latest_link)
            if os.path.exists(resolved):
                util.info(f"Auto-baseline: {resolved}")
                return resolved

        candidates = glob.glob(os.path.join(baseline_dir_abs, f"{target_name}_*.acdb"))
        if not candidates:
            util.warning(
                f"No baseline found for target '{self.handler_obj.target}' in {baseline_dir_abs}"
            )
            return ''

        baseline_path = max(candidates, key=os.path.getmtime)
        util.info(f"Auto-baseline: {baseline_path}")
        return baseline_path

    def generate_coverage_json(
            self, cov_txt_path: str, json_output: str, target_name: str = ''
    ) -> None:
        """Generate structured JSON coverage report from a text coverage report."""
        metrics = self.parse_coverage_metrics(cov_txt_path) if os.path.exists(cov_txt_path) else {}
        if not target_name:
            target_name = getattr(self.handler_obj, 'target', 'unknown')
        report = {
            'version': '1.0',
            'tool': 'Riviera-PRO',
            'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
            'target': target_name,
            'coverage': {
                k: dict(
                    zip(['hits', 'total', 'percentage'], metrics.get(k, (0, 0, 0.0)))
                ) for k in ['statement', 'branch', 'toggle', 'covergroup']
            },
        }
        out_file = files.safe_output_file(
            work_dir=self.handler_obj.args['work-dir'], default_fname='coverage_output.json',
            user_fpath=json_output
        )
        with open(out_file, 'w', encoding='utf-8') as f:
            json.dump(report, f, indent=2)
        util.info(f"Coverage JSON: {out_file}")
