"""Rules PIPE-01 to PIPE-06, LIFE-01 to LIFE-06 and AUTO-01 to AUTO-10, in the order of
references/rules-process.md.

Each rule is one function named after its ID (PIPE-01 is pipe_01). It takes the merged inventory (Ctx) and
an empty result (Res) from audit_common.py and records findings, NOT CHECKED reasons and notes on the
result. audit_crm.py loads this file by path; it is not run on its own.
"""
from audit_common import (
    CRITICAL_WRITER_CLASSES, ACTION_NAMES, num, alnum, norm_writer, is_automated, pct, ratio, tokens, empty,
    prob, norm_name, known_or_nc, unknown_writes_note, names_of, check_ratio)


ESCALATE_AUTO02 = {"routing", "ownership", "consent", "lifecycle", "stage", "revenue"}

LATE_STAGES = {"opportunity", "customer", "evangelist"}

NON_RELATIONSHIP = {"churned", "churn", "trial", "recycled", "badfit", "disqualified", "nurture", "insequence"}

FOLLOW_UP = {"contacted", "working", "attempting", "attempted", "inprogress", "connected"}

ACTIVITY_WORDS = {"contacted", "called", "emailed", "meeting", "demo", "scheduled", "booked", "attempting", "follow"}

# Pipeline properties hold stage and pipeline IDs in filters, and PIPE-06 checks those IDs.
PIPELINE_PROPS = {"dealstage", "pipeline", "hs_pipeline", "hs_pipeline_stage"}


def stages_of(p):
    st = [s for s in p.get("stages") or [] if isinstance(s, dict)]
    return sorted(st, key=lambda s: num(s.get("order")) if num(s.get("order")) is not None else 0) if any("order" in s for s in st) else st


def pipe_01(c, r):
    if not isinstance(c.pipelines, list):
        return r.nc("pipelines missing from the inventory")
    sigs = {}
    for p in c.pipelines:
        sig = tuple(alnum(s.get("label")) for s in stages_of(p))
        if sig in sigs:
            r.add("WARN", "%r and %r have identical stage lists (%d stages)" % (sigs[sig], p.get("label"), len(sig)))
        else:
            sigs[sig] = p.get("label")
        if num(p.get("open_deals")) == 0 and num(p.get("created_180d")) == 0:
            r.add("WARN", "%r has no open deals and none created in 180 days" % p.get("label"))
        if num(p.get("records_180d")) == 0:
            r.add("WARN", "%r (sales process or record type) has no records in 180 days" % p.get("label"))
    if not r.items:
        r.add("PASS", "%d pipelines with distinct stage lists and recent use" % len(c.pipelines))


def pipe_02(c, r):
    if not isinstance(c.pipelines, list):
        return r.nc("pipelines missing from the inventory")
    mx = c.cfg["PIPE-02"]["max_open_stages"]
    for p in c.pipelines:
        open_st = [s for s in stages_of(p) if c.stage_type(s) == "open"]
        if len(open_st) > mx:
            r.add("WARN", "%r has %d open stages (more than %d)" % (p.get("label"), len(open_st), mx))
        no_ev = [s.get("label") for s in open_st if empty(s.get("entry_evidence"))]
        if no_ev:
            r.add("WARN", "%r: open stages without written entry evidence: %s" % (p.get("label"), ", ".join(no_ev)))
        act = [s.get("label") for s in open_st if set(tokens(s.get("label"))) & ACTIVITY_WORDS]
        if act:
            r.add("WARN", "%r: stage names that may describe seller activity, not buyer commitment: %s" % (p.get("label"), ", ".join(act)))
    if not r.items:
        r.add("PASS", "every pipeline has at most %d open stages with written entry evidence" % mx)


