// -*- mode: C++; c-file-style: "cc-mode" -*-
//*************************************************************************
// DESCRIPTION: Verilator: Functional coverage implementation
//
// Code available from: https://verilator.org
//
//*************************************************************************
//
// This program is free software; you can redistribute it and/or modify it
// under the terms of either the GNU Lesser General Public License Version 3
// or the Perl Artistic License Version 2.0.
// SPDX-FileCopyrightText: 2003-2026 Wilson Snyder
// SPDX-License-Identifier: LGPL-3.0-only OR Artistic-2.0
//
//*************************************************************************
// FUNCTIONAL COVERAGE TRANSFORMATIONS:
//      For each covergroup (AstClass with isCovergroup()):
//          For each coverpoint (AstCoverpoint):
//              Generate member variable for VerilatedCoverpoint
//              Generate initialization in constructor
//              Generate sample code in sample() method
//
//*************************************************************************

#include "V3PchAstNoMT.h"  // VL_MT_DISABLED_CODE_UNIT

#include "V3Covergroup.h"

#include "V3Const.h"
#include "V3File.h"
#include "V3MemberMap.h"

#include <set>
#include <vector>

VL_DEFINE_DEBUG_FUNCTIONS;

//######################################################################
// Covergroup expression validation visitor

class CovergroupExprValidVisitor final : public VNVisitor {
    const std::set<const AstVar*>& m_sampleMembers;
    const std::set<const AstVar*>& m_constructorRefMembers;
    bool m_inCoverageExpression = false;
    bool m_sampleFormalAllowed = false;

    void scanSampleExpression(AstNode* nodep) {
        if (!nodep) return;
        VL_RESTORER(m_sampleFormalAllowed);
        m_sampleFormalAllowed = true;
        iterateAndNextNull(nodep);
    }

    void scanCoverageExpression(AstNode* nodep) {
        if (!nodep) return;
        VL_RESTORER(m_inCoverageExpression);
        m_inCoverageExpression = true;
        iterateAndNextNull(nodep);
    }

    void visit(AstCoverpoint* nodep) override {
        scanSampleExpression(nodep->exprp());
        scanSampleExpression(nodep->iffp());
        iterateAndNextNull(nodep->binsp());
        iterateAndNextNull(nodep->optionsp());
    }
    void visit(AstCoverCross* nodep) override {
        iterateAndNextNull(nodep->itemsp());
        iterateAndNextNull(nodep->optionsp());
        iterateAndNextNull(nodep->rawBodyp());
    }
    void visit(AstCoverBin* nodep) override {
        scanCoverageExpression(nodep->rangesp());
        scanSampleExpression(nodep->iffp());
        scanCoverageExpression(nodep->arraySizep());
        scanCoverageExpression(nodep->transp());
    }
    void visit(AstVarRef* nodep) override {
        if (!m_sampleFormalAllowed && m_sampleMembers.count(nodep->varp())) {
            nodep->v3error("Covergroup sample formal argument "
                           << nodep->varp()->prettyNameQ()
                           << " may only be used in a coverpoint or conditional guard "
                              "expression (IEEE 1800-2012 19.8.1).");
        }
        if (m_inCoverageExpression && m_constructorRefMembers.count(nodep->varp())) {
            nodep->v3error("Ref covergroup constructor formal argument "
                           << nodep->varp()->prettyNameQ()
                           << " may not be used in a covergroup expression "
                              "(IEEE 1800-2012 19.5).");
        }
    }
    void visit(AstNodeFTaskRef* nodep) override {
        if (m_inCoverageExpression && nodep->taskp()) {
            bool invalidDirection = false;
            for (AstNode* stmtp = nodep->taskp()->stmtsp(); stmtp; stmtp = stmtp->nextp()) {
                const AstVar* const varp = VN_CAST(stmtp, Var);
                if (varp && varp->isIO() && varp->isWritable()) {
                    invalidDirection = true;
                    break;
                }
            }
            if (invalidDirection) {
                nodep->v3error("Function " << nodep->taskp()->prettyNameQ()
                                           << " called in a covergroup expression has an "
                                              "output, inout, or non-const ref argument "
                                              "(IEEE 1800-2012 19.5).");
            }
        }
        iterateChildren(nodep);
    }
    void visit(AstNode* nodep) override { iterateChildren(nodep); }

public:
    CovergroupExprValidVisitor(const std::set<const AstVar*>& sampleMembers,
                               const std::set<const AstVar*>& constructorRefMembers)
        : m_sampleMembers{sampleMembers}
        , m_constructorRefMembers{constructorRefMembers} {}
    void scan(AstNode* nodep) { iterate(nodep); }
};

//######################################################################
// Functional coverage visitor

class FunctionalCoverageVisitor final : public VNVisitor {
    // NODE STATE
    // Entire netlist:
    //  AstCoverpoint::user1p()  -> AstVar*.  Previous-value variable for transition bins
    const VNUser1InUse m_inuser1;

    // STATE
    AstClass* m_covergroupp = nullptr;  // Current covergroup being processed
    AstClass* m_enclosingClassp = nullptr;  // Class lexically enclosing the covergroup, if any
    AstVar* m_embeddedVarp = nullptr;  // Embedded covergroup member of m_enclosingClassp, if any
    AstFunc* m_sampleFuncp = nullptr;  // Current sample() function
    AstFunc* m_constructorp = nullptr;  // Current constructor
    std::vector<AstCoverpoint*> m_coverpoints;  // Coverpoints in current covergroup
    std::map<std::string, AstCoverpoint*> m_coverpointMap;  // Name -> coverpoint for fast lookup
    std::vector<AstCoverCross*> m_coverCrosses;  // Cross coverage items in current covergroup

    struct EmbeddedEventTrigger final {
        FileLine* eventFl;  // Clocking-event source location
        AstVar* baseVarp;  // Base enclosing-class member in the event expression
        AstVar* memberVarp;  // Selected member in a 'base.member' expression, or nullptr
        VEdgeType edgeType;  // Clocking-event edge qualifier
        AstVar* prevVarp;  // Member containing the previous event value, or nullptr
        EmbeddedEventTrigger(FileLine* eventFl, AstVar* baseVarp, AstVar* memberVarp,
                             VEdgeType edgeType)
            : eventFl{eventFl}
            , baseVarp{baseVarp}
            , memberVarp{memberVarp}
            , edgeType{edgeType}
            , prevVarp{nullptr} {}
    };

    std::set<std::string> m_crossedCpNames;  // Coverpoints referenced by a cross
    std::vector<AstVar*> m_cpVars;  // VlCoverpoint member, one per coverpoint
    std::vector<AstVar*> m_crossVars;  // VlCoverCross member, one per cross
    std::map<std::string, AstVar*> m_cpVarMap;  // Coverpoint name -> its VlCoverpoint member
    std::set<AstCoverCross*>
        m_droppedCrosses;  // Crosses with a bare-variable item: drop (COVERIGN)
    std::map<int, AstCDType*> m_vlCoverpointTypes;  // hit-list bound K -> "VlCoverpointT<K>" type
    AstCDType* m_vlCoverCrossDTypep = nullptr;  // Shared "VlCoverCross" C++ member type

    VMemberMap m_memberMap;  // Member names cached for fast lookup

    // METHODS
    void processCovergroup() {
        UINFO(4, "Processing covergroup: " << m_covergroupp->name() << " with "
                                           << m_coverpoints.size() << " coverpoints and "
                                           << m_coverCrosses.size() << " crosses");

        m_crossedCpNames.clear();
        m_cpVars.clear();
        m_crossVars.clear();
        m_cpVarMap.clear();
        m_droppedCrosses.clear();

        // Scan every cross item to record the coverpoints it references (the cross dimensions)
        // and to flag any cross naming a bare variable -- a would-be implicit coverpoint, which
        // Verilator does not synthesize.  An unresolvable item drops only that one cross (with a
        // COVERIGN in generateCrossCode), leaving the rest of the covergroup intact.
        for (AstCoverCross* crossp : m_coverCrosses) {
            for (AstNode* itemp = crossp->itemsp(); itemp; itemp = itemp->nextp()) {
                const AstCoverpointRef* const refp = VN_AS(itemp, CoverpointRef);
                if (refp->exprp()) continue;  // hierarchical ref: dropped in generateCrossCode
                if (m_coverpointMap.find(refp->name()) == m_coverpointMap.end()) {
                    m_droppedCrosses.insert(crossp);  // bare variable: drop this cross only
                } else {
                    m_crossedCpNames.insert(refp->name());
                }
            }
        }

        // For each coverpoint, generate sampling code
        for (AstCoverpoint* cpp : m_coverpoints) generateCoverpointCode(cpp);

        // For each cross, generate sampling code
        for (AstCoverCross* crossp : m_coverCrosses) generateCrossCode(crossp);

        // Generate coverage computation code (even for empty covergroups).  Bin registration
        // with the coverage database is handled per coverpoint/cross by their runtime
        // registerBins() calls (emitted in generateCoverpoint/generateCross).

        // TODO: Generate instance registry infrastructure for static get_coverage()
        // This requires:
        // - Static registry members (t_instances, s_mutex)
        // - registerInstance() / unregisterInstance() methods
        // - Proper C++ emission in EmitC backend
        // For now, get_coverage() returns 0.0 (placeholder)
        generateCoverageComputationCode();
    }

    static constexpr int COVER_BINS_LIMIT
        = 1000;  // Sanity limit to avoid hangs from e.g. signed underflow

    void expandAutomaticBins(AstCoverpoint* coverpointp, AstNodeExpr* exprp) {
        // Find and expand any automatic bins
        AstNode* prevBinp = nullptr;
        for (AstNode* binp = coverpointp->binsp(); binp;) {
            AstCoverBin* const cbinp = VN_AS(binp, CoverBin);
            AstNode* const nextBinp = binp->nextp();

            if (cbinp->binsType() == VCoverBinsType::BINS_AUTO) {
                UINFO(4, "  Expanding automatic bin: " << cbinp->name());

                // Get array size - must be a constant
                AstNodeExpr* const sizep = cbinp->arraySizep();

                // Evaluate as constant
                const AstConst* constp = VN_CAST(sizep, Const);
                if (!constp) {
                    cbinp->v3error("Automatic bins array size must be a constant");
                    binp = nextBinp;
                    continue;
                }

                const int numBins = constp->toSInt();
                if (numBins <= 0) {
                    cbinp->v3error("Automatic bins array size must be >= 1, got " << numBins);
                    binp = nextBinp;
                    continue;
                }
                if (numBins > COVER_BINS_LIMIT) {
                    cbinp->v3error("Automatic bins array size of "
                                   << numBins << " exceeds limit of " << COVER_BINS_LIMIT);
                    binp = nextBinp;
                    continue;
                }

                // Calculate range division
                const int width = exprp->width();
                const uint64_t maxVal = (width >= 64) ? UINT64_MAX : ((1ULL << width) - 1);
                // For width >= 64: (maxVal+1) would overflow; compute binSize without overflow
                const uint64_t binSize
                    = (width < 64) ? ((maxVal + 1) / numBins) : (UINT64_MAX / numBins + 1);

                UINFO(4, "    Width=" << width << " maxVal=" << maxVal << " numBins=" << numBins
                                      << " binSize=" << binSize);

                // Create expanded bins
                for (int i = 0; i < numBins; i++) {
                    const uint64_t lo = static_cast<uint64_t>(i) * binSize;
                    const uint64_t hi = (i == numBins - 1) ? maxVal : ((i + 1) * binSize - 1);

                    // Create constants for range (use setQuad to handle values > 32-bit)
                    V3Number loNum{cbinp->fileline(), width, 0};
                    loNum.setQuad(lo);
                    AstConst* const loConstp = new AstConst{cbinp->fileline(), loNum};
                    V3Number hiNum{cbinp->fileline(), width, 0};
                    hiNum.setQuad(hi);
                    AstConst* const hiConstp = new AstConst{cbinp->fileline(), hiNum};

                    // Create InsideRange [lo:hi]
                    AstInsideRange* const rangep
                        = new AstInsideRange{cbinp->fileline(), loConstp, hiConstp};
                    rangep->dtypeFrom(exprp);  // Set dtype from coverpoint expression

                    // Create new bin
                    const string binName = cbinp->name() + "[" + std::to_string(i) + "]";
                    AstCoverBin* const newBinp
                        = new AstCoverBin{cbinp->fileline(), binName, rangep, false, false};

                    // Insert after previous bin
                    if (prevBinp) {
                        prevBinp->addNext(newBinp);
                    } else {
                        coverpointp->addBinsp(newBinp);
                    }
                    prevBinp = newBinp;
                }

                // Remove the AUTO bin from the list
                VL_DO_DANGLING(pushDeletep(binp->unlinkFrBack()), binp);
            } else {
                prevBinp = binp;
            }

            binp = nextBinp;
        }
    }

    // Extract all coverpoint option values in a single pass.
    // atLeastOut: option.at_least (default 1)
    // autoBinMaxOut: option.auto_bin_max (coverpoint overrides covergroup, default 64)
    void extractCoverpointOptions(AstCoverpoint* coverpointp, int& atLeastOut,
                                  int& autoBinMaxOut) {
        atLeastOut = 1;
        autoBinMaxOut = -1;  // -1 = not set at coverpoint level
        for (AstNode* optionp = coverpointp->optionsp(); optionp; optionp = optionp->nextp()) {
            AstCoverOption* const optp = VN_AS(optionp, CoverOption);
            AstConst* const constp = VN_CAST(optp->valuep(), Const);
            if (!constp) {
                optp->valuep()->v3warn(COVERIGN, "Ignoring unsupported: non-constant 'option."
                                                     << optp->optType().ascii()
                                                     << "'; using default value");
                continue;
            }
            if (optp->optType() == VCoverOptionType::AT_LEAST) {
                atLeastOut = constp->toSInt();
            } else {
                // V3LinkParse only converts at_least/auto_bin_max coverpoint options into
                // AstCoverOption (others are dropped there), so this is the only alternative.
                UASSERT_OBJ(optp->optType() == VCoverOptionType::AUTO_BIN_MAX, optp,
                            "Unexpected coverpoint option type reaching V3Covergroup");
                autoBinMaxOut = constp->toSInt();
            }
        }
        // Fall back to covergroup-level auto_bin_max if not set at coverpoint level
        if (autoBinMaxOut < 0) {
            if (m_covergroupp->cgAutoBinMax() >= 0) {
                autoBinMaxOut = m_covergroupp->cgAutoBinMax();
            } else {
                autoBinMaxOut = 64;  // Default per IEEE 1800-2023 Table 19-1
            }
        }
    }

    // Extract individual values from a range expression list, used only to carve values
    // out of implicit auto-bins.  Iterates over all siblings (nextp) in the list, handling
    // AstConst (single value) and AstInsideRange ([lo:hi]); an open-ended bound ('$',
    // AstUnbounded) resolves to the coverpoint domain min (lower) or max (upper, == maxVal).
    void extractValuesFromRange(AstNode* nodep, std::set<uint64_t>& values, uint64_t maxVal) {
        // Cap enumeration so a '$'-bounded or otherwise huge range cannot blow up memory;
        // auto-bins are per-value only for small domains, so a partial set is harmless here.
        constexpr size_t maxEnumerate = 1ULL << 16;
        for (AstNode* np = nodep; np; np = np->nextp()) {
            if (AstConst* constp = VN_CAST(np, Const)) {
                if (constp->num().isFourState())
                    continue;  // wildcard patterns can't be enumerated
                values.insert(constp->toUQuad());
            } else if (AstInsideRange* rangep = VN_CAST(np, InsideRange)) {
                AstNodeExpr* const lhsp = V3Const::constifyEdit(rangep->lhsp());
                AstNodeExpr* const rhsp = V3Const::constifyEdit(rangep->rhsp());
                const bool loUnbounded = VN_IS(lhsp, Unbounded);
                const bool hiUnbounded = VN_IS(rhsp, Unbounded);
                AstConst* const loConstp = VN_CAST(lhsp, Const);
                AstConst* const hiConstp = VN_CAST(rhsp, Const);
                if ((!loConstp && !loUnbounded) || (!hiConstp && !hiUnbounded)) {
                    rangep->v3error("Non-constant expression in bin range; "
                                    "range bounds must be constants");
                    continue;
                }
                if ((loConstp && loConstp->num().isFourState())
                    || (hiConstp && hiConstp->num().isFourState()))
                    continue;
                const uint64_t lo = loUnbounded ? 0 : loConstp->toUQuad();
                const uint64_t hi = hiUnbounded ? maxVal : hiConstp->toUQuad();
                for (uint64_t v = lo; v <= hi; v++) {
                    if (values.size() >= maxEnumerate) break;
                    values.insert(v);
                }
            } else {
                np->v3error("Non-constant expression in bin value list; values must be constants");
            }
        }
    }

    // Single-pass categorization: determine whether any regular (non-ignore/illegal) bins exist
    // and collect the set of excluded values from ignore/illegal bins.
    void categorizeBins(AstCoverpoint* coverpointp, bool& hasRegularOut,
                        std::set<uint64_t>& excludedOut, uint64_t maxVal) {
        hasRegularOut = false;
        for (AstNode* binp = coverpointp->binsp(); binp; binp = binp->nextp()) {
            AstCoverBin* const cbinp = VN_AS(binp, CoverBin);
            const VCoverBinsType btype = cbinp->binsType();
            if (btype == VCoverBinsType::BINS_IGNORE || btype == VCoverBinsType::BINS_ILLEGAL) {
                if (AstNode* rangep = cbinp->rangesp()) {
                    extractValuesFromRange(rangep, excludedOut, maxVal);
                }
            } else {
                hasRegularOut = true;
            }
        }
    }

    // Create implicit automatic bins when coverpoint has no explicit regular bins
    void createImplicitAutoBins(AstCoverpoint* coverpointp, AstNodeExpr* exprp, int autoBinMax) {
        const int width = exprp->width();
        const uint64_t maxVal = (width >= 64) ? UINT64_MAX : ((1ULL << width) - 1);

        // Single pass: check for regular bins and collect excluded values simultaneously.
        // maxVal resolves any '$' (open-ended) bound in ignore_bins/illegal_bins ranges.
        bool hasRegular = false;
        std::set<uint64_t> excluded;
        categorizeBins(coverpointp, hasRegular, excluded, maxVal);

        // If already has regular bins, nothing to do
        if (hasRegular) return;

        UINFO(4, "  Creating implicit automatic bins for coverpoint: " << coverpointp->name());

        const uint64_t numTotalValues = (width >= 64) ? UINT64_MAX : (1ULL << width);
        const uint64_t numValidValues = numTotalValues - excluded.size();

        // Determine number of bins to create (based on non-excluded values)
        int numBins;
        if (numValidValues <= static_cast<uint64_t>(autoBinMax)) {
            // Create one bin per valid value
            numBins = numValidValues;
        } else {
            // Create autoBinMax bins, dividing range
            numBins = autoBinMax;
        }

        UINFO(4, "    Width=" << width << " numTotalValues=" << numTotalValues
                              << " numValidValues=" << numValidValues << " autoBinMax="
                              << autoBinMax << " creating " << numBins << " bins");

        // Strategy: Create bins for each value (if numValidValues <= autoBinMax)
        // or create range bins that avoid excluded values
        if (numValidValues <= static_cast<uint64_t>(autoBinMax)) {
            // Create one bin per valid value
            int binCount = 0;
            for (uint64_t v = 0; v <= maxVal && binCount < numBins; v++) {
                // Skip excluded values
                if (excluded.find(v) != excluded.end()) continue;

                // Create single-value bin
                AstConst* const valConstp = new AstConst{
                    coverpointp->fileline(), V3Number(coverpointp->fileline(), width, v)};
                AstConst* const valConstp2 = new AstConst{
                    coverpointp->fileline(), V3Number(coverpointp->fileline(), width, v)};

                AstInsideRange* const rangep
                    = new AstInsideRange{coverpointp->fileline(), valConstp, valConstp2};
                rangep->dtypeFrom(exprp);

                const string binName = "auto_" + std::to_string(binCount);
                AstCoverBin* const newBinp
                    = new AstCoverBin{coverpointp->fileline(), binName, rangep, false, false};

                coverpointp->addBinsp(newBinp);
                binCount++;
            }
            UINFO(4, "    Created " << binCount << " single-value automatic bins");
        } else {
            // Create range bins (more complex - need to handle excluded values in ranges)
            // For simplicity, create bins and let excluded values not match any bin
            const uint64_t binSize = (maxVal + 1) / numBins;

            for (int i = 0; i < numBins; i++) {
                const uint64_t lo = i * binSize;
                const uint64_t hi = (i == numBins - 1) ? maxVal : ((i + 1) * binSize - 1);

                // Create constants for range
                AstConst* const loConstp = new AstConst{
                    coverpointp->fileline(), V3Number(coverpointp->fileline(), width, lo)};
                AstConst* const hiConstp = new AstConst{
                    coverpointp->fileline(), V3Number(coverpointp->fileline(), width, hi)};

                // Create InsideRange [lo:hi]
                AstInsideRange* const rangep
                    = new AstInsideRange{coverpointp->fileline(), loConstp, hiConstp};
                rangep->dtypeFrom(exprp);

                // Create bin name
                const string binName = "auto_" + std::to_string(i);
                AstCoverBin* const newBinp
                    = new AstCoverBin{coverpointp->fileline(), binName, rangep, false, false};

                // Add to coverpoint
                coverpointp->addBinsp(newBinp);
            }

            UINFO(4, "    Created range-based automatic bins");
        }
    }

    // Sanitize generated names to be valid C++ identifiers
    static string sanitizeGeneratedName(string name) {
        std::replace(name.begin(), name.end(), '[', '_');
        std::replace(name.begin(), name.end(), ']', '_');
        return name;
    }

    // Capture an iff guard in a function-local temporary so it is evaluated once per sample()
    AstVarRef* captureIffToTemp(AstNodeExpr* iffp, const string& tempName) {
        FileLine* const fl = iffp->fileline();
        AstVar* const iffVarp
            = new AstVar{fl, VVarType::BLOCKTEMP, tempName, iffp->findBitDType()};
        iffVarp->funcLocal(true);
        m_sampleFuncp->addStmtsp(iffVarp);
        iffp->unlinkFrBack();
        m_sampleFuncp->addStmtsp(
            new AstAssign{fl, new AstVarRef{fl, iffVarp, VAccess::WRITE}, iffp});
        return new AstVarRef{fl, iffVarp, VAccess::READ};
    }

    AstNodeExpr* applyCoverpointIffCondition(AstCoverpoint* coverpointp, FileLine* fl,
                                             AstNodeExpr* condp) {
        if (AstNodeExpr* const iffp = coverpointp->iffp()) {
            UINFO(6, "      Adding iff condition");
            condp = new AstAnd{fl, iffp->cloneTree(false), condp};
        }
        return condp;
    }

    // Create previous value variable for transition tracking
    AstVar* createPrevValueVar(AstCoverpoint* coverpointp, AstNodeExpr* exprp) {
        // Check if already created
        if (AstVar* const prevVarp = VN_CAST(coverpointp->user1p(), Var)) return prevVarp;

        // Create variable to store previous sampled value
        const string varName = "__Vprev_" + coverpointp->name();
        AstVar* prevVarp
            = new AstVar{coverpointp->fileline(), VVarType::MEMBER, varName, exprp->dtypep()};
        prevVarp->isStatic(false);
        m_covergroupp->addMembersp(prevVarp);

        UINFO(4, "    Created previous value variable: " << varName);

        // Initialize to zero in constructor
        AstNodeExpr* const initExprp
            = new AstConst{prevVarp->fileline(), AstConst::WidthedValue{}, prevVarp->width(), 0};
        AstNodeStmt* const initStmtp = new AstAssign{
            prevVarp->fileline(), new AstVarRef{prevVarp->fileline(), prevVarp, VAccess::WRITE},
            initExprp};
        m_constructorp->addStmtsp(initStmtp);

        coverpointp->user1p(prevVarp);
        return prevVarp;
    }

    // Create state position variable for multi-value transition bins
    // Tracks position in sequence: 0=not started, 1=seen first item, etc.
    AstVar* createSequenceStateVar(AstCoverpoint* coverpointp, AstCoverBin* binp) {
        // Create variable to track sequence position
        const string varName = "__Vseqpos_" + coverpointp->name() + "_" + binp->name();
        // Use 8-bit integer for state position (sequences rarely > 255 items)
        AstVar* stateVarp
            = new AstVar{binp->fileline(), VVarType::MEMBER, varName, VFlagLogicPacked{}, 8};
        stateVarp->isStatic(false);
        m_covergroupp->addMembersp(stateVarp);

        UINFO(4, "    Created sequence state variable: " << varName);

        // Initialize to 0 (not started) in constructor
        AstNodeStmt* const initStmtp = new AstAssign{
            stateVarp->fileline(), new AstVarRef{stateVarp->fileline(), stateVarp, VAccess::WRITE},
            new AstConst{stateVarp->fileline(), AstConst::WidthedValue{}, 8, 0}};
        m_constructorp->addStmtsp(initStmtp);

        return stateVarp;
    }

    void generateCoverpointCode(AstCoverpoint* coverpointp) {
        UINFO(4, "  Generating code for coverpoint: " << coverpointp->name());

        // Get the coverpoint expression
        AstNodeExpr* exprp = coverpointp->exprp();

        // Expand automatic bins before processing
        expandAutomaticBins(coverpointp, exprp);

        // Extract all coverpoint options in a single pass
        int atLeastValue;
        int autoBinMax;
        extractCoverpointOptions(coverpointp, atLeastValue, autoBinMax);
        UINFO(6, "    Coverpoint at_least = " << atLeastValue << " auto_bin_max = " << autoBinMax);

        // Create implicit automatic bins if no regular bins exist
        createImplicitAutoBins(coverpointp, exprp, autoBinMax);

        AstVar* const valueVarp = new AstVar{
            coverpointp->fileline(), VVarType::BLOCKTEMP,
            "__VcpValue_" + sanitizeGeneratedName(coverpointp->name()), exprp->dtypep()};
        valueVarp->funcLocal(true);
        m_sampleFuncp->addStmtsp(valueVarp);
        exprp->unlinkFrBack();
        m_sampleFuncp->addStmtsp(new AstAssign{
            coverpointp->fileline(),
            new AstVarRef{coverpointp->fileline(), valueVarp, VAccess::WRITE}, exprp});
        coverpointp->exprp(new AstVarRef{coverpointp->fileline(), valueVarp, VAccess::READ});
        exprp = coverpointp->exprp();

        // Every coverpoint routes through the VlCoverpoint runtime.  Transition coverpoints are
        // included: their per-value matching is still generated as a state machine in sample()
        // (see generateCoverpoint), but the bin hit is recorded in the runtime bin
        // rather than a bare counter.
        generateCoverpoint(coverpointp, exprp, atLeastValue);
    }