def pipe_03(c, r):
    if not isinstance(c.pipelines, list):
        return r.nc("pipelines missing from the inventory")
    hubspot = c.crm == "hubspot"
    for p in c.pipelines:
        st = stages_of(p)
        label = p.get("label")
        won = [s for s in st if c.stage_type(s) == "won"]
        lost = [s for s in st if c.stage_type(s) == "lost"]
        for s in st:
            t, pr = c.stage_type(s), prob(s)
            recorded = str(s.get("type") or "").lower()
            if hubspot and recorded in ("open", "won", "lost") and recorded != t:
                r.notes.append("%r: stage %r is recorded as %s but HubSpot treats a %.0f%% stage as %s" % (
                    label, s.get("label"), recorded, 100 * pr, t))
            if t == "open" and pr is not None and pr >= 1.0:
                r.add("FAIL", "%r: stage %r is at 100%% probability but is not the won stage" % (label, s.get("label")))
            if t == "lost" and pr:
                r.add("FAIL", "%r: lost stage %r is at %.0f%%" % (label, s.get("label"), 100 * pr))
            if t == "won" and pr is not None and pr < 1.0:
                r.add("FAIL", "%r: won stage %r is at %.0f%%" % (label, s.get("label"), 100 * pr))
            fc = alnum(s.get("forecast_category"))
            if fc:
                if t in ("won", "lost") and fc in ("pipeline", "bestcase", "commit"):
                    r.add("FAIL", "%r: closed stage %r maps to forecast category %s" % (label, s.get("label"), s.get("forecast_category")))
                elif t == "won" and fc != "closed":
                    r.add("FAIL", "%r: won stage %r maps to forecast category %s, not Closed" % (label, s.get("label"), s.get("forecast_category")))
                elif t == "open" and fc == "closed":
                    r.add("FAIL", "%r: open stage %r maps to forecast category Closed" % (label, s.get("label")))
        if len(won) > 1 and empty(p.get("multiple_won_reason")):
            r.add("FAIL", "%r has %d won stages with no documented reason (%s)%s" % (
                label, len(won), ", ".join(str(s.get("label")) for s in won),
                "; HubSpot counts a deal as won, sets its close date and can fire the deal-won lifecycle setting "
                "in the first 100% stage it enters" if hubspot else ""))
        if not lost:
            r.add("WARN", "%r has no lost stage" % label)
        opens = [(s.get("label"), prob(s)) for s in st if c.stage_type(s) == "open" and prob(s) is not None]
        for a, b in zip(opens, opens[1:]):
            if b[1] < a[1]:
                r.add("WARN", "%r: open-stage probabilities decrease along the stage order: %s (%.0f%%) then %s (%.0f%%)" % (label, a[0], 100 * a[1], b[0], 100 * b[1]))
                break
    if not r.items:
        r.add("PASS", "won stages at 100%, lost stages at 0%, no ambiguous closed stages")


def pipe_04(c, r):
    if not isinstance(c.pipelines, list):
        return r.nc("pipelines missing from the inventory")
    t = c.cfg["PIPE-04"]["missing_required"]
    rated = 0
    for p in c.pipelines:
        for s in stages_of(p):
            for field, cnt in (s.get("missing_required") or {}).items():
                if check_ratio(r, cnt, "deals in %r / %r missing %s" % (p.get("label"), s.get("label"), field), t["warn"], t["fail"]) == "rated":
                    rated += 1
    if not rated and not r.nc_reasons:
        r.nc("no stage-required field counts (pipelines[].stages[].missing_required)")


def pipe_05(c, r):
    if not isinstance(c.pipelines, list):
        return r.nc("pipelines missing from the inventory")
    t = c.cfg["PIPE-05"]
    seen = False
    for p in c.pipelines:
        for s in stages_of(p):
            if c.stage_type(s) != "open":
                continue
            if "entries_180d" in s:
                seen = True
                if num(s.get("entries_180d")) == 0:
                    r.add("WARN", "%r: no deal entered %r in %d days" % (p.get("label"), s.get("label"), t["unused_stage_days"]))
            rt = ratio(s.get("stuck_open"))
            if rt:
                seen = True
                if rt[1] > 0 and rt[0] / rt[1] > t["stuck_share_warn"]:
                    r.add("WARN", "%r / %r: %d of %d open deals (%s) sat longer than the larger of %d days and %sx the median dwell" % (
                        p.get("label"), s.get("label"), rt[0], rt[1], pct(*rt), t["dwell_min_days"], t["dwell_median_multiple"]))
    if not seen:
        return r.nc("no stage entry or dwell counts (stages[].entries_180d, stages[].stuck_open)")
    if not r.items:
        r.add("PASS", "every open stage had entries in %d days and no stuck share above %.0f%%" % (t["unused_stage_days"], 100 * t["stuck_share_warn"]))