    // Build the condition under which a default bin matches: NOT(OR of all normal bins).
    AstNodeExpr* buildDefaultCondition(AstCoverpoint* coverpointp, AstNodeExpr* exprp,
                                       FileLine* fl) {
        AstNodeExpr* anyBinMatchp = nullptr;
        for (AstNode* binp = coverpointp->binsp(); binp; binp = binp->nextp()) {
            AstCoverBin* const cbinp = VN_AS(binp, CoverBin);
            if (cbinp->binsType() == VCoverBinsType::BINS_DEFAULT
                || cbinp->binsType() == VCoverBinsType::BINS_IGNORE
                || cbinp->binsType() == VCoverBinsType::BINS_ILLEGAL)
                continue;
            AstNodeExpr* const binCondp = buildBinCondition(cbinp, exprp);
            UASSERT_OBJ(binCondp, cbinp,
                        "buildBinCondition returned nullptr for non-ignore/non-illegal bin");
            anyBinMatchp = anyBinMatchp ? new AstOr{fl, anyBinMatchp, binCondp} : binCondp;
        }
        return anyBinMatchp ? static_cast<AstNodeExpr*>(new AstNot{fl, anyBinMatchp})
                            : static_cast<AstNodeExpr*>(new AstConst{fl, AstConst::BitTrue{}});
    }

    //====================================================================
    // VlCoverpoint conversion

    // True if a coverpoint has any transition bin.  Used to decide whether sample() emits the
    // end-of-sample previous-value update that transition matching needs.
    static bool coverpointHasTransition(AstCoverpoint* coverpointp) {
        for (AstNode* binp = coverpointp->binsp(); binp; binp = binp->nextp()) {
            if (VN_AS(binp, CoverBin)->transp()) return true;
        }
        return false;
    }

    // Get (or create) the "VlCoverpointT<K>" member type for hit-list bound K.
    AstCDType* vlCoverpointType(FileLine* fl, int hitBound) {
        const auto it = m_vlCoverpointTypes.find(hitBound);
        if (it != m_vlCoverpointTypes.end()) return it->second;
        AstCDType* const typep
            = new AstCDType{fl, "VlCoverpointT<" + std::to_string(hitBound) + ">"};
        v3Global.rootp()->typeTablep()->addTypesp(typep);
        m_vlCoverpointTypes.emplace(hitBound, typep);
        return typep;
    }

    // Constant bounds of one rangesp() element (an InsideRange or a single Const).  Each bound is
    // the raw AST node -- an AstConst or an AstUnbounded ('$') -- with the const/unbounded view
    // derived on demand, so there is one source of truth per bound.  After a successful
    // constRangeBounds() neither node is null; a single Const has both bounds aliasing one node.
    struct RangeBounds final {
        AstNode* loNodep = nullptr;  // low bound: AstConst or AstUnbounded
        AstNode* hiNodep = nullptr;  // high bound: AstConst or AstUnbounded
        bool loUnbounded() const { return VN_IS(loNodep, Unbounded); }
        bool hiUnbounded() const { return VN_IS(hiNodep, Unbounded); }
        AstConst* loConstp() const { return VN_CAST(loNodep, Const); }
        AstConst* hiConstp() const { return VN_CAST(hiNodep, Const); }
    };

    // Decode one rangesp() element into its constant bounds.  Returns false if rp is neither an
    // InsideRange nor a single Const, or if a present bound is non-constant or 4-state.  '$'
    // bounds are left as AstUnbounded (not resolved) -- the caller applies its own policy.
    // Centralizes the InsideRange/Const/Unbounded decode shared by the hit-list-bound paths.
    static bool constRangeBounds(AstNode* rp, RangeBounds& rb) {
        if (AstInsideRange* const irp = VN_CAST(rp, InsideRange)) {
            rb.loNodep = irp->lhsp();
            rb.hiNodep = irp->rhsp();
        } else if (AstConst* const cp = VN_CAST(rp, Const)) {
            rb.loNodep = rb.hiNodep = cp;
        } else {
            return false;
        }
        // Each bound must be a constant unless it is '$'; reject non-const and 4-state.
        AstConst* const lc = rb.loConstp();
        AstConst* const hc = rb.hiConstp();
        if ((!lc && !rb.loUnbounded()) || (!hc && !rb.hiUnbounded())) return false;
        if ((lc && lc->num().isFourState()) || (hc && hc->num().isFourState())) return false;
        return true;
    }

    // Collect the covered value intervals of a single (non-array) Normal bin.  Returns false
    // if any range isn't a constant/open InsideRange or single Const (e.g. wildcard, non-const).
    static bool extractRangeIntervals(AstCoverBin* cbinp, uint64_t maxVal,
                                      std::vector<std::pair<uint64_t, uint64_t>>& out) {
        if (!cbinp->rangesp()) return false;
        for (AstNode* rp = cbinp->rangesp(); rp; rp = rp->nextp()) {
            RangeBounds rb;
            if (!constRangeBounds(rp, rb)) return false;
            const uint64_t lo = rb.loUnbounded() ? 0 : rb.loConstp()->toUQuad();
            const uint64_t hi = rb.hiUnbounded() ? maxVal : rb.hiConstp()->toUQuad();
            if (lo > hi) return false;
            out.emplace_back(lo, hi);
        }
        return true;
    }

    // Append one Normal bin's cross-slot interval-sets to `bins` and bump `slotCount` by the
    // number of cross slots the bin contributes.  Returns false if any part isn't statically
    // enumerable (the caller then falls back to the always-safe Normal-slot count).  A non-array
    // bin is one slot covering the union of its intervals; an array bin contributes one
    // single-value slot per element value (mirroring how it lowers to b[0]..b[N-1]).
    bool appendBinCrossSlots(AstCoverBin* cbinp, uint64_t maxVal,
                             std::vector<std::vector<std::pair<uint64_t, uint64_t>>>& bins,
                             int& slotCount) {
        if (cbinp->isArray()) return appendArrayBinCrossSlots(cbinp, bins, slotCount);
        // Non-array bin: one slot covering the union of its intervals.
        ++slotCount;
        std::vector<std::pair<uint64_t, uint64_t>> ivs;
        if (cbinp->isWildcard() || !extractRangeIntervals(cbinp, maxVal, ivs)) return false;
        bins.push_back(std::move(ivs));
        return true;
    }

    // Append the cross slots of an array Normal bin: each element value is its own single-value
    // Normal bin.  '$'-bounded or non-constant elements can't be enumerated, so they count one
    // slot but lose exactness.  Returns false if any element wasn't enumerable to exact values.
    bool appendArrayBinCrossSlots(AstCoverBin* cbinp,
                                  std::vector<std::vector<std::pair<uint64_t, uint64_t>>>& bins,
                                  int& slotCount) {
        bool exact = true;
        for (AstNode* rp = cbinp->rangesp(); rp; rp = rp->nextp()) {
            RangeBounds rb;
            if (!constRangeBounds(rp, rb) || rb.loUnbounded() || rb.hiUnbounded()) {
                ++slotCount;
                exact = false;
            } else if (rb.loNodep == rb.hiNodep) {  // single Const element (both alias one node)
                ++slotCount;
                bins.push_back({{rb.loConstp()->toUQuad(), rb.loConstp()->toUQuad()}});
            } else {  // [lo:hi] range: one single-value slot per enumerated value
                for (int64_t v = rb.loConstp()->toSInt(); v <= rb.hiConstp()->toSInt(); ++v) {
                    ++slotCount;
                    bins.push_back({{static_cast<uint64_t>(v), static_cast<uint64_t>(v)}});
                }
            }
        }
        return exact;
    }

    // Compute the hit-list bound for a coverpoint: the maximum number of Normal
    // bins one sample value can match.  Non-cross-fed coverpoints don't feed a cross, so
    // their hit list is unused -> 1.  Otherwise compute the exact max bin overlap; fall back
    // to the (always-safe) Normal-slot count when any bin isn't statically analyzable.
    int computeHitListBound(AstCoverpoint* coverpointp, AstNodeExpr* exprp, bool crossFed) {
        if (!crossFed) return 1;
        const int width = exprp->width();
        const uint64_t maxVal = (width >= 64) ? UINT64_MAX : ((1ULL << width) - 1);
        // One entry per Normal bin (cross slot): its covered intervals.
        std::vector<std::vector<std::pair<uint64_t, uint64_t>>> bins;
        int slotCount = 0;  // == runtime m_normal; the safe fallback bound
        bool exact = true;
        for (AstNode* binp = coverpointp->binsp(); binp; binp = binp->nextp()) {
            AstCoverBin* const cbinp = VN_AS(binp, CoverBin);
            if (!cbinp->binsType().binIsNormal())
                continue;  // ignore/illegal/default: not hit-listed
            if (!appendBinCrossSlots(cbinp, maxVal, bins, slotCount)) exact = false;
        }
        if (!exact) return std::max(1, slotCount);
        if (bins.empty()) return 1;
        // Max overlap occurs at some interval start; count covering bins at each lo.
        std::vector<uint64_t> pts;
        for (const auto& b : bins)
            for (const auto& iv : b) pts.push_back(iv.first);
        std::sort(pts.begin(), pts.end());
        pts.erase(std::unique(pts.begin(), pts.end()), pts.end());
        int maxOverlap = 1;
        for (const uint64_t p : pts) {
            int cnt = 0;
            for (const auto& b : bins) {
                for (const auto& iv : b) {
                    if (iv.first <= p && p <= iv.second) {
                        ++cnt;
                        break;  // count each bin at most once
                    }
                }
            }
            if (cnt > maxOverlap) maxOverlap = cnt;
        }
        return maxOverlap;
    }

    // A 'this->m_member' reference for embedding in an AstCStmt
    AstVarRef* memberRef(FileLine* fl, AstVar* varp) {
        AstVarRef* const refp = new AstVarRef{fl, varp, VAccess::READ};
        refp->selfPointer(VSelfPointerText{VSelfPointerText::This{}});
        return refp;
    }

    // Individual equality targets of an array bin (bins b[] = {values/ranges}), in order.
    // An open-ended bound ('$', AstUnbounded) resolves to the coverpoint domain: '[lo:$]'
    // covers [lo:maxVal] and '[$:hi]' covers [0:hi].  One target is produced per value; a
    // range whose resolved size would exceed COVER_BINS_LIMIT (e.g. an open '[lo:$]' over a
    // wide coverpoint) is unsupported -- emits COVERIGN, sets unsupportedOut, yields nothing.
    std::vector<AstNodeExpr*> extractArrayValues(AstCoverBin* arrayBinp, AstNodeExpr* exprp,
                                                 bool& unsupportedOut) {
        unsupportedOut = false;
        const int width = exprp->width();
        const uint64_t maxVal = (width >= 64) ? UINT64_MAX : ((1ULL << width) - 1);
        std::vector<AstNodeExpr*> values;
        for (AstNode* rangep = arrayBinp->rangesp(); rangep; rangep = rangep->nextp()) {
            if (AstInsideRange* const irp = VN_CAST(rangep, InsideRange)) {
                AstNodeExpr* const lhsp = V3Const::constifyEdit(irp->lhsp());
                AstNodeExpr* const rhsp = V3Const::constifyEdit(irp->rhsp());
                const bool loUnb = VN_IS(lhsp, Unbounded);
                const bool hiUnb = VN_IS(rhsp, Unbounded);
                AstConst* const minp = VN_CAST(lhsp, Const);
                AstConst* const maxp = VN_CAST(rhsp, Const);
                if ((!minp && !loUnb) || (!maxp && !hiUnb)) {
                    arrayBinp->v3error("Non-constant expression in array bins range; "
                                       "range bounds must be constants");
                    return values;
                }
                if ((minp && minp->num().isFourState()) || (maxp && maxp->num().isFourState())) {
                    arrayBinp->v3error("Four-state (x/z) value in array bins range bound; "
                                       "range bounds must be two-state constants");
                    return values;
                }
                const uint64_t lo = loUnb ? 0 : minp->toUQuad();
                const uint64_t hi = hiUnb ? maxVal : maxp->toUQuad();
                if (hi < lo) continue;  // empty range contributes no bins
                // Guard against a '$'-bounded or otherwise huge range exploding the bin count.
                const uint64_t span = hi - lo;  // == valueCount - 1 (no overflow: hi >= lo)
                if (span >= static_cast<uint64_t>(COVER_BINS_LIMIT)
                    || values.size() + span + 1 > static_cast<uint64_t>(COVER_BINS_LIMIT)) {
                    arrayBinp->v3warn(COVERIGN, "Unsupported: array 'bins' covering more than "
                                                    << COVER_BINS_LIMIT
                                                    << " values (e.g. an open '[lo:$]' range over "
                                                       "a wide coverpoint); bin ignored");
                    unsupportedOut = true;
                    for (AstNodeExpr* const vp : values) VL_DO_DANGLING(pushDeletep(vp), vp);
                    values.clear();
                    return values;
                }
                for (uint64_t v = lo; v <= hi; ++v)
                    values.push_back(new AstConst{irp->fileline(), AstConst::WidthedValue{}, width,
                                                  static_cast<uint32_t>(v)});
            } else if (VN_IS(rangep, Const)) {
                values.push_back(VN_AS(rangep->cloneTree(false), NodeExpr));
            } else {
                arrayBinp->v3error("Non-constant expression in array bins value list; "
                                   "values must be constants");
                return values;
            }
        }
        return values;
    }