def pipe_06(c, r):
    if not isinstance(c.pipelines, list):
        return r.nc("pipelines missing from the inventory")
    ids = set(str(s.get("id")) for p in c.pipelines for s in stages_of(p) if s.get("id") is not None)
    touching = 0
    for i in c.integrations or []:
        refs = [str(x) for x in i.get("stage_refs") or []]
        if refs or i.get("stage_mapping"):
            touching += 1
        gone = [x for x in refs if x not in ids]
        if gone:
            r.add("FAIL", "%r references stage IDs that no longer exist: %s" % (i.get("name"), ", ".join(gone)))
        if str(i.get("stage_mapping") or "").lower() == "labels":
            r.add("WARN", "%r maps stages by label, not internal ID" % i.get("name"))
    for p in c.pipelines:
        if any(s.get("id") in (None, "") for s in stages_of(p)):
            r.add("WARN", "%r: no record of stage internal IDs" % p.get("label"))
    if not r.items:
        r.add("PASS", "%s" % ("integrations reference current stage IDs" if touching else "no integration or import template references deal stages"))


def lc_norm(v):
    return alnum(v)


def life_01(c, r):
    opts = (c.lifecycle or {}).get("options")
    if not opts:
        return r.nc("lifecycle.options missing")
    undefined = [o.get("label") for o in opts if empty(o.get("entry_evidence")) or empty(o.get("owner"))]
    if undefined:
        r.add("WARN", "lifecycle stages without written entry evidence or an owner: %s" % ", ".join(undefined))
    nonrel = [o.get("label") for o in opts if lc_norm(o.get("label")) in NON_RELATIONSHIP or set(tokens(o.get("label"))) & NON_RELATIONSHIP]
    if nonrel:
        r.add("WARN", "lifecycle options that encode a non-relationship state: %s; keep churn, trial and follow-up in their own fields" % ", ".join(nonrel))
    if not r.items:
        r.add("PASS", "%d lifecycle stages, all defined relationship milestones" % len(opts))


def life_02(c, r):
    lc = c.lifecycle
    if not isinstance(lc, dict):
        return r.nc("lifecycle missing from the inventory")
    if not lc.get("options") and lc.get("leads_object_active") is None and lc.get("lead_status_used_for_follow_up") is None:
        return r.nc("lifecycle options and lead status usage not recorded")
    fu = [o.get("label") for o in lc.get("options") or [] if lc_norm(o.get("label")) in FOLLOW_UP or set(tokens(o.get("label"))) & FOLLOW_UP]
    if fu:
        r.add("WARN", "lifecycle options that describe follow-up: %s" % ", ".join(fu))
    if lc.get("leads_object_active") and lc.get("lead_status_used_for_follow_up"):
        r.add("WARN", "Lead Status and the leads object are both used for follow-up")
    dup = [f.get("name") for f in c.fields or [] if f.get("custom") and norm_name(c, f.get("name"))[0] in ("lifecyclestage", "lifecycle")]
    if dup:
        r.add("WARN", "custom lifecycle field beside the standard one: %s" % ", ".join(dup))
    if not r.items:
        r.add("PASS", "lifecycle, lead status and follow-up each answer one question")


def lifecycle_writers(c):
    return c.writers(None, c.lifecycle_field)


def life_03(c, r):
    lc = c.lifecycle
    if not isinstance(lc, dict):
        return r.nc("lifecycle missing from the inventory")
    w = lifecycle_writers(c)
    if not w:
        return r.nc("no lifecycle writers recorded (lifecycle.writers, automations, integrations, forms)")
    unknown_writes_note(c, r)
    auto = sorted(l for l in w if is_automated(l))
    if len(auto) >= 2 and empty(lc.get("precedence_rule")):
        r.add("FAIL", "%d automated writers can set lifecycle (%s) and no written precedence rule" % (len(auto), ", ".join(auto)))
    for x in lc.get("writers") or []:
        sets = [lc_norm(s) for s in x.get("sets") or []]
        if str(x.get("kind") or "").lower() in ("billing", "enrichment") and "customer" in sets and not x.get("customer_evidence_documented"):
            r.add("FAIL", "%s (%s) sets Customer without the documented evidence" % (norm_writer(x.get("writer")), x.get("kind")))
    if not r.items and not empty(lc.get("precedence_rule")) and lc.get("precedence_tested") is not True:
        r.add("WARN", "lifecycle precedence is written down but has not been tested")
    if not r.items:
        r.add("PASS", "lifecycle writers (%s) match the documented precedence" % ", ".join(sorted(w)))


def life_04(c, r):
    forms = c.inv.get("forms")
    if forms is None:
        return r.nc("forms missing from the inventory")
    for f in forms:
        v = lc_norm(f.get("sets_lifecycle"))
        if f.get("public") and v in LATE_STAGES:
            r.add("FAIL", "public form %r sets lifecycle stage %s" % (f.get("name"), f.get("sets_lifecycle")))
        elif v in ("marketingqualifiedlead", "mql", "salesqualifiedlead", "sql"):
            r.add("WARN", "form %r sets %s directly instead of a qualification rule" % (f.get("name"), f.get("sets_lifecycle")))
    d = lc_norm((c.lifecycle or {}).get("default_new_records"))
    if d in LATE_STAGES:
        r.add("FAIL", "the default lifecycle stage for new records is %s" % (c.lifecycle or {}).get("default_new_records"))
    if not r.items:
        r.add("PASS", "%d forms checked; no public form or default sets Opportunity, Customer or Evangelist" % len(forms))


def life_05(c, r):
    if c.automations is None:
        return r.nc("automations missing (pass --automations or fill inventory automations)")
    lf = c.lifecycle_field
    act = known_or_nc(c, r, "writes_known")
    if act is None:
        return
    for a in act:
        own = [w for w in a.get("writes") or [] if str(w.get("field") or "").lower() == lf]
        clears = [w for w in own if w.get("op") == "clear" or w.get("lowers")]
        if clears and not (a.get("manual_only") and a.get("sets_reason_field")):
            r.add("FAIL", "%r clears or lowers lifecycle without manual-only enrolment and a reason field" % a.get("name"))
        if any(w.get("op") == "set-dynamic" for w in own):
            r.notes.append("%r writes lifecycle from a value the definition does not show; open it to confirm it cannot clear" % a.get("name"))
    doc = (c.lifecycle or {}).get("correction_path_documented")
    if doc is False:
        r.add("WARN", "no documented lifecycle correction path; re-imports report success and change nothing for forward-only writers")
    elif doc is None:
        r.notes.append("lifecycle.correction_path_documented not recorded")
    if not r.items:
        r.add("PASS", "no automation lowers lifecycle outside a manual-only correction path")


def life_06(c, r):
    t = c.cfg["LIFE-06"]
    a = check_ratio(r, c.counts.get("customers_without_evidence"), "Customer records without a closed-won deal or billing evidence",
                    t["customer_without_evidence"]["warn"], t["customer_without_evidence"]["fail"])
    b = check_ratio(r, c.counts.get("opportunity_without_open_deal"), "Opportunity records without an open deal", t["opportunity_without_open_deal_warn"])
    d = check_ratio(r, c.counts.get("recent_contacts_empty_lifecycle"), "contacts created in the last %d days with no lifecycle stage" % t["recent_days"],
                    t["recent_empty_stage_warn"])
    if a is None and b is None and d is None and not r.nc_reasons:
        r.nc("counts.customers_without_evidence missing")


def writes_critical(c, a, classes):
    for w in (a.get("writes") or []) + (a.get("owner_writes") or []):
        cf = c.critical_for(a.get("object"), w.get("field"))
        if cf and str(cf.get("class") or "").lower() in classes:
            return cf
    return None