    // Emit a 'this->m_cp.addSingleNamer/addArrayNamer(...)' statement for one bin
    AstCStmt* makeNamer(AstVar* cpVarp, AstCoverBin* binp, int count) {
        FileLine* const fl = binp->fileline();
        AstCStmt* const cs = new AstCStmt{fl};
        cs->add(memberRef(fl, cpVarp));
        // Under --protect-ids the filename and bin name flow into the coverage database
        // verbatim, so obfuscate them exactly as line/toggle coverage points are (whole-
        // unit filename, per-word bin name).  A no-op when --protect-ids is off.
        const bool prot = v3Global.opt.protectIds();
        const std::string loc = "\"" + VIdProtect::protectIf(fl->filename(), prot) + "\", "
                                + std::to_string(fl->lineno()) + ", "
                                + std::to_string(fl->firstColumn()) + ");";
        const std::string binName = VIdProtect::protectWordsIf(binp->name(), prot);
        if (count < 0) {  // single bin
            cs->add(".addSingleNamer(" + std::string{binp->binsType().binSetEnum()} + ", \""
                    + binName + "\", " + loc);
        } else {  // value array bin
            cs->add(".addArrayNamer(" + std::string{binp->binsType().binSetEnum()} + ", "
                    + std::to_string(count) + ", \"" + binName + "\", " + loc);
        }
        return cs;
    }

    // Emit 'if (iff && cond) m_cp.incrementBin(idx);' (or recordHit, + illegal action) in sample()
    // Where a bin's hit is recorded in the runtime VlCoverpoint member.
    struct ConvBinTarget final {
        AstVar* cpVarp;  // the __Vcp_<coverpoint> member
        int idx;  // bin index within that coverpoint
        bool isNormal;  // Normal -> incrementBin (count + cross hit list); else recordHit (count)
    };

    // Emit 'this->m_cp.incrementBin(idx);' (Normal) or '.recordHit(idx);'
    // (ignore/illegal/default).
    AstNodeStmt* makeRuntimeBinHit(FileLine* fl, const ConvBinTarget& tgt) {
        AstCStmt* const cs = new AstCStmt{fl};
        cs->add(memberRef(fl, tgt.cpVarp));
        cs->add((tgt.isNormal ? ".incrementBin(" : ".recordHit(") + std::to_string(tgt.idx)
                + ");");
        return cs;
    }

    void emitConvHitIf(AstCoverpoint* coverpointp, AstCoverBin* binp, AstVar* cpVarp, int idx,
                       AstNodeExpr* condp) {
        FileLine* const fl = binp->fileline();
        AstNode* actionp = makeRuntimeBinHit(fl, {cpVarp, idx, binp->binsType().binIsNormal()});
        if (binp->binsType() == VCoverBinsType::BINS_ILLEGAL) {
            actionp->addNext(makeIllegalBinAction(fl, "Illegal bin " + binp->prettyNameQ()
                                                          + " hit in coverpoint "
                                                          + coverpointp->prettyNameQ()));
        }
        AstNodeExpr* const guardedp = applyCoverpointIffCondition(coverpointp, fl, condp);
        UASSERT_OBJ(m_sampleFuncp, binp, "sample() CFunc not set for coverpoint");
        m_sampleFuncp->addStmtsp(new AstIf{fl, guardedp, actionp, nullptr});
    }

    // Emit a transition bin's hit action into sample():
    //   if (iff && cond) { m_cp.incrementBin/recordHit(idx); [illegal: $error; $stop] }
    // Used by the transition generators so a completed sequence records into the runtime bin.
    void addConvTransHitIf(AstCoverpoint* coverpointp, AstCoverBin* binp, const ConvBinTarget& tgt,
                           AstNodeExpr* condp) {
        FileLine* const fl = binp->fileline();
        AstNode* actionp = makeRuntimeBinHit(fl, tgt);
        if (binp->binsType() == VCoverBinsType::BINS_ILLEGAL) {
            actionp->addNext(makeIllegalBinAction(
                fl, "Illegal transition bin " + binp->prettyNameQ() + " hit in coverpoint "
                        + coverpointp->prettyNameQ()));
        }
        AstNodeExpr* const guardedp = applyCoverpointIffCondition(coverpointp, fl, condp);
        UASSERT_OBJ(m_sampleFuncp, binp, "sample() CFunc not set for transition bin");
        m_sampleFuncp->addStmtsp(new AstIf{fl, guardedp, actionp, nullptr});
    }

    // Route a coverpoint through a VlCoverpoint member: emit the member, its sample()
    // increments, the constructor configuration (init + namers), and registration.
    void generateCoverpoint(AstCoverpoint* coverpointp, AstNodeExpr* exprp, int atLeastValue) {
        FileLine* const fl = coverpointp->fileline();
        UINFO(4, "  Generating VlCoverpoint member: " << coverpointp->name());

        if (AstNodeExpr* const iffp = coverpointp->iffp()) {
            coverpointp->iffp(
                captureIffToTemp(iffp, "__VcpIff_" + sanitizeGeneratedName(coverpointp->name())));
        }

        // Size the hit list to the gen-time max bin overlap (1 unless cross-fed with
        // overlapping ranges), so no cross hit is ever dropped and storage is minimal.
        const bool crossFed = m_crossedCpNames.count(coverpointp->name()) != 0;
        const int hitBound = computeHitListBound(coverpointp, exprp, crossFed);
        UINFO(6, "    Hit-list bound (max bin overlap) = " << hitBound);
        AstVar* const cpVarp = new AstVar{fl, VVarType::MEMBER, "__Vcp_" + coverpointp->name(),
                                          vlCoverpointType(fl, hitBound)};
        cpVarp->isStatic(false);
        m_covergroupp->addMembersp(cpVarp);
        m_cpVars.push_back(cpVarp);
        m_cpVarMap[coverpointp->name()] = cpVarp;

        // A cross reads this coverpoint's hit list, so clear it at the start of the
        // coverpoint's sample() contribution (before any incrementBin appends to it).
        if (crossFed) {
            AstCStmt* const clrp = new AstCStmt{fl};
            clrp->add(memberRef(fl, cpVarp));
            clrp->add(".clearHitList();");
            UASSERT_OBJ(m_sampleFuncp, coverpointp, "sample() CFunc not set for clearHitList");
            m_sampleFuncp->addStmtsp(clrp);
        }

        // Walk bins (non-default, then default), assigning sequential indices that match the
        // namer append order; emit sample increments and collect namer statements.
        std::vector<AstCStmt*> namerStmts;
        std::vector<AstCoverBin*> defaultBins;
        int idx = 0;
        for (AstNode* binp = coverpointp->binsp(); binp; binp = binp->nextp()) {
            AstCoverBin* const cbinp = VN_AS(binp, CoverBin);
            if (cbinp->binsType() == VCoverBinsType::BINS_DEFAULT) {
                defaultBins.push_back(cbinp);
                continue;
            }
            if (cbinp->transp()) {
                // Transition bin (incl. array transition 'bins t[] = (a=>b),(c=>d)' and
                // illegal_bins/ignore_bins transitions).  All sequences of one transition bin
                // share a bin name and merge in the coverage DB to a single point, so model
                // them as one runtime bin incremented by any matching sequence.  The sequence
                // matching is generated as a state machine, with the hit routed to this bin's
                // runtime slot.
                namerStmts.push_back(makeNamer(cpVarp, cbinp, -1));
                const ConvBinTarget tgt{cpVarp, idx, cbinp->binsType().binIsNormal()};
                for (AstNode* sp = cbinp->transp(); sp; sp = sp->nextp())
                    generateSingleTransitionCode(coverpointp, cbinp, exprp, tgt,
                                                 VN_AS(sp, CoverTransSet));
                ++idx;
                continue;
            }
            if (cbinp->isArray()) {  // value array: bins b[N] = {...} -> b[0]..b[N-1]
                bool unsupported = false;
                std::vector<AstNodeExpr*> values = extractArrayValues(cbinp, exprp, unsupported);
                if (unsupported) continue;  // bin ignored (COVERIGN emitted); reserve no slot
                namerStmts.push_back(makeNamer(cpVarp, cbinp, static_cast<int>(values.size())));
                for (AstNodeExpr* valuep : values) {
                    // TODO: A 4-state bin value (e.g. bins b[] = {2'b0x}) must match with ===
                    // (AstEqCase) per IEEE 1800-2023 19.5.4. == is equivalent under 2-state sim
                    // (x/z collapse to 0); switch to AstEqCase when 4-state sim support lands.
                    emitConvHitIf(coverpointp, cbinp, cpVarp, idx++,
                                  new AstEq{cbinp->fileline(), exprp->cloneTree(false), valuep});
                }
            } else {
                namerStmts.push_back(makeNamer(cpVarp, cbinp, -1));
                // buildBinCondition is null for 'ignore_bins = default' (no ranges); the bin
                // still gets a reserved slot (recorded, never incremented).
                if (AstNodeExpr* const condp = buildBinCondition(cbinp, exprp))
                    emitConvHitIf(coverpointp, cbinp, cpVarp, idx, condp);
                ++idx;
            }
        }
        for (AstCoverBin* const defBinp : defaultBins) {
            namerStmts.push_back(makeNamer(cpVarp, defBinp, -1));
            emitConvHitIf(coverpointp, defBinp, cpVarp, idx++,
                          buildDefaultCondition(coverpointp, exprp, defBinp->fileline()));
        }

        // Transition coverpoints track the previous sampled value; update it once at the end of
        // this coverpoint's sample() contribution (the prev var was created on demand by the
        // transition matching above).
        if (coverpointHasTransition(coverpointp)) {
            AstVar* const prevVarp = VN_AS(coverpointp->user1p(), Var);
            m_sampleFuncp->addStmtsp(
                new AstAssign{coverpointp->fileline(),
                              new AstVarRef{prevVarp->fileline(), prevVarp, VAccess::WRITE},
                              exprp->cloneTree(false)});
        }

        // Constructor: init (allocates), namers, then registration (under --coverage).
        // Under --protect-ids the hierarchy and page string reach the coverage database
        // verbatim, so obfuscate them like line/toggle points (per-word hierarchy, whole-
        // unit page).  No-ops when --protect-ids is off.
        const bool prot = v3Global.opt.protectIds();
        const std::string hier
            = VIdProtect::protectWordsIf(m_covergroupp->name() + "." + coverpointp->name(), prot);
        AstCStmt* const initp = new AstCStmt{fl};
        initp->add(memberRef(fl, cpVarp));
        initp->add(".init(\"" + hier + "\", " + std::to_string(atLeastValue) + ", "
                   + std::to_string(idx) + ");");
        m_constructorp->addStmtsp(initp);
        for (AstCStmt* const ns : namerStmts) m_constructorp->addStmtsp(ns);
        if (v3Global.opt.coverage()) {
            const std::string page
                = VIdProtect::protectIf("v_covergroup/" + m_covergroupp->name(), prot);
            AstCStmt* const regp = new AstCStmt{fl};
            regp->add(memberRef(fl, cpVarp));
            regp->add(".registerBins(vlSymsp->_vm_contextp__->coveragep(), \"" + page + "\");");
            m_constructorp->addStmtsp(regp);
        }
    }

    // Generate state machine code for multi-value transition sequences
    // Handles transitions like (1 => 2 => 3 => 4)
    void generateMultiValueTransitionCode(AstCoverpoint* coverpointp, AstCoverBin* binp,
                                          AstNodeExpr* exprp, const ConvBinTarget& tgt,
                                          const std::vector<AstCoverTransItem*>& items) {
        UINFO(4, "    Generating multi-value transition state machine for: " << binp->name());
        UINFO(4, "      Sequence length: " << items.size() << " items");

        // Create state position variable
        AstVar* const stateVarp = createSequenceStateVar(coverpointp, binp);

        // Build case statement with N cases (one for each state 0 to N-1)
        // State 0: Not started, looking for first item
        // State 1 to N-1: In progress, looking for next item

        AstCase* const casep
            = new AstCase{binp->fileline(), VCaseType::CT_CASE,
                          new AstVarRef{stateVarp->fileline(), stateVarp, VAccess::READ}, nullptr};

        // Generate each case item in the switch statement
        for (size_t state = 0; state < items.size(); ++state) {
            AstCaseItem* caseItemp = generateTransitionStateCase(coverpointp, binp, exprp, tgt,
                                                                 stateVarp, items, state);
            casep->addItemsp(caseItemp);
        }

        // Add default case (reset to state 0) to prevent CASEINCOMPLETE warnings,
        // since the state variable is wider than the number of valid states.
        AstCaseItem* const defaultItemp = new AstCaseItem{
            binp->fileline(), nullptr,
            new AstAssign{binp->fileline(),
                          new AstVarRef{binp->fileline(), stateVarp, VAccess::WRITE},
                          new AstConst{binp->fileline(), AstConst::WidthedValue{}, 8, 0}}};
        casep->addItemsp(defaultItemp);

        m_sampleFuncp->addStmtsp(casep);
        UINFO(4, "      Successfully added multi-value transition state machine");
    }