def auto_01(c, r):
    if c.automations is None:
        return r.nc("automations missing (pass --automations or fill inventory automations)")
    active = c.enabled()
    if not active:
        return r.add("PASS", "no active automations")
    has_owner = any("owner" in a for a in c.automations)
    has_desc = any(a.get("description") is not None for a in active)
    if not has_owner and not has_desc:
        return r.nc("descriptions and owners not recorded for any automation")
    lacking = [a.get("name") for a in active if (has_desc and empty(a.get("description"))) or (has_owner and empty(a.get("owner")))]
    if len(lacking) / float(len(active)) > c.cfg["AUTO-01"]["undocumented_active_warn"]:
        r.add("WARN", "%d of %d active automations lack a description or owner: %s" % (len(lacking), len(active), ", ".join(lacking)))
    if has_owner:
        crit = [a.get("name") for a in active if empty(a.get("owner")) and writes_critical(c, a, CRITICAL_WRITER_CLASSES | {"consent"})]
        if crit:
            r.add("WARN", "automations writing a critical field with no owner: " + ", ".join(crit))
    else:
        r.notes.append("owners not recorded for any automation; descriptions checked only")
    if not has_desc:
        r.notes.append("descriptions not recorded for any automation; owners checked only")
    if not r.items:
        r.add("PASS", "%d of %d active automations have %s" % (len(active) - len(lacking), len(active),
                                                              " and ".join(x for x, ok in (("a description", has_desc), ("an owner", has_owner)) if ok)))


def auto_02(c, r):
    if c.automations is None:
        return r.nc("automations missing (pass --automations or fill inventory automations)")
    known = [a for a in c.automations if num(a.get("errors_30d")) is not None]
    if not known:
        return r.nc("errors_30d not recorded for any automation")
    for a in known:
        n = num(a.get("errors_30d"))
        if n and n > 0:
            cf = writes_critical(c, a, ESCALATE_AUTO02)
            if cf:
                r.add("FAIL", "%r: errors in 30 days: %d; it writes %s (%s), so the rule counts as class A" % (a.get("name"), n, cf["field"], cf.get("class")))
                r.cls = "A"
            else:
                r.add("WARN", "%r: errors in 30 days: %d" % (a.get("name"), n))
    if not r.items:
        r.add("PASS", "no errors in 30 days across %d automations" % len(known))


def auto_03(c, r):
    if c.automations is None:
        return r.nc("automations missing (pass --automations or fill inventory automations)")
    act = known_or_nc(c, r, "writes_known")
    if act is None:
        return
    by_field = {}
    for a in act:
        for w in a.get("writes") or []:
            if w.get("op") not in ("set", "clear"):
                continue
            k = (str(w.get("object") or a.get("object") or "").lower(), str(w.get("field") or "").lower())
            by_field.setdefault(k, []).append((a, w))
    for (obj, field), lst in sorted(by_field.items()):
        crit = c.is_critical(obj, field)
        for i in range(len(lst)):
            for j in range(i + 1, len(lst)):
                (a, wa), (b, wb) = lst[i], lst[j]
                if a is b or (wa.get("op") == wb.get("op") == "set" and wa.get("value") == wb.get("value")) or wa.get("op") == wb.get("op") == "clear":
                    continue
                ida, idb = str(a.get("id")), str(b.get("id"))
                if idb in [str(x) for x in a.get("mutually_exclusive_with") or []] or ida in [str(x) for x in b.get("mutually_exclusive_with") or []]:
                    continue
                va = wa.get("value") if wa.get("op") == "set" else "(clear)"
                vb = wb.get("value") if wb.get("op") == "set" else "(clear)"
                text = "%r sets %s to %s while %r sets %s on overlapping populations" % (a.get("name"), field, va, b.get("name"), vb)
                if a.get("enrollment_readable") is False or b.get("enrollment_readable") is False:
                    r.add("WARN", text + "; overlap cannot be ruled out because enrolment criteria are unreadable")
                elif crit:
                    r.add("FAIL", text + ", with no mutually exclusive enrolment criteria (critical field)")
                else:
                    r.add("WARN", text + " (not a critical field)")
    if not r.items:
        r.add("PASS", "no two enabled automations write conflicting values to one field")


def option_ok(value, field):
    v = alnum(value)
    return any(v in (alnum(o.get("value")), alnum(o.get("label"))) for o in field.get("options") or [])


def auto_04(c, r):
    if c.automations is None:
        return r.nc("automations missing (pass --automations or fill inventory automations)")
    if c.fields is None:
        return r.nc("fields missing, so references cannot be resolved")
    present = set(str(f.get("object") or "").lower() for f in c.fields)
    slated = set(str(x).lower() for x in (c.inv.get("archive_candidates") or []))
    skipped, other = 0, 0
    act = known_or_nc(c, r, "refs_known")
    if act is None:
        return
    for a in act:
        ao = str(a.get("object") or "").lower()
        other += int(num(a.get("other_object_refs")) or 0)
        if ao and ao not in present:
            skipped += 1
            continue

        def lookup(prop, obj=None):
            if obj or ao:
                return c.field_index.get(((obj or ao), prop))
            hits = c.field_by_name.get(prop) or []
            return hits[0] if hits else None
        for ref in a.get("filter_refs") or []:
            prop = str(ref.get("property") or "").lower()
            f = lookup(prop)
            if f is None:
                r.add("FAIL", "%r filters or branches on %s, which does not exist" % (a.get("name"), ref.get("property")))
                continue
            if f.get("type_family") == "enum" and f.get("options") and prop not in PIPELINE_PROPS:
                gone = [v for v in ref.get("values") or [] if not option_ok(v, f)]
                if gone:
                    r.add("FAIL", "%r filters on %s = %s, which is not an option of %s (options: %s)" % (
                        a.get("name"), ref.get("property"), ", ".join(gone), ref.get("property"),
                        ", ".join(str(o.get("value")) for o in f["options"])))
            if prop in slated:
                r.add("WARN", "%r reads %s, which the change list slates for archive" % (a.get("name"), ref.get("property")))
        for w in (a.get("writes") or []) if c.is_known(a, "writes_known") else []:
            wo = str(w.get("object") or "").lower()
            if w.get("associated") or (wo and wo not in present):  # another object, with no fields data here
                other += 1
            elif lookup(str(w.get("field") or "").lower(), wo) is None:
                r.add("FAIL", "%r writes %s, which does not exist" % (a.get("name"), w.get("field")))
        for lst in a.get("archived_list_refs") or []:
            r.add("FAIL", "%r references archived list or segment %s" % (a.get("name"), lst))
    if skipped:
        r.notes.append("%d automations on objects with no fields data were not checked" % skipped)
    if other:
        r.notes.append("%d filters or writes on events or other objects were not resolved; read them in the workflow if a "
                       "field or option was removed" % other)
    if not r.items:
        r.add("PASS", "all filter, branch and write references on the enrolled objects resolve")


def auto_05(c, r):
    if c.automations is None:
        return r.nc("automations missing (pass --automations or fill inventory automations)")
    act = known_or_nc(c, r, "writes_known")
    if act is None:
        return

    def written(a):
        return set(str(w.get("field") or "").lower() for w in (a.get("writes") or []) + (a.get("owner_writes") or []))

    def triggers(a):
        re_ = a.get("reenrollment") or {}
        return set(str(x).lower() for x in re_.get("trigger_fields") or []) if re_.get("enabled") else set()
    for a in act:
        loop = triggers(a) & written(a)
        re_on = (a.get("reenrollment") or {}).get("enabled")
        if loop:
            r.add("FAIL", "%r re-enrols on %s, which it writes" % (a.get("name"), ", ".join(sorted(loop))))
        elif re_on and a.get("has_exit_or_suppression") is False:
            r.add("WARN", "%r re-enrols with no suppression or exit for records that already completed" % a.get("name"))
        if re_on and (a.get("reenrollment") or {}).get("unread_event_triggers") and not triggers(a):
            r.gap("%r re-enrols on a property change event whose property the definition does not name; read the trigger "
                  "and record the field in reenrollment.trigger_fields" % a.get("name"), "for the automations read: ")
    for i in range(len(act)):
        for j in range(i + 1, len(act)):
            a, b = act[i], act[j]
            if triggers(b) & written(a) and triggers(a) & written(b):
                r.add("FAIL", "%r and %r write each other's re-enrolment triggers" % (a.get("name"), b.get("name")))
    if not r.items:
        r.add("PASS", "no re-enrolment loops among %d active automations" % len(act))