    // Generate code for a single state in the transition state machine
    // Returns the case item for this state
    AstCaseItem* generateTransitionStateCase(AstCoverpoint* coverpointp, AstCoverBin* binp,
                                             AstNodeExpr* exprp, const ConvBinTarget& tgt,
                                             AstVar* stateVarp,
                                             const std::vector<AstCoverTransItem*>& items,
                                             size_t state) {
        FileLine* const fl = binp->fileline();

        // Build condition for current value matching expected item at this state
        AstNodeExpr* matchCondp = buildTransitionItemCondition(items[state], exprp);

        // Apply iff condition if present
        if (AstNodeExpr* iffp = coverpointp->iffp()) {
            matchCondp = new AstAnd{fl, iffp->cloneTree(false), matchCondp};
        }

        AstNodeStmt* matchActionp = nullptr;

        if (state == items.size() - 1) {
            // Last state: sequence complete!  Record the hit in the runtime VlCoverpoint.
            matchActionp = makeRuntimeBinHit(fl, tgt);

            // For illegal_bins, add error message
            if (binp->binsType() == VCoverBinsType::BINS_ILLEGAL) {
                const string errMsg = "Illegal transition bin " + binp->prettyNameQ()
                                      + " hit in coverpoint " + coverpointp->prettyNameQ();
                matchActionp = matchActionp->addNext(makeIllegalBinAction(fl, errMsg));
            }

            // Reset state to 0
            matchActionp = matchActionp->addNext(
                new AstAssign{fl, new AstVarRef{fl, stateVarp, VAccess::WRITE},
                              new AstConst{fl, AstConst::WidthedValue{}, 8, 0}});
        } else {
            // Intermediate state: advance to next state
            matchActionp = new AstAssign{
                fl, new AstVarRef{fl, stateVarp, VAccess::WRITE},
                new AstConst{fl, AstConst::WidthedValue{}, 8, static_cast<uint32_t>(state + 1)}};
        }

        // Build restart logic: check if current value matches first item
        // If so, restart sequence from state 1 (even if we're in middle of sequence)
        AstNodeStmt* noMatchActionp = nullptr;
        if (state > 0) {
            // Check if current value matches first item (restart condition)
            AstNodeExpr* restartCondp = buildTransitionItemCondition(items[0], exprp);

            UASSERT_OBJ(restartCondp, items[0],
                        "buildTransitionItemCondition returned nullptr for restart");
            // Apply iff condition
            if (AstNodeExpr* iffp = coverpointp->iffp()) {
                restartCondp = new AstAnd{fl, iffp->cloneTree(false), restartCondp};
            }

            // Restart to state 1
            AstNodeStmt* restartActionp
                = new AstAssign{fl, new AstVarRef{fl, stateVarp, VAccess::WRITE},
                                new AstConst{fl, AstConst::WidthedValue{}, 8, 1}};

            // Reset to state 0 (else branch)
            AstNodeStmt* resetActionp
                = new AstAssign{fl, new AstVarRef{fl, stateVarp, VAccess::WRITE},
                                new AstConst{fl, AstConst::WidthedValue{}, 8, 0}};

            noMatchActionp = new AstIf{fl, restartCondp, restartActionp, resetActionp};
        }
        // For state 0, no action needed if no match (stay in state 0)

        // Combine into if-else
        AstNodeStmt* const stmtp = new AstIf{fl, matchCondp, matchActionp, noMatchActionp};

        // Create case item for this state value
        AstCaseItem* const caseItemp = new AstCaseItem{
            fl, new AstConst{fl, AstConst::WidthedValue{}, 8, static_cast<uint32_t>(state)},
            stmtp};

        return caseItemp;
    }

    // Create: $error(msg); $stop;  Used when an illegal bin is hit.
    AstNodeStmt* makeIllegalBinAction(FileLine* fl, const string& errMsg) {
        AstDisplay* const errorp
            = new AstDisplay{fl, VDisplayType::DT_ERROR, errMsg, nullptr, nullptr};
        errorp->fmtp()->timeunit(m_covergroupp->timeunit());
        static_cast<AstNode*>(errorp)->addNext(new AstStop{fl, true});
        return errorp;
    }

    // Clone a constant node, widening to targetWidth if needed (zero-extend).
    // Used to ensure comparisons use matching widths after V3Width has run.
    static AstConst* widenConst(FileLine* fl, AstConst* constp, int targetWidth) {
        if (constp->width() == targetWidth) return constp->cloneTree(false);
        V3Number num{fl, targetWidth, 0};
        num.opAssign(constp->num());
        return new AstConst{fl, num};
    }

    // Build a range condition: minp <= exprp <= maxp.
    // Uses signed comparisons if exprp is signed; omits trivially-true bounds for unsigned.
    // All arguments are non-owning; clones exprp/minp/maxp as needed.
    AstNodeExpr* makeRangeCondition(FileLine* fl, AstNodeExpr* exprp, AstNodeExpr* minp,
                                    AstNodeExpr* maxp) {
        const int exprWidth = exprp->widthMin();
        AstConst* const minConstp = VN_AS(minp, Const);
        AstConst* const maxConstp = VN_AS(maxp, Const);
        // Widen constants to match expression width so post-V3Width nodes use correct macros
        AstConst* const minWidep = widenConst(fl, minConstp, exprWidth);
        AstConst* const maxWidep = widenConst(fl, maxConstp, exprWidth);
        if (exprp->isSigned()) {
            return new AstAnd{fl, new AstGteS{fl, exprp->cloneTree(false), minWidep},
                              new AstLteS{fl, exprp->cloneTree(false), maxWidep}};
        }
        // Unsigned: skip bounds that are trivially satisfied for the expression width
        const bool skipLowerCheck = (minConstp->toUQuad() == 0);
        bool skipUpperCheck = false;
        if (exprWidth <= 64) {
            const uint64_t maxVal
                = (exprWidth == 64) ? ~static_cast<uint64_t>(0) : ((1ULL << exprWidth) - 1ULL);
            skipUpperCheck = (maxConstp->toUQuad() == maxVal);
        }
        if (skipLowerCheck && skipUpperCheck) {
            VL_DO_DANGLING(pushDeletep(minWidep), minWidep);
            VL_DO_DANGLING(pushDeletep(maxWidep), maxWidep);
            return new AstConst{fl, AstConst::BitTrue{}};
        } else if (skipLowerCheck) {
            VL_DO_DANGLING(pushDeletep(minWidep), minWidep);
            return new AstLte{fl, exprp->cloneTree(false), maxWidep};
        } else if (skipUpperCheck) {
            VL_DO_DANGLING(pushDeletep(maxWidep), maxWidep);
            return new AstGte{fl, exprp->cloneTree(false), minWidep};
        } else {
            return new AstAnd{fl, new AstGte{fl, exprp->cloneTree(false), minWidep},
                              new AstLte{fl, exprp->cloneTree(false), maxWidep}};
        }
    }

    // Build a one-sided comparison for an open-ended bin range whose other bound is '$'.
    // '$' denotes the coverpoint domain extreme, so {[lo:$]} == (expr >= lo) and
    // {[$:hi]} == (expr <= hi).
    AstNodeExpr* makeOpenRangeCondition(FileLine* fl, AstNodeExpr* exprp, AstConst* boundp,
                                        bool isLowerBound) {
        AstConst* const widep = widenConst(fl, boundp, exprp->widthMin());
        if (isLowerBound) {
            if (exprp->isSigned()) return new AstGteS{fl, exprp->cloneTree(false), widep};
            return new AstGte{fl, exprp->cloneTree(false), widep};
        }
        if (exprp->isSigned()) return new AstLteS{fl, exprp->cloneTree(false), widep};
        return new AstLte{fl, exprp->cloneTree(false), widep};
    }

    // Build condition for a single transition item.
    // Returns expression that checks if exprp matches the item's value/range list.
    // Overload for when the expression is a variable read -- creates and manages the VarRef
    // internally, so callers don't need to construct a temporary node.
    AstNodeExpr* buildTransitionItemCondition(AstCoverTransItem* itemp, AstVar* varp) {
        AstNodeExpr* varRefp = new AstVarRef{varp->fileline(), varp, VAccess::READ};
        AstNodeExpr* const condp = buildTransitionItemCondition(itemp, varRefp);
        VL_DO_DANGLING(pushDeletep(varRefp), varRefp);
        return condp;
    }

    // Non-owning: exprp is cloned internally; caller retains ownership of exprp.
    AstNodeExpr* buildTransitionItemCondition(AstCoverTransItem* itemp, AstNodeExpr* exprp) {
        AstNodeExpr* condp = nullptr;

        for (AstNode* valp = itemp->valuesp(); valp; valp = valp->nextp()) {
            AstNodeExpr* singleCondp = nullptr;

            AstConst* const constp = VN_AS(valp, Const);
            singleCondp
                = new AstEq{constp->fileline(), exprp->cloneTree(false), constp->cloneTree(false)};

            if (condp) {
                condp = new AstOr{itemp->fileline(), condp, singleCondp};
            } else {
                condp = singleCondp;
            }
        }

        return condp;
    }

    // Generate code for a single transition sequence (used by both regular and array bins)
    void generateSingleTransitionCode(AstCoverpoint* coverpointp, AstCoverBin* binp,
                                      AstNodeExpr* exprp, const ConvBinTarget& tgt,
                                      AstCoverTransSet* transSetp) {
        UINFO(4, "      Generating code for transition sequence");

        // Get or create previous value variable
        AstVar* const prevVarp = createPrevValueVar(coverpointp, exprp);

        UASSERT_OBJ(
            transSetp, binp,
            "Transition bin has no transition set (transp() was checked before calling this)");

        // Get transition items (the sequence: item1 => item2 => item3)
        std::vector<AstCoverTransItem*> items;
        for (AstNode* itemp = transSetp->itemsp(); itemp; itemp = itemp->nextp())
            items.push_back(VN_AS(itemp, CoverTransItem));

        if (items.empty()) {
            binp->v3error("Transition set without items");
            return;
        }

        if (items.size() == 1) {
            // Single item transition not valid (need at least 2 values for =>)
            binp->v3error("Transition requires at least two values");
            return;
        } else if (items.size() == 2) {
            // Simple two-value transition: (val1 => val2)
            // Use optimized direct comparison (no state machine needed)
            AstNodeExpr* const cond1p = buildTransitionItemCondition(items[0], prevVarp);
            AstNodeExpr* const cond2p = buildTransitionItemCondition(items[1], exprp);

            // Combine: prev matches val1 AND current matches val2
            AstNodeExpr* fullCondp = new AstAnd{binp->fileline(), cond1p, cond2p};

            addConvTransHitIf(coverpointp, binp, tgt, fullCondp);

            UINFO(4, "        Successfully added 2-value transition if statement");
        } else {
            // Multi-value sequence (a => b => c => ...)
            // Use state machine to track position in sequence
            generateMultiValueTransitionCode(coverpointp, binp, exprp, tgt, items);
        }
    }

    // Append a "{ VlCoverpoint* __Vcx_cps[] = {&cp0, &cp1, ...}; <member>.<call> }" statement.
    AstCStmt* makeCrossCpsCall(FileLine* fl, const std::vector<AstVar*>& cpVars, AstVar* cxVarp,
                               const std::string& callText) {
        AstCStmt* const cs = new AstCStmt{fl};
        cs->add("{ VlCoverpoint* __Vcx_cps[] = {");
        for (size_t d = 0; d < cpVars.size(); ++d) {
            cs->add(d == 0 ? "&" : ", &");
            cs->add(memberRef(fl, cpVars[d]));
        }
        cs->add("}; ");
        cs->add(memberRef(fl, cxVarp));
        cs->add(callText);
        cs->add(" }");
        return cs;
    }

    // Route a cross through a VlCoverCross member: emit the member, its constructor init +
    // registration, and the sample() call.  The feeding coverpoints are already generated
    // (their hit lists drive the cross), so only O(1) generated code is needed here.
    void generateCross(AstCoverCross* crossp) {
        FileLine* const fl = crossp->fileline();
        UINFO(4, "  Generating VlCoverCross member: " << crossp->name());

        if (AstNodeExpr* const iffp = crossp->iffp()) {
            crossp->iffp(
                captureIffToTemp(iffp, "__VcrossIff_" + sanitizeGeneratedName(crossp->name())));
        }

        // Resolve and unlink the coverpoint refs, in dimension order.  Every ref resolves to a
        // known coverpoint (a cross with an unresolvable item was dropped earlier).
        std::vector<AstVar*> cpVars;
        for (AstNode* itemp = crossp->itemsp(); itemp;) {
            AstNode* const nextp = itemp->nextp();
            AstCoverpointRef* const refp = VN_AS(itemp, CoverpointRef);
            const auto it = m_cpVarMap.find(refp->name());
            UASSERT_OBJ(it != m_cpVarMap.end(), crossp, "Cross references an unknown coverpoint");
            cpVars.push_back(it->second);
            VL_DO_DANGLING(pushDeletep(refp->unlinkFrBack()), refp);
            itemp = nextp;
        }
        const int dims = static_cast<int>(cpVars.size());

        if (!m_vlCoverCrossDTypep) {
            m_vlCoverCrossDTypep = new AstCDType{fl, "VlCoverCross"};
            v3Global.rootp()->typeTablep()->addTypesp(m_vlCoverCrossDTypep);
        }
        AstVar* const cxVarp
            = new AstVar{fl, VVarType::MEMBER, "__Vcx_" + crossp->name(), m_vlCoverCrossDTypep};
        cxVarp->isStatic(false);
        m_covergroupp->addMembersp(cxVarp);
        m_crossVars.push_back(cxVarp);

        // Constructor: init (after the coverpoints, which generate earlier) then registration.
        // Obfuscate the hierarchy/filename/page under --protect-ids as for coverpoints above.
        const bool prot = v3Global.opt.protectIds();
        const std::string hier
            = VIdProtect::protectWordsIf(m_covergroupp->name() + "." + crossp->name(), prot);
        const std::string initCall
            = ".init(\"" + hier + "\", " + std::to_string(dims) + ", __Vcx_cps, \""
              + VIdProtect::protectIf(fl->filename(), prot) + "\", " + std::to_string(fl->lineno())
              + ", " + std::to_string(fl->firstColumn()) + ");";
        m_constructorp->addStmtsp(makeCrossCpsCall(fl, cpVars, cxVarp, initCall));
        if (v3Global.opt.coverage()) {
            const std::string page
                = VIdProtect::protectIf("v_covergroup/" + m_covergroupp->name(), prot);
            AstCStmt* const regp = new AstCStmt{fl};
            regp->add(memberRef(fl, cxVarp));
            regp->add(".registerBins(vlSymsp->_vm_contextp__->coveragep(), \"" + page + "\");");
            m_constructorp->addStmtsp(regp);
        }

        // sample(): after all coverpoints have sampled (cross loop runs after coverpoint loop).
        UASSERT_OBJ(m_sampleFuncp, crossp, "sample() CFunc not set for cross");
        AstNodeStmt* const samplep = makeCrossCpsCall(fl, cpVars, cxVarp, ".sample(__Vcx_cps);");
        if (AstNodeExpr* const iffp = crossp->iffp()) {
            m_sampleFuncp->addStmtsp(new AstIf{fl, iffp->cloneTree(false), samplep});
        } else {
            m_sampleFuncp->addStmtsp(samplep);
        }
    }