def auto_06(c, r):
    if c.automations is None:
        return r.nc("automations missing (pass --automations or fill inventory automations)")
    found = 0
    act = known_or_nc(c, r, "actions_known")
    if act is None:
        return
    for a in act:
        acts = list(a.get("destructive_actions") or []) + list(a.get("consent_actions") or [])
        if not acts:
            continue
        found += 1
        names = ", ".join(ACTION_NAMES.get(str(x), str(x)) for x in acts)
        ref = a.get("decision_ref")
        if empty(ref):
            r.add("FAIL", "%r uses %s with no dated decision naming the owner and policy" % (a.get("name"), names))
        elif str(ref) not in c.dated_decisions:  # a reference counts only when decisions.items holds it with a date
            r.add("FAIL", "%r uses %s and cites decision %s, which is not a dated entry in decisions.items" % (
                a.get("name"), names, ref))
        elif not a.get("tested"):
            r.add("WARN", "%r uses %s under decision %s, with no test record or audit trail" % (a.get("name"), names, ref))
        else:
            r.add("PASS", "%r uses %s under decision %s, tested" % (a.get("name"), names, ref))
    if not found:
        r.add("PASS", "no enabled automation deletes records or changes consent or marketing status%s" % (
            " among classified actions" if c.unclassified() else ""))


def auto_07(c, r):
    if c.automations is None:
        return r.nc("automations missing (pass --automations or fill inventory automations)")
    act = known_or_nc(c, r, "writes_known")
    if act is None:
        return
    for a in act:
        for ow in a.get("owner_writes") or []:
            if ow.get("overwrite") is None:  # the rotate-owner keys are not in HubSpot's public action reference
                r.notes.append("%r: the owner action records no overwrite setting, so whether it replaces existing owners "
                               "is unconfirmed; read it in a workflows get output" % a.get("name"))
            elif ow.get("overwrite") and a.get("population_includes_customers") is not False:
                r.add("WARN", "%r overwrites existing owners for a population that can include customers or open-deal contacts" % a.get("name"))
            if (ow.get("available_only") is True or a.get("available_only") is True) and not a.get("owner_fallback"):
                r.add("WARN", "%r rotates to available users only, with no monitored fallback" % a.get("name"))
        if (num(a.get("rotation_users_without_seat")) or 0) > 0:
            r.add("WARN", "%r rotates to %d users without paid seats, which HubSpot skips" % (a.get("name"), num(a.get("rotation_users_without_seat"))))
    if not r.items:
        r.add("PASS", "owner assignment protects existing owners and has a fallback")


def auto_08(c, r):
    if c.automations is None:
        return r.nc("automations missing (pass --automations or fill inventory automations)")
    days = c.cfg["AUTO-08"]["dormant_days"]
    unset = [a for a in c.automations if a.get("enabled") in (None, "")]
    if unset:  # state not recorded: neither dormant nor active can be shown
        r.gap("enabled state not recorded for %d automations: %s; record enabled true or false" % (
            len(unset), names_of(unset)), "for the automations with a recorded state: ")
    for a in c.automations:
        if a.get("enabled") in (None, ""):
            continue
        on = a.get("enabled") in (True, "true", "True", 1)
        if not on:
            d = c.days_since(a.get("turned_off_at") or a.get("updated_at"))
            if d is not None and d > days:
                r.add("WARN", "%r has been off for about %d days%s" % (a.get("name"), d, "" if a.get("turned_off_at") else " (last update used as the date)"))
        elif num(a.get("enrolled_total")) == 0:
            d = c.days_since(a.get("created_at"))
            if d is not None and d > days:
                r.add("WARN", "%r has never enrolled a record in %d days" % (a.get("name"), d))
    if c.settings.get("account_cleanup_auto_remove_workflows") and empty(c.settings.get("account_cleanup_owner")):
        r.add("WARN", "account cleanup can remove unused workflows automatically and has no named owner (restores last 90 days, as inactive copies)")
    if not r.items:
        r.add("PASS", "no automation off or unused for more than %d days" % days)