    void generateCrossCode(AstCoverCross* crossp) {
        UINFO(4, "  Generating code for cross: " << crossp->name());

        // Non-standard hierarchical/dotted cross item (e.g. 'cross a.b'): an implicit coverpoint
        // over the referenced expression (carried in refp->exprp()).  The grammar already warned
        // NONSTD; implicit coverpoints are not yet implemented, so generate no sampling code for
        // this cross.  When support is added the implicit coverpoint should be synthesized
        // upstream (V3LinkParse) as a real AstCoverpoint so it flows through the normal coverpoint
        // path - by here coverpoint lowering has already run.
        for (AstNode* itemp = crossp->itemsp(); itemp; itemp = itemp->nextp()) {
            const AstCoverpointRef* const refp = VN_AS(itemp, CoverpointRef);
            if (refp->exprp()) {
                refp->v3warn(COVERIGN,
                             "Unsupported: cross of hierarchical reference (implicit coverpoint)");
                return;
            }
        }

        // A cross naming a bare variable (implicit coverpoint, which Verilator does not
        // synthesize) is dropped entirely with a COVERIGN warning -- it produces no coverage
        // either way -- but only this cross is dropped; its sibling crosses are still generated
        // and the real coverpoints it referenced remain as independent coverpoints.
        if (m_droppedCrosses.count(crossp)) {
            for (AstNode* itemp = crossp->itemsp(); itemp; itemp = itemp->nextp()) {
                const AstCoverpointRef* const refp = VN_AS(itemp, CoverpointRef);
                if (m_coverpointMap.find(refp->name()) == m_coverpointMap.end()) {
                    refp->v3warn(COVERIGN, "Unsupported: cross of "
                                               << refp->prettyNameQ()
                                               << " which is not a coverpoint (implicit "
                                                  "coverpoint)");
                    break;
                }
            }
            return;
        }

        // Every cross that isn't dropped routes through a VlCoverCross member.
        generateCross(crossp);
    }

    AstNodeExpr* buildBinCondition(AstCoverBin* binp, AstNodeExpr* exprp) {
        // Get the range list from the bin
        AstNode* const rangep = binp->rangesp();
        if (!rangep) return nullptr;

        // Check if this is a wildcard bin
        const bool isWildcard = binp->isWildcard();

        // Build condition by OR-ing all ranges together
        AstNodeExpr* fullCondp = nullptr;

        for (AstNode* currRangep = rangep; currRangep; currRangep = currRangep->nextp()) {
            AstNodeExpr* rangeCondp = nullptr;

            if (AstInsideRange* irp = VN_CAST(currRangep, InsideRange)) {
                AstNodeExpr* const minExprp = irp->lhsp();
                AstNodeExpr* const maxExprp = irp->rhsp();
                AstConst* const minConstp = VN_CAST(minExprp, Const);
                AstConst* const maxConstp = VN_CAST(maxExprp, Const);
                const bool loUnbounded = VN_IS(minExprp, Unbounded);
                const bool hiUnbounded = VN_IS(maxExprp, Unbounded);
                if (loUnbounded || hiUnbounded) {
                    // Open-ended range: '$' is the coverpoint domain min/max, so the
                    // range reduces to a single inequality (e.g. {[10:$]} -> expr >= 10).
                    AstConst* const boundp = hiUnbounded ? minConstp : maxConstp;
                    if (loUnbounded && hiUnbounded) {
                        rangeCondp = new AstConst{irp->fileline(), AstConst::BitTrue{}};
                    } else if (!boundp) {
                        irp->v3error("Non-constant expression in bin range; "
                                     "range bounds must be constants");
                        if (fullCondp) VL_DO_DANGLING(pushDeletep(fullCondp), fullCondp);
                        return nullptr;
                    } else if (boundp->num().isFourState()) {
                        irp->v3error("Four-state (x/z) value in bin range bound; "
                                     "range bounds must be two-state constants");
                        if (fullCondp) VL_DO_DANGLING(pushDeletep(fullCondp), fullCondp);
                        return nullptr;
                    } else {
                        rangeCondp = makeOpenRangeCondition(irp->fileline(), exprp, boundp,
                                                            /*isLowerBound=*/hiUnbounded);
                    }
                } else if (!minConstp || !maxConstp) {
                    irp->v3error("Non-constant expression in bin range; "
                                 "range bounds must be constants");
                    if (fullCondp) VL_DO_DANGLING(pushDeletep(fullCondp), fullCondp);
                    return nullptr;
                } else if (minConstp->num().isFourState() || maxConstp->num().isFourState()) {
                    irp->v3error("Four-state (x/z) value in bin range bound; "
                                 "range bounds must be two-state constants");
                    if (fullCondp) VL_DO_DANGLING(pushDeletep(fullCondp), fullCondp);
                    return nullptr;
                } else if (minConstp->toUQuad() == maxConstp->toUQuad()) {
                    // Single value
                    if (isWildcard) {
                        rangeCondp = buildWildcardCondition(binp, exprp, minConstp);
                    } else {
                        rangeCondp = new AstEq{binp->fileline(), exprp->cloneTree(false),
                                               minExprp->cloneTree(false)};
                    }
                } else {
                    rangeCondp = makeRangeCondition(irp->fileline(), exprp, minExprp, maxExprp);
                }
            } else if (AstConst* constp = VN_CAST(currRangep, Const)) {
                if (isWildcard) {
                    rangeCondp = buildWildcardCondition(binp, exprp, constp);
                } else {
                    // TODO: A 4-state bin value (e.g. bins b = {2'b0x}) must match with ===
                    // (AstEqCase) per IEEE 1800-2023 19.5.4. == is equivalent under 2-state sim
                    // (x/z collapse to 0); switch to AstEqCase when 4-state sim support lands.
                    rangeCondp = new AstEq{binp->fileline(), exprp->cloneTree(false),
                                           constp->cloneTree(false)};
                }
            } else {
                currRangep->v3error(
                    "Non-constant expression in bin range; values must be constants");
                if (fullCondp) VL_DO_DANGLING(pushDeletep(fullCondp), fullCondp);
                return nullptr;
            }

            UASSERT_OBJ(rangeCondp, binp, "rangeCondp is null after building range condition");
            fullCondp
                = fullCondp ? new AstOr{binp->fileline(), fullCondp, rangeCondp} : rangeCondp;
        }

        return fullCondp;
    }

    // Build a wildcard condition: (expr & mask) == (value & mask)
    // where mask has 1s for defined bits and 0s for wildcard bits
    // Non-owning: exprp is cloned internally; caller retains ownership.
    AstNodeExpr* buildWildcardCondition(AstCoverBin* binp, AstNodeExpr* exprp, AstConst* constp) {
        FileLine* const fl = binp->fileline();

        // Extract mask from constant (bits that are not X/Z)
        V3Number mask{constp, constp->width()};
        V3Number value{constp, constp->width()};

        for (int bit = 0; bit < constp->width(); ++bit) {
            if (constp->num().bitIs0(bit) || constp->num().bitIs1(bit)) {
                mask.setBit(bit, 1);
                value.setBit(bit, constp->num().bitIs1(bit) ? 1 : 0);
            } else {
                mask.setBit(bit, 0);
                value.setBit(bit, 0);
            }
        }

        // Generate: (expr & mask) == (value & mask)
        AstConst* const maskConstp = new AstConst{fl, mask};
        AstConst* const valueConstp = new AstConst{fl, value};

        AstNodeExpr* const exprMasked = new AstAnd{fl, exprp->cloneTree(false), maskConstp};
        AstNodeExpr* const valueMasked = new AstAnd{fl, valueConstp, maskConstp->cloneTree(false)};

        // TODO: masking the wildcard (don't-care) bits is correct, but the defined-bit
        // comparison should use === (AstEqCase) per IEEE 1800-2023 19.5.4 once 4-state sim
        // support lands; == is equivalent under 2-state sim (x/z collapse to 0).
        return new AstEq{fl, exprMasked, valueMasked};
    }

    void generateCoverageComputationCode() {
        UINFO(4, "  Generating coverage computation code");

        // Invalidate cache: addMembersp() calls in generateCoverpointCode/generateCrossCode
        // have added new members since the last scan, so clear before re-querying.
        m_memberMap.clear();

        // Find get_coverage() and get_inst_coverage() methods
        AstFunc* const getCoveragep
            = VN_CAST(m_memberMap.findMember(m_covergroupp, "get_coverage"), Func);
        AstFunc* const getInstCoveragep
            = VN_CAST(m_memberMap.findMember(m_covergroupp, "get_inst_coverage"), Func);

        // Generate code for get_inst_coverage() (an empty covergroup returns 100%).
        generateCoverageMethodBody(getInstCoveragep);

        // Generate code for get_coverage() (type-level)
        // NOTE: Full type-level coverage requires instance tracking infrastructure
        // For now, return 0.0 as a placeholder
        AstVar* const coverageReturnVarp = VN_AS(getCoveragep->fvarp(), Var);
        // TODO: Implement proper type-level coverage aggregation
        // This requires tracking all instances and averaging their coverage
        // For now, return 0.0
        getCoveragep->addStmtsp(new AstAssign{
            getCoveragep->fileline(),
            new AstVarRef{getCoveragep->fileline(), coverageReturnVarp, VAccess::WRITE},
            new AstConst{getCoveragep->fileline(), AstConst::RealDouble{}, 0.0}});
        UINFO(4, "    Added placeholder get_coverage() (returns 0.0)");
    }

    void generateCoverageMethodBody(AstFunc* funcp) {
        FileLine* const fl = funcp->fileline();
        AstVar* const returnVarp = VN_AS(funcp->fvarp(), Var);

        // Every coverpoint and cross holds its bins in the runtime (VlCoverpoint/VlCoverCross).
        // Sum their covered/total contributions via coverageParts (Normal bins only; ignore,
        // illegal, and default are excluded per LRM 19.5).  A covergroup with no coverpoints
        // (and hence no crosses) has nothing to cover and reports 100%.
        if (m_cpVars.empty()) {
            funcp->addStmtsp(new AstAssign{fl, new AstVarRef{fl, returnVarp, VAccess::WRITE},
                                           new AstConst{fl, AstConst::RealDouble{}, 100.0}});
            return;
        }
        AstCStmt* const headp = new AstCStmt{fl};
        headp->add("double __Vcov = 0.0; double __Vtot = 0.0;");
        funcp->addStmtsp(headp);
        for (AstVar* const cpVarp : m_cpVars) {
            AstCStmt* const cs = new AstCStmt{fl};
            cs->add("{ double __Vc = 0.0; double __Vt = 0.0; ");
            cs->add(memberRef(fl, cpVarp));
            cs->add(".coverageParts(__Vc, __Vt); __Vcov += __Vc; __Vtot += __Vt; }");
            funcp->addStmtsp(cs);
        }
        // Crosses contribute the same covered/total ratio as their per-tuple bins.
        for (AstVar* const cxVarp : m_crossVars) {
            AstCStmt* const cs = new AstCStmt{fl};
            cs->add("{ double __Vc = 0.0; double __Vt = 0.0; ");
            cs->add(memberRef(fl, cxVarp));
            cs->add(".coverageParts(__Vc, __Vt); __Vcov += __Vc; __Vtot += __Vt; }");
            funcp->addStmtsp(cs);
        }
        AstCStmt* const retp = new AstCStmt{fl};
        retp->add(new AstVarRef{fl, returnVarp, VAccess::WRITE});
        retp->add(" = (__Vtot != 0.0) ? (100.0 * __Vcov / __Vtot) : 100.0;");
        funcp->addStmtsp(retp);
    }

    // VISITORS
    static bool isEnclosingInstanceVar(const AstVar* varp) {
        return varp->isClassMember() && !varp->lifetime().isStatic() && !varp->isParam();
    }

    void rewriteThisRef(AstThisRef* refp, AstVar* handleVarp) {
        const AstClassRefDType* const refDTypep
            = VN_CAST(refp->dtypep()->skipRefp(), ClassRefDType);
        UASSERT_OBJ(refDTypep && refDTypep->classp() == m_covergroupp, refp,
                    "Unexpected this reference in embedded covergroup");
        AstNodeExpr* const newp = new AstVarRef{refp->fileline(), handleVarp, VAccess::READ};
        refp->replaceWith(newp);
        VL_DO_DANGLING(pushDeletep(refp), refp);
    }

    void rewriteVarRef(AstVarRef* refp, AstVar* handleVarp) {
        FileLine* const fl = refp->fileline();
        AstMemberSel* const selp
            = new AstMemberSel{fl, new AstVarRef{fl, handleVarp, VAccess::READ}, refp->varp()};
        selp->access(refp->access());
        refp->replaceWith(selp);
        VL_DO_DANGLING(pushDeletep(refp), refp);
    }

    bool isEmbeddedCovergroupVar(const AstVar* varp) const {
        if (!varp || !varp->isClassMember()) return false;
        const AstClassRefDType* const refp = VN_CAST(varp->dtypep()->skipRefp(), ClassRefDType);
        return refp && refp->classp() == m_covergroupp;
    }

    AstVar* findEmbeddedCovergroupVar() const {
        if (!m_enclosingClassp) return nullptr;
        for (AstNode* itemp = m_enclosingClassp->membersp(); itemp; itemp = itemp->nextp()) {
            if (AstVar* const varp = VN_CAST(itemp, Var)) {
                if (isEmbeddedCovergroupVar(varp)) return varp;
            }
        }
        // V3LinkParse always creates an implicit variable for an embedded covergroup.
        return nullptr;  // LCOV_EXCL_LINE
    }

    std::vector<AstNodeAssign*> findCovergroupConstructions() {
        std::vector<AstNodeAssign*> foundps;
        if (!m_embeddedVarp) return foundps;
        AstFunc* const enclosingNewp
            = VN_CAST(m_memberMap.findMember(m_enclosingClassp, "new"), Func);
        if (!enclosingNewp) return foundps;
        enclosingNewp->foreach([&](AstNodeAssign* asgnp) {
            const AstNew* const newp = VN_CAST(asgnp->rhsp(), New);
            const AstVarRef* const lhsRefp = VN_CAST(asgnp->lhsp(), VarRef);
            if (!newp || !lhsRefp || lhsRefp->varp() != m_embeddedVarp) return;
            const AstClassRefDType* const refp = VN_CAST(newp->dtypep(), ClassRefDType);
            if (refp && refp->classp() == m_covergroupp) foundps.push_back(asgnp);
        });
        return foundps;
    }

    AstNodeAssign* findInvalidEmbeddedCovergroupAssignment() {
        if (!m_embeddedVarp) return nullptr;
        std::set<const AstNodeAssign*> constructorAssignps;
        AstFunc* const enclosingNewp
            = VN_CAST(m_memberMap.findMember(m_enclosingClassp, "new"), Func);
        if (enclosingNewp) {
            enclosingNewp->foreach([&](AstNodeAssign* asgnp) {
                const AstVarRef* const refp = VN_CAST(asgnp->lhsp(), VarRef);
                if (refp && refp->varp() == m_embeddedVarp) constructorAssignps.insert(asgnp);
            });
        }
        AstNodeAssign* invalidp = nullptr;
        m_enclosingClassp->foreach([&](AstNodeAssign* asgnp) {
            if (invalidp || constructorAssignps.count(asgnp)) return;
            const AstVarRef* const refp = VN_CAST(asgnp->lhsp(), VarRef);
            if (refp && refp->varp() == m_embeddedVarp) invalidp = asgnp;
        });
        return invalidp;
    }

    std::set<const AstVar*> enclosingInstanceVars() const {
        std::set<const AstVar*> vars;
        if (m_enclosingClassp) {
            m_enclosingClassp->foreachMember([&](AstClass* const, AstVar* const varp) {
                if (isEnclosingInstanceVar(varp)) vars.insert(varp);
            });
        }
        return vars;
    }

    bool hasEnclosingEventRef(AstCovergroup* cgp) const {
        if (!m_embeddedVarp || !cgp->eventp()) return false;
        const std::set<const AstVar*> enclosingVars = enclosingInstanceVars();
        bool found = false;
        cgp->eventp()->foreach([&](AstVarRef* refp) {
            if (enclosingVars.count(refp->varp())) found = true;
        });
        return found;
    }

    static bool parseEmbeddedEventExpr(AstNodeExpr* exprp, AstVar*& baseVarp,
                                       AstVar*& memberVarp) {
        if (AstVarRef* const refp = VN_CAST(exprp, VarRef)) {
            baseVarp = refp->varp();
            memberVarp = nullptr;
            return true;
        }
        AstMemberSel* const selp = VN_CAST(exprp, MemberSel);
        if (!selp) return false;
        AstVarRef* const baseRefp = VN_CAST(selp->fromp(), VarRef);
        if (!baseRefp) return false;
        baseVarp = baseRefp->varp();
        memberVarp = selp->varp();
        return true;
    }

    bool isEventLvalue(AstNodeExpr* exprp, const EmbeddedEventTrigger& trigger) const {
        if (AstSel* const selp = VN_CAST(exprp, Sel)) exprp = selp->fromp();
        AstVar* baseVarp = nullptr;
        AstVar* memberVarp = nullptr;
        if (!parseEmbeddedEventExpr(exprp, baseVarp, memberVarp)) return false;
        return baseVarp == trigger.baseVarp && memberVarp == trigger.memberVarp;
    }

    AstNodeExpr* newEventRead(FileLine* fl, const EmbeddedEventTrigger& trigger) const {
        AstNodeExpr* const basep = new AstVarRef{fl, trigger.baseVarp, VAccess::READ};
        if (!trigger.memberVarp) return basep;
        AstMemberSel* const selp = new AstMemberSel{fl, basep, trigger.memberVarp};
        selp->access(VAccess::READ);
        return selp;
    }

    string eventPrevName(const EmbeddedEventTrigger& trigger, size_t triggerIndex) const {
        string name = "__Vcg_prev_" + m_embeddedVarp->name() + "_" + std::to_string(triggerIndex)
                      + "_" + trigger.baseVarp->name();
        if (trigger.memberVarp) name += "_" + trigger.memberVarp->name();
        return name;
    }

    AstNodeExpr* newEmbeddedVarNonNull(FileLine* fl) const {
        return new AstNeq{fl, new AstVarRef{fl, m_embeddedVarp, VAccess::READ},
                          new AstConst{fl, AstConst::Null{}}};
    }

    AstNodeStmt* newSampleStmt(FileLine* fl) const {
        AstMethodCall* const callp = new AstMethodCall{
            fl, new AstVarRef{fl, m_embeddedVarp, VAccess::READ}, "sample", nullptr};
        callp->taskp(m_sampleFuncp);
        callp->dtypeSetVoid();
        return callp->makeStmt();
    }

    void installEmbeddedEventFork(AstSenTree* eventp,
                                  const std::vector<AstNodeAssign*>& constructps) {
        // IEEE 1800-2023 19.3 samples coverpoints whenever their clocking event occurs. A
        // per-instance event cannot use V3Active's static sensitivity path, so spawn
        // 'fork forever begin @(event); cg.sample(); end join_none' after each construction.
        for (AstNodeAssign* const constructp : constructps) {
            FileLine* const fl = constructp->fileline();
            AstLoop* const loopp = new AstLoop{fl};
            loopp->addStmtsp(new AstEventControl{fl, eventp->cloneTree(false), nullptr});
            loopp->addStmtsp(new AstIf{fl, newEmbeddedVarNonNull(fl), newSampleStmt(fl)});
            AstFork* const forkp = new AstFork{fl, VJoinType::JOIN_NONE};
            forkp->immediateStart(true);
            forkp->addForksp(new AstBegin{fl, "", loopp, true});
            constructp->addNextHere(forkp);
        }
        VL_DO_DANGLING(pushDeletep(eventp), eventp);
    }

    AstNodeExpr* newEventReadyCondition(FileLine* fl, const EmbeddedEventTrigger& trigger) const {
        AstNodeExpr* const curp = newEventRead(fl, trigger);
        AstNodeExpr* const prevp = new AstVarRef{fl, trigger.prevVarp, VAccess::READ};
        AstNodeExpr* edgep = nullptr;
        // IEEE 1800-2023 9.4.2 detects edge-qualified events only on the expression's LSB,
        // while an implicit change event observes the complete expression.
        if (trigger.edgeType == VEdgeType::ET_POSEDGE) {
            edgep = new AstSel{fl, new AstAnd{fl, curp, new AstNot{fl, prevp}}, 0, 1};
        } else if (trigger.edgeType == VEdgeType::ET_NEGEDGE) {
            edgep = new AstSel{fl, new AstAnd{fl, new AstNot{fl, curp}, prevp}, 0, 1};
        } else if (trigger.edgeType == VEdgeType::ET_BOTHEDGE) {
            edgep = new AstSel{fl, new AstXor{fl, curp, prevp}, 0, 1};
        } else {
            edgep = new AstNeq{fl, curp, prevp};
        }
        return new AstLogAnd{fl, newEmbeddedVarNonNull(fl), edgep};
    }

    std::vector<EmbeddedEventTrigger> collectEmbeddedEventTriggers(AstCovergroup* cgp) {
        std::vector<EmbeddedEventTrigger> triggers;
        const std::set<const AstVar*> enclosingVars = enclosingInstanceVars();
        for (AstNode* senp = cgp->eventp()->sensesp(); senp; senp = senp->nextp()) {
            AstSenItem* const itemp = VN_AS(senp, SenItem);
            AstVar* baseVarp = nullptr;
            AstVar* memberVarp = nullptr;
            if (!parseEmbeddedEventExpr(itemp->sensp(), baseVarp, memberVarp)
                || !enclosingVars.count(baseVarp)) {
                return {};
            }
            triggers.emplace_back(itemp->fileline(), baseVarp, memberVarp, itemp->edgeType());
        }
        return triggers;
    }

    void installEmbeddedEventTriggers(std::vector<EmbeddedEventTrigger>& triggers,
                                      const std::vector<AstNodeAssign*>& constructps) {
        // Without --timing, approximate a per-instance event by sampling after assignments
        // within the enclosing class. External writes and exact scheduling cannot be observed.
        if (constructps.empty()) return;
        for (size_t triggerIndex = 0; triggerIndex < triggers.size(); ++triggerIndex) {
            EmbeddedEventTrigger& trigger = triggers[triggerIndex];
            std::vector<AstNodeAssign*> assignps;
            m_enclosingClassp->foreach([&](AstNodeAssign* asgnp) {
                if (isEventLvalue(asgnp->lhsp(), trigger)) assignps.push_back(asgnp);
            });
            if (assignps.empty()) {
                trigger.eventFl->v3warn(
                    COVERIGN, "Unsupported: 'covergroup' clocking event signal has no assignment "
                              "within the enclosing class; no coverage sampled. Use --timing for "
                              "full support.");
                continue;
            }
            AstNodeDType* const dtypep
                = trigger.memberVarp ? trigger.memberVarp->dtypep() : trigger.baseVarp->dtypep();
            AstVar* const prevVarp = new AstVar{trigger.eventFl, VVarType::MEMBER,
                                                eventPrevName(trigger, triggerIndex), dtypep};
            prevVarp->isStatic(false);
            m_enclosingClassp->addMembersp(prevVarp);
            trigger.prevVarp = prevVarp;
            for (AstNodeAssign* const asgnp : assignps) {
                FileLine* const fl = asgnp->fileline();
                AstIf* const ifp
                    = new AstIf{fl, newEventReadyCondition(fl, trigger), newSampleStmt(fl)};
                ifp->addNextHere(new AstAssign{fl,
                                               new AstVarRef{fl, trigger.prevVarp, VAccess::WRITE},
                                               newEventRead(fl, trigger)});
                asgnp->addNextHere(ifp);
            }
        }
    }

    void deleteCoverageItems() {
        for (AstCoverpoint* const cpp : m_coverpoints) {
            VL_DO_DANGLING(pushDeletep(cpp->unlinkFrBack()), cpp);
        }
        for (AstCoverCross* const crossp : m_coverCrosses) {
            VL_DO_DANGLING(pushDeletep(crossp->unlinkFrBack()), crossp);
        }
    }

    class FormalRefVisitor final : public VNVisitor {
        const std::map<const AstVar*, AstVar*>& m_replacements;

        void visit(AstVarRef* nodep) override {
            const auto it = m_replacements.find(nodep->varp());
            if (it == m_replacements.end()) return;
            nodep->varp(it->second);
        }
        void visit(AstNode* nodep) override { iterateChildren(nodep); }

    public:
        explicit FormalRefVisitor(const std::map<const AstVar*, AstVar*>& replacements)
            : m_replacements{replacements} {}
        void scan(AstNode* nodep) { iterate(nodep); }
    };

    void validateCovergroupExpressions() {
        std::set<const AstVar*> sampleMembers;
        std::set<const AstVar*> constructorRefMembers;
        for (AstNode* stmtp = m_constructorp->stmtsp(); stmtp; stmtp = stmtp->nextp()) {
            const AstVar* const varp = VN_CAST(stmtp, Var);
            if (!varp || !varp->isIO() || (!varp->isRef() && !varp->isConstRef())) continue;
            const AstVar* const memberp
                = VN_CAST(m_memberMap.findMember(m_covergroupp, varp->name()), Var);
            UASSERT_OBJ(memberp && memberp->isClassMember(), varp,
                        "Covergroup constructor argument missing persistent member");
            constructorRefMembers.insert(varp);
            constructorRefMembers.insert(memberp);
        }
        for (AstNode* stmtp = m_sampleFuncp->stmtsp(); stmtp; stmtp = stmtp->nextp()) {
            const AstVar* const varp = VN_CAST(stmtp, Var);
            if (!varp || !varp->isIO()) continue;
            const AstVar* const memberp
                = VN_CAST(m_memberMap.findMember(m_covergroupp, varp->name()), Var);
            UASSERT_OBJ(memberp && memberp->isClassMember(), varp,
                        "Covergroup sample argument missing persistent member");
            sampleMembers.insert(memberp);
        }
        CovergroupExprValidVisitor{sampleMembers, constructorRefMembers}.scan(m_constructorp);
    }