def auto_09(c, r):
    if c.automations is None:
        return r.nc("automations missing (pass --automations or fill inventory automations)")
    writes = {}  # field -> [(object or None, layer, name)]; a write with no object matches every object
    known, unknown = c.enabled_known("writes_known")
    if unknown and known:
        r.part("field writes unknown for %d enabled automations (%s), so layer overlap is only partly checked" % (
            len(unknown), names_of(unknown)))
    for a in known:
        ao = str(a.get("object") or "").lower() or None
        for w in (a.get("writes") or []) + (a.get("owner_writes") or []):
            writes.setdefault(str(w.get("field") or "").lower(), []).append((ao, a.get("layer") or "workflow", a.get("name")))
    for i in c.integrations or []:
        for io, k in c.int_fields(i):
            if k in writes:
                writes[k].append((io, "integration", i.get("name")))
    documented = set(str(x).lower().split(".")[-1] for x in c.decisions.get("automation_order_documented") or [])
    for field, lst in sorted(writes.items()):
        if field in documented:
            continue
        for obj in sorted(set(o for o, _, _ in lst if o)) or [None]:
            ly = {}
            for o, layer, name in lst:
                if o is None or obj is None or o == obj:
                    ly.setdefault(layer, set()).add(str(name))
            if len(ly) >= 2:
                r.add("FAIL", "%s is written by %d automation layers on one object with no documented order: %s" % (
                    "%s.%s" % (obj, field) if obj else field, len(ly),
                    "; ".join("%s (%s)" % (k, ", ".join(sorted(v))) for k, v in sorted(ly.items()))))
    legacy = [a.get("name") for a in c.enabled() if a.get("layer") in ("workflow_rule", "process_builder")]
    if legacy:
        r.add("WARN", "legacy Workflow Rules or Process Builder still active: %s (Salesforce ended support on 31 December 2025; they keep running with no support or bug fixes)" % ", ".join(legacy))
    groups = {}
    for a in c.enabled():
        if a.get("layer") == "flow" and a.get("trigger_type"):
            groups.setdefault((a.get("object"), a.get("trigger_type")), []).append(a)
    for (obj, trig), lst in groups.items():
        if len(lst) > 1 and any(empty(a.get("trigger_order")) for a in lst):
            r.add("WARN", "%d record-triggered flows on %s (%s) with no Trigger Order set: %s" % (len(lst), obj, trig, ", ".join(a.get("name") for a in lst)))
    if unknown and not known:
        msg = ("field writes unknown for all %d enabled automations (%s); read each full definition and record its "
               "writes" % (len(unknown), names_of(unknown)))
        if not r.items:
            return r.nc(msg)
        r.part(msg)
    if not r.items:
        r.add("PASS", "no field is written by more than one automation layer")


def auto_10(c, r):
    if c.automations is None:
        return r.nc("automations missing (pass --automations or fill inventory automations)")
    act = known_or_nc(c, r, "actions_known")
    if act is None:
        return
    for a in act:
        essential = a.get("essential") or a.get("owner_writes") or writes_critical(c, a, {"routing", "ownership"})
        if not essential:
            continue
        if a.get("credit_actions") and not a.get("credit_fallback"):
            r.add("WARN", "%r puts a credit-consuming action (%s) on an essential path with no non-credit fallback" % (
                a.get("name"), ", ".join(ACTION_NAMES.get(str(x), str(x)) for x in a["credit_actions"])))
        if (num(a.get("sequence_enrolments")) or 0) > 0 and not a.get("send_limit_fallback"):
            r.add("WARN", "%r enrols in a sequence on an essential path; enrolment is skipped at the sender's send limit" % a.get("name"))
    if c.settings.get("sequences_suppress_active_conversations") is False:
        r.add("WARN", "sequences and marketing workflows reach the same contacts with no suppression of active conversations")
    if not r.items:
        r.add("PASS", "no essential step depends on credits or send limits without a fallback")