    void rebindFormalRefs() {
        std::map<const AstVar*, AstVar*> replacements;
        for (AstNode* stmtp = m_constructorp->stmtsp(); stmtp; stmtp = stmtp->nextp()) {
            if (const AstVar* const varp = VN_CAST(stmtp, Var)) {
                if (!varp->isIO()) continue;
                AstVar* const memberp
                    = VN_CAST(m_memberMap.findMember(m_covergroupp, varp->name()), Var);
                UASSERT_OBJ(memberp && memberp->isClassMember(), varp,
                            "Covergroup constructor argument missing persistent member");
                replacements.emplace(varp, memberp);
            }
        }
        size_t expectedBindings = 0;
        for (const auto& pair : replacements) {
            if (pair.first->isRef() || pair.first->isConstRef()) ++expectedBindings;
        }
        size_t rewrittenBindings = 0;
        for (AstNode* stmtp = m_constructorp->stmtsp(); stmtp;) {
            AstNode* const nextp = stmtp->nextp();
            if (AstAssign* const assignp = VN_CAST(stmtp, Assign)) {
                AstVarRef* const lhsp = VN_CAST(assignp->lhsp(), VarRef);
                AstVarRef* const rhsp = VN_CAST(assignp->rhsp(), VarRef);
                if (lhsp && rhsp
                    && (lhsp->varp()->declDirection() == VDirection::REF
                        || lhsp->varp()->declDirection() == VDirection::CONSTREF)) {
                    const auto it = replacements.find(rhsp->varp());
                    UASSERT_OBJ(it != replacements.end() && it->second == lhsp->varp(), assignp,
                                "Unexpected covergroup reference binding assignment");
                    AstCExpr* const bindp = new AstCExpr{assignp->fileline(), ""};
                    bindp->add(lhsp->unlinkFrBack());
                    bindp->add(" = &");
                    bindp->add(rhsp->unlinkFrBack());
                    assignp->replaceWith(bindp->makeStmt());
                    pushDeletep(assignp);
                    ++rewrittenBindings;
                }
            }
            stmtp = nextp;
        }
        UASSERT_OBJ(rewrittenBindings == expectedBindings, m_constructorp,
                    "Covergroup reference argument missing binding");
        FormalRefVisitor visitor{replacements};
        for (AstCoverpoint* const cpp : m_coverpoints) visitor.scan(cpp);
        for (AstCoverCross* const crossp : m_coverCrosses) visitor.scan(crossp);
    }

    AstVarRef* installEnclosingBackPointer(const std::vector<AstNodeAssign*>& constructps) {
        // Simple-case support for embedded covergroups (IEEE 1800-2023 19.4) whose
        // coverpoints reference members of the enclosing class ("Class members can be used
        // in coverpoint expressions").  The covergroup is lowered into a sibling class with
        // no implicit handle to the enclosing object, so such references would emit
        // uncompilable C++.  Add an explicit back-pointer member to the enclosing instance,
        // route the member references through it, and initialize it right after the
        // 'cgvar = new' construction.  The enclosing member values are only read in
        // sample(), which runs after construction, so this ordering is safe.  Returns an invalid
        // reference if an outer class member cannot be reached; otherwise returns an empty result.
        if (!m_enclosingClassp) return nullptr;  // Offending refs require an enclosing class

        AstVarRef* invalidp = nullptr;
        AstNode* offenderp = nullptr;
        std::set<const AstVar*> ownVars;
        for (AstNode* itemp = m_covergroupp->membersp(); itemp; itemp = itemp->nextp()) {
            if (const AstVar* const varp = VN_CAST(itemp, Var)) ownVars.insert(varp);
        }
        const std::set<const AstVar*> enclosingVars = enclosingInstanceVars();
        std::vector<AstVarRef*> refsToRewrite;
        std::vector<AstThisRef*> thisRefsToRewrite;
        const auto scan = [&](AstNode* rootp) {
            rootp->foreach([&](AstVarRef* refp) {
                if (invalidp) return;
                const AstVar* const varp = refp->varp();
                if (!isEnclosingInstanceVar(varp) || ownVars.count(varp)) return;
                if (!enclosingVars.count(varp)) {
                    invalidp = refp;
                    return;
                }
                refsToRewrite.push_back(refp);
                if (!offenderp) offenderp = refp;
            });
            if (invalidp) return;
            rootp->foreach([&](AstThisRef* refp) {
                const AstClassRefDType* const refDTypep
                    = VN_CAST(refp->dtypep()->skipRefp(), ClassRefDType);
                if (refDTypep && refDTypep->classp() == m_covergroupp) {
                    thisRefsToRewrite.push_back(refp);
                    if (!offenderp) offenderp = refp;
                }
            });
        };
        for (AstCoverpoint* const cpp : m_coverpoints) scan(cpp);
        for (AstCoverCross* const crossp : m_coverCrosses) scan(crossp);
        if (invalidp || !offenderp) return invalidp;

        UASSERT_OBJ(m_embeddedVarp, m_covergroupp, "Embedded covergroup variable not found");
        // Commit: add the back-pointer member, rewrite the references, initialize the handle.
        FileLine* const fl = m_covergroupp->fileline();
        AstClassRefDType* const enclDTypep = new AstClassRefDType{fl, m_enclosingClassp, nullptr};
        enclDTypep->rawPointer(true);
        v3Global.rootp()->typeTablep()->addTypesp(enclDTypep);
        AstVar* const handleVarp
            = new AstVar{fl, VVarType::MEMBER, "__Vcg_enclosingp", enclDTypep};
        handleVarp->isStatic(false);
        m_covergroupp->addMembersp(handleVarp);

        // Route each enclosing-member reference through the back-pointer: 'm' -> 'h.m'.
        for (AstVarRef* const refp : refsToRewrite) { rewriteVarRef(refp, handleVarp); }
        for (AstThisRef* const refp : thisRefsToRewrite) { rewriteThisRef(refp, handleVarp); }

        // Initialize the raw back-pointer after each construction.  With no construction site,
        // the embedded covergroup handle remains null, so no back-pointer is observed.
        for (AstNodeAssign* const constructp : constructps) {
            FileLine* const cfl = constructp->fileline();
            AstMemberSel* const lhsp
                = new AstMemberSel{cfl, constructp->lhsp()->cloneTree(false), handleVarp};
            lhsp->access(VAccess::WRITE);
            AstCExpr* const thisp = new AstCExpr{cfl, "this"};
            thisp->dtypep(enclDTypep);
            constructp->addNextHere(new AstAssign{cfl, lhsp, thisp});
        }
        return nullptr;
    }

    void visit(AstClass* nodep) override {
        UINFO(9, "Visiting class: " << nodep->name() << " isCovergroup=" << nodep->isCovergroup());
        if (nodep->isCovergroup()) {
            VL_RESTORER(m_covergroupp);
            VL_RESTORER(m_embeddedVarp);
            VL_RESTORER(m_sampleFuncp);
            VL_RESTORER(m_constructorp);
            VL_RESTORER_CLEAR(m_coverpoints);
            VL_RESTORER_CLEAR(m_coverpointMap);
            VL_RESTORER_CLEAR(m_coverCrosses);
            m_covergroupp = nodep;
            m_embeddedVarp = findEmbeddedCovergroupVar();
            m_sampleFuncp = nullptr;
            m_constructorp = nullptr;
            std::vector<EmbeddedEventTrigger> embeddedEventTriggers;
            AstSenTree* embeddedEventForkp = nullptr;

            // Extract and store the clocking event from AstCovergroup node
            // The parser creates this node to preserve the event information
            bool hasUnsupportedEvent = false;
            for (AstNode* itemp = nodep->membersp(); itemp;) {
                AstNode* const nextp = itemp->nextp();
                if (AstCovergroup* const cgp = VN_CAST(itemp, Covergroup)) {
                    // Store the event in the global map for V3Active to retrieve later
                    // V3LinkParse only creates this sentinel AstCovergroup node when a clocking
                    // event exists, so cgp->eventp() is always non-null here.
                    UASSERT_OBJ(cgp->eventp(), cgp,
                                "Sentinel AstCovergroup in class must have non-null eventp");
                    if (hasEnclosingEventRef(cgp)) {
                        UASSERT_OBJ(m_embeddedVarp, cgp,
                                    "Embedded covergroup event has no instance variable");
                        // IEEE 1800-2023 19.4 permits assignment to an embedded covergroup
                        // variable only in the enclosing class's new method.
                        if (AstNodeAssign* const invalidp
                            = findInvalidEmbeddedCovergroupAssignment()) {
                            invalidp->v3error(
                                "Embedded covergroup variable "
                                << m_embeddedVarp->prettyNameQ()
                                << " may only be assigned in the enclosing class's 'new' method "
                                   "(IEEE 1800-2023 19.4).");
                            hasUnsupportedEvent = true;
                            VL_DO_DANGLING(pushDeletep(cgp->unlinkFrBack()), cgp);
                            itemp = nextp;
                            continue;
                        }
                        if (v3Global.opt.timing().isSetTrue()) {
                            embeddedEventForkp = cgp->eventp()->unlinkFrBack();
                        } else {
                            embeddedEventTriggers = collectEmbeddedEventTriggers(cgp);
                            if (embeddedEventTriggers.empty()) {
                                cgp->v3warn(COVERIGN,
                                            "Unsupported: 'covergroup' clocking event on complex "
                                            "member expression; use --timing for full support.");
                                hasUnsupportedEvent = true;
                            }
                        }
                        VL_DO_DANGLING(pushDeletep(cgp->unlinkFrBack()), cgp);
                        itemp = nextp;
                        continue;
                    }
                    // V3Active handles events that do not depend on an enclosing instance.
                    UINFO(4, "Keeping covergroup event node for V3Active: " << nodep->name());
                    itemp = nextp;
                    continue;
                }
                itemp = nextp;
            }

            // Find the sample() method and constructor
            m_sampleFuncp = VN_CAST(m_memberMap.findMember(nodep, "sample"), Func);
            // V3LinkParse always synthesizes a sample() method for every covergroup, and the
            // sampling-code generation below dereferences m_sampleFuncp unconditionally.
            UASSERT_OBJ(m_sampleFuncp, nodep, "Covergroup missing synthesized sample() method");
            m_sampleFuncp->isCovergroupSample(true);
            m_constructorp = VN_CAST(m_memberMap.findMember(nodep, "new"), Func);
            UINFO(9, "Found sample() method: " << (m_sampleFuncp ? "yes" : "no"));
            UINFO(9, "Found constructor: " << (m_constructorp ? "yes" : "no"));

            // If covergroup has unsupported clocking event, skip processing it
            // but still clean up coverpoints so they don't reach downstream passes
            if (hasUnsupportedEvent) {
                iterateChildren(nodep);
                validateCovergroupExpressions();
                rebindFormalRefs();
                deleteCoverageItems();
                if (embeddedEventForkp) {
                    VL_DO_DANGLING(pushDeletep(embeddedEventForkp), embeddedEventForkp);
                }
                return;
            }

            iterateChildren(nodep);
            validateCovergroupExpressions();
            rebindFormalRefs();
            const std::vector<AstNodeAssign*> constructps = findCovergroupConstructions();

            // Embedded covergroups (IEEE 1800-2023 19.4): coverpoints, iff expressions, and
            // crosses may reference members of the enclosing class. The covergroup is lowered
            // into a sibling class with no implicit handle to the enclosing instance. Install
            // an explicit back-pointer and route the references through it.
            if (AstVarRef* const invalidp = installEnclosingBackPointer(constructps)) {
                invalidp->v3error("Non-static member "
                                  << invalidp->varp()->prettyNameQ()
                                  << " of an outer class requires an explicit "
                                     "object handle (IEEE 1800-2023 8.23).");
                deleteCoverageItems();
                if (embeddedEventForkp) {
                    VL_DO_DANGLING(pushDeletep(embeddedEventForkp), embeddedEventForkp);
                }
                return;
            }
            installEmbeddedEventTriggers(embeddedEventTriggers, constructps);
            if (embeddedEventForkp) installEmbeddedEventFork(embeddedEventForkp, constructps);
            processCovergroup();
            // Remove lowered coverpoints/crosses from the class - they have been
            // fully translated into C++ code and must not reach downstream passes
            deleteCoverageItems();
        } else {
            // Track the lexically enclosing class so a nested covergroup can resolve
            // references to the enclosing object's members (installEnclosingBackPointer).
            VL_RESTORER(m_enclosingClassp);
            m_enclosingClassp = nodep;
            iterateChildren(nodep);
        }
    }

    void visit(AstCoverpoint* nodep) override {
        UINFO(9, "Found coverpoint: " << nodep->name());
        m_coverpoints.push_back(nodep);
        m_coverpointMap.emplace(nodep->name(), nodep);
        iterateChildren(nodep);
    }

    void visit(AstCoverCross* nodep) override {
        UINFO(9, "Found cross: " << nodep->name());
        m_coverCrosses.push_back(nodep);
        iterateChildren(nodep);
    }

    void visit(AstNode* nodep) override { iterateChildren(nodep); }

public:
    // CONSTRUCTORS
    explicit FunctionalCoverageVisitor(AstNetlist* nodep) { iterate(nodep); }
    ~FunctionalCoverageVisitor() override = default;
};

//######################################################################
// Functional coverage class functions

void V3Covergroup::covergroup(AstNetlist* nodep) {
    UINFO(4, __FUNCTION__ << ": ");
    { FunctionalCoverageVisitor{nodep}; }  // Destruct before checking
    V3Global::dumpCheckGlobalTree("coveragefunc", 0, dumpTreeEitherLevel() >= 3);
}
