"""Rules ACC-01 to ACC-06, INT-01 to INT-07 and REP-01 to REP-07, in the order of
references/rules-access-integrations-reporting.md.

Each rule is one function named after its ID (ACC-01 is acc_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 (
    ENUM_VALUES, num, alnum, norm_writer, pct, fmt_num, empty, check_ratio)


def acc_01(c, r):
    if c.users is None:
        return r.nc("users missing from the inventory")
    t = c.cfg["ACC-01"]
    active = [u for u in c.users if u.get("active")]
    priv = [u for u in active if u.get("privileged")]
    for u in priv:
        at = str(u.get("account_type") or "").lower().replace("-", "_")
        what = {"agency": "belongs to an agency", "shared": "is a shared login", "generic": "is a generic login",
                "former_employee": "belongs to a former employee"}.get(at)
        if what:
            r.add("FAIL", "privileged account %s %s" % (u.get("id"), what))
        elif at == "integration" and not u.get("documented_need"):
            r.add("FAIL", "privileged account %s is an integration identity with no documented need" % u.get("id"))
    limit = max(t["max_privileged_min"], t["max_privileged_share"] * len(active))
    if len(priv) > limit:
        r.add("WARN", "%d privileged accounts of %d active users; the limit is the larger of %d and %.0f%% (%s)" % (
            len(priv), len(active), t["max_privileged_min"], 100 * t["max_privileged_share"], fmt_num(round(limit, 1))))
    if not r.items:
        r.add("PASS", "%d privileged of %d active users (limit %s), all personal" % (len(priv), len(active), fmt_num(round(limit, 1))))


def acc_02(c, r):
    if c.integrations is None:
        return r.nc("integrations missing from the inventory")
    unknown = []
    for i in c.integrations:
        it = str(i.get("identity_type") or "").lower()
        if it not in ENUM_VALUES["identity_type"]:
            unknown.append(str(i.get("name")))
        if it == "personal":
            r.add("FAIL", "%r authenticates as a named person's own user (%s)" % (i.get("name"), i.get("identity") or "identity not recorded"))
        pv = str(i.get("identity_privilege") or "")
        if alnum(pv) in ("superadmin", "modifyalldata", "systemadministrator", "admin", "workspaceadmin") and not i.get("documented_need"):
            r.add("FAIL", "%r identity holds %s with no documented need" % (i.get("name"), pv.replace("_", " ")))
        if i.get("scopes_exceed_writes"):
            r.add("WARN", "%r has scopes beyond what it writes" % i.get("name"))
    if not r.items and not unknown:
        n = len(c.integrations)
        r.add("PASS", "%d integration%s on dedicated, least-privilege identities" % (n, "" if n == 1 else "s"))
    if unknown:
        r.nc("identity_type not recorded, or not one of %s, for %s; read it from the connected apps list" % (
            ", ".join(ENUM_VALUES["identity_type"]), ", ".join(repr(x) for x in unknown)))


def acc_03(c, r):
    if c.users is None:
        return r.nc("users missing from the inventory")
    held = []
    for u in c.users:  # a departed person whose account is still active can still log in, so it counts too
        if u.get("active") and str(u.get("account_type") or "").lower() != "former_employee":
            continue
        owned = sum(int(num(u.get(k)) or 0) for k in ("open_deals_owned", "open_tickets_owned", "routing_records_owned"))
        if owned:
            held.append("%s%s (%d open deals, %d open tickets, %d routing records)" % (
                u.get("id"), ", departed but still active" if u.get("active") else "", num(u.get("open_deals_owned")) or 0,
                num(u.get("open_tickets_owned")) or 0, num(u.get("routing_records_owned")) or 0))
    if held:
        r.add("FAIL", "deactivated or departed users who own live work: %s" % "; ".join(held))
    days = c.cfg["ACC-03"]["inactive_days"]
    idle = [u.get("id") for u in c.users if u.get("active") and u.get("paid_seat") and (c.days_since(u.get("last_login")) or 0) > days]
    if idle:
        r.add("WARN", "paid seats with no login in %d days: %s" % (days, ", ".join(idle)))
    if not r.items:
        r.add("PASS", "no inactive user owns live work; no idle paid seats")


def acc_04(c, r):
    if c.users is None:
        return r.nc("users missing from the inventory")
    non_admin = [u for u in c.users if u.get("active") and not u.get("privileged")]
    known = [u for u in non_admin if any(k in u for k in ("can_bulk_delete", "can_merge_all", "can_export_all"))]
    if not known:
        return r.nc("bulk delete, merge and export rights not recorded for users")
    n = sum(1 for u in known if u.get("can_bulk_delete") or u.get("can_merge_all") or u.get("can_export_all"))
    if n / float(len(known)) > c.cfg["ACC-04"]["bulk_delete_share_warn"]:
        r.add("WARN", "%d of %d active non-admin users (%s) can bulk delete, merge or export all records" % (n, len(known), pct(n, len(known))))
    else:
        r.add("PASS", "%d of %d active non-admin users can bulk delete, merge or export all records" % (n, len(known)))


def acc_05(c, r):
    restricted = c.decisions.get("restricted_fields")
    checks = c.inv.get("access_checks") or []
    records = c.inv.get("record_access") or []
    if restricted is None and not checks and not records:
        return r.nc("decisions.restricted_fields not recorded and no access views (access_checks, record_access)")
    if c.crm == "attio" and str(c.settings.get("attio_plan") or "").lower() == "free" and restricted:
        r.add("FAIL", "the workspace is on Attio Free, where everything is visible and editable by all users, and it holds restricted data")
    if restricted is not None and not restricted:
        r.add("WARN", "no list of restricted fields exists")

    def exposed(subject, reader, via, confirmed):
        via = (" via " + str(via)) if via else ""
        if confirmed is False:
            r.add("FAIL", "%s: may be readable by %s outside the approved list%s (possible exposure)" % (subject, reader, via))
            r.cls = r.cls or "B"
        else:
            r.add("FAIL", "%s: readable by %s outside the approved list%s" % (subject, reader, via))
            r.cls = "A"
    for ch in checks:
        if ch.get("readable") and not ch.get("approved"):
            exposed(ch.get("field"), ch.get("role") or "a role", ch.get("via"), ch.get("confirmed"))
    for ra in records:
        obj = ra.get("object")
        if ra.get("holds_restricted_records") and ra.get("readable_outside_approved"):
            exposed("%s records" % obj, "roles", ra.get("via") or ra.get("default_access"), ra.get("confirmed"))
        ref = ra.get("decision_ref")
        if (alnum(obj) in ("account", "accounts", "opportunity", "opportunities") and alnum(ra.get("default_access")).startswith("publicreadwrite")
                and (empty(ref) or str(ref) not in c.dated_decisions)):
            r.add("WARN", "%s org-wide default is %s with no dated decision" % (obj, ra.get("default_access")))
    if c.settings.get("restricted_controls_only_layouts"):
        r.add("WARN", "property restrictions or page layouts are the only control on restricted fields")
    if restricted and not checks and not records and not r.items:
        return r.nc("restricted fields listed but no access views recorded (access_checks, record_access)")
    if not r.items:
        r.add("PASS", "access views for %d restricted fields and %d objects match the approved list" % (len(checks or restricted or []), len(records)))


def acc_06(c, r):
    s = c.settings
    tfa, sso = s.get("two_factor_required"), s.get("sso_enforced")
    if tfa is None and sso is None:
        r.nc("two-factor and SSO settings not captured")
    elif tfa is not True and sso is not True:
        r.add("WARN", "two-factor is %s and SSO is %s for all users (the security owner decides; the audit records it)" % (
            "not required" if tfa is False else "not recorded", "not enforced" if sso is False else "not recorded"))
    if s.get("team_visibility_used") and (num(s.get("owners_without_team")) or 0) > 0:
        r.add("WARN", "team-based visibility is used while %d active owners belong to no team" % num(s.get("owners_without_team")))
    if not r.items and not r.nc_reasons:
        r.add("PASS", "authentication enforced and team structure fits the permission model")


def int_01(c, r):
    if c.integrations is None:
        return r.nc("integrations missing from the inventory")
    for i in c.integrations:
        w = c.writes_int(i)
        n = i.get("name")
        if w and not i.get("in_stack_md") and not i.get("believed_connected"):
            r.add("FAIL", "%r has write access and is missing from stack.md and the team's list" % n)
        if w and empty(i.get("owner")):
            r.add("FAIL", "%r has write access and no named owner" % n)
        gaps = [k for k in ("direction", "objects") if empty(i.get(k))]
        if w and empty(i.get("match_key")) and empty(i.get("match_keys")):
            gaps.append("match_key")
        if w and empty(i.get("fields_written")) and not i.get("creates_records"):
            gaps.append("fields_written")
        if gaps:
            r.add("WARN", "%r entry lacks %s" % (n, ", ".join(gaps)))
        if not w and empty(i.get("owner")):
            r.add("WARN", "read-only integration %r has no owner" % n)
    if not r.items:
        r.add("PASS", "%d integrations listed with owners and the fields they write" % len(c.integrations))


def int_02(c, r):
    if c.integrations is None:
        return r.nc("integrations missing from the inventory")
    for i in c.integrations:
        n = i.get("name")
        split = [str(f) for f in i.get("two_way_fields_with_split_owners") or []]
        crit = [f for f in split if c.is_critical(None, f.split(".")[-1])]
        if crit:
            r.add("FAIL", "%r syncs %s two-way%s while different systems own %s" % (
                n, ", ".join(crit), (" with %s as the default app" % i["default_app"]) if i.get("default_app") else "", "it" if len(crit) == 1 else "them"))
        if [f for f in split if f not in crit]:
            r.add("WARN", "%r syncs non-critical fields two-way with split owners: %s" % (n, ", ".join(f for f in split if f not in crit)))
        if i.get("blanks_clear_owned_values"):
            r.add("FAIL", "%r sends blanks that clear values another system owns" % n)
        if c.writes_int(i) and str(i.get("blank_semantics") or "").lower() not in ("preserve", "clear"):
            r.add("WARN", "%r has no defined blank and clear behaviour" % n)
        if i.get("first_sync_baseline_decided") is False:
            r.add("WARN", "%r: first-sync baseline not decided for newly paired records" % n)
    if not r.items:
        r.add("PASS", "every mapped field has one direction and defined blank behaviour")


def int_03(c, r):
    if c.integrations is None:
        return r.nc("integrations missing from the inventory")
    t = c.cfg["INT-03"]
    seen = False
    for i in c.integrations:
        n = i.get("name")
        if i.get("suspended_30d"):
            seen = True
            r.add("FAIL", "%r was suspended in the last %d days" % (n, t["suspension_lookback_days"]))
        elif i.get("suspended_30d") is False:
            seen = True
        if check_ratio(r, i.get("record_errors"), "%r records with sync errors" % n, t["error_rate"]["warn"], t["error_rate"]["fail"]) == "rated":
            seen = True
        od = num(i.get("oldest_open_error_days"))
        if od is not None and od > t["open_days_warn"]:
            r.add("WARN", "%r has an error open for %d days" % (n, od))
    if not seen and not r.items:
        return r.nc("no sync health recorded (suspended_30d, record_errors)")


def int_04(c, r):
    ac = c.inv.get("api_capacity")
    if not isinstance(ac, dict):
        return r.nc("api_capacity missing from the inventory")
    t = c.cfg["INT-04"]
    rated = False
    sf = ac.get("salesforce_daily") or {}
    if num(sf.get("used")) is not None and num(sf.get("max")):
        rated = True
        s = num(sf["used"]) / num(sf["max"])
        txt = "Salesforce daily API requests at %s of the allocation (%s of %s, rolling 24 hours)" % (pct(num(sf["used"]), num(sf["max"])), fmt_num(num(sf["used"])), fmt_num(num(sf["max"])))
        r.add("FAIL" if s >= t["salesforce_daily_api"]["fail"] else ("WARN" if s >= t["salesforce_daily_api"]["warn"] else "PASS"), txt)
    hs = ac.get("hubspot_private_app_daily") or {}
    if num(hs.get("used")) is not None and num(hs.get("limit")):
        rated = True
        s = num(hs["used"]) / num(hs["limit"])
        r.add("WARN" if s >= t["hubspot_private_app_daily_warn"] else "PASS",
              "HubSpot daily API usage at %s of the account limit, which every app shares" % pct(num(hs["used"]), num(hs["limit"])))
    if not rated:
        r.nc("no API usage readings in api_capacity")


def int_05(c, r):
    if c.integrations is None:
        return r.nc("integrations missing from the inventory")
    days = c.cfg["INT-05"]["unused_token_days"]
    legacy = [i for i in c.integrations if i.get("legacy_private_app")]
    for i in c.integrations:
        if i.get("credential_tied_to_departed_person") and c.writes_int(i):
            r.add("FAIL", "%r runs on a credential tied to a departed person and still has write access" % i.get("name"))
        d = c.days_since(i.get("last_used_at"))
        if d is not None and d > days:
            r.add("WARN", "%r credential unused for %d days" % (i.get("name"), d))
    if legacy:
        no_owner = [i.get("name") for i in legacy if empty(i.get("owner"))]
        no_plan = [i.get("name") for i in legacy if empty(i.get("migration_plan"))]
        if no_owner or no_plan:
            r.add("WARN", "legacy private apps: %d (new ones cannot be created in existing accounts since 26 October 2026)%s%s" % (
                len(legacy), ("; no owner: " + ", ".join(no_owner)) if no_owner else "", ("; no migration plan: " + ", ".join(no_plan)) if no_plan else ""))
    if c.settings.get("salesforce_dx_mcp_configured"):
        r.add("WARN", "the Salesforce DX local MCP server is still configured; it reached end of life on 2 November 2026 and its repositories are archived")
    if not r.items:
        r.add("PASS", "credentials are current and owned")


def int_06(c, r):
    if c.integrations is None:
        return r.nc("integrations missing from the inventory")
    s = c.settings
    if s.get("smart_transfer_active") and s.get("native_salesforce_integration_active"):
        r.add("FAIL", "HubSpot Smart Transfer and the native Salesforce integration are both active")
    rot = [a.get("name") for a in c.enabled() if a.get("owner_writes")]
    own = [i.get("name") for i in c.integrations if i.get("writes_owner") or any(n in c.owner_fields for _, n in c.int_fields(i))]
    if rot and own:
        r.add("FAIL", "owner rotation (%s) and %s both write owners on the same records" % (", ".join(rot), ", ".join(own)))
    if s.get("inclusion_segment_used_as_security"):
        r.add("WARN", "a Salesforce inclusion segment is treated as a security boundary")
    if s.get("sync_filters_assumed_exclusion"):
        r.add("WARN", "data sync filters are assumed to exclude records after the first sync; they only limit the initial sync")
    if not r.items:
        r.add("PASS", "no known conflicting combinations")


def int_07(c, r):
    if c.integrations is None:
        return r.nc("integrations missing from the inventory")
    seen = 0
    for i in c.integrations:
        t = str(i.get("type") or "").lower()
        lab = c.integration_label(i)
        if t == "enrichment":
            seen += 1
            mode = str(i.get("overwrite_mode") or "").lower()
            if mode == "overwrite":
                owned = []
                for o, f in c.int_fields(i):
                    cf = c.critical_for(o, f)
                    src = norm_writer(cf.get("authoritative_source")) if cf and cf.get("authoritative_source") else None
                    if (cf and src and src != lab) or f in c.owner_fields:
                        owned.append(f)
                if owned:
                    r.add("FAIL", "%r may overwrite %s, which another system or team owns" % (i.get("name"), ", ".join(sorted(set(owned)))))
                else:
                    r.add("WARN", "%r runs in overwrite mode; confirm none of its fields is owned elsewhere" % i.get("name"))
            elif mode in ("fill_empty", "fill-empty") and empty(i.get("provenance_field")):
                r.add("WARN", "%r fills empty values only but keeps no provenance or observed-at field" % i.get("name"))
        if t in ("reverse_etl", "reverse-etl"):
            seen += 1
            if i.get("clears_on_missing_rows"):
                others = set()
                for _, f in c.int_fields(i):
                    for j in c.integrations:
                        if j is not i and f in [n for _, n in c.int_fields(j)]:
                            others.add(f)
                    if any(str(w.get("field") or "").lower() == f for a in c.enabled() for w in a.get("writes") or []):
                        others.add(f)
                    cf = c.critical_for(None, f)
                    if cf and cf.get("authoritative_source") and norm_writer(cf["authoritative_source"]) != lab:
                        others.add(f)
                if others:
                    r.add("FAIL", "%r clears values when rows are missing, including %s, which other writers own or also write" % (i.get("name"), ", ".join(sorted(others))))
                elif not i.get("row_count_guard"):
                    r.add("WARN", "%r clears its own fields when rows are missing and has no guard against an empty model run" % i.get("name"))
            if i.get("feeds_automation") and empty(i.get("freshness_field")):
                r.add("WARN", "%r syncs facts that automations act on with no freshness field" % i.get("name"))
    if not r.items:
        r.add("PASS", "no enrichment or reverse ETL sync can overwrite owned fields" if seen else "no enrichment or reverse ETL integration")


def rep_01(c, r):
    metrics = (c.reporting or {}).get("metrics")
    if metrics is None:
        return r.nc("reporting.metrics missing (from metrics.md)")
    heads = [m for m in metrics if m.get("headline")]
    if not heads:
        return r.nc("no headline metrics listed")
    for m in heads:
        core = [k for k in ("counting_unit", "timestamp") if empty(m.get(k))]
        if core:
            r.add("FAIL", "%r has no written %s" % (m.get("name"), " or ".join(core).replace("_", " ")))
        extra = [k for k in ("timezone", "exclusions") if empty(m.get(k))]
        if m.get("money") and empty(m.get("currency_policy")):
            extra.append("currency_policy")
        if extra:
            r.add("WARN", "%r lacks %s" % (m.get("name"), ", ".join(x.replace("_", " ") for x in extra)))
    if not r.items:
        r.add("PASS", "%d headline metrics fully defined" % len(heads))


def rep_02(c, r):
    rep = c.reporting or {}
    recs, heads = rep.get("reconciliations"), rep.get("headline_numbers")
    if not recs and not heads:
        return r.nc("no reconciliations or headline numbers recorded (reporting.reconciliations)")
    tol = c.cfg["REP-02"]["reconcile_tolerance"]
    done = set()  # metrics whose reconciliation ran or already carries a WARN
    for x in recs or []:
        dv, rv = num(x.get("dashboard_value")), num(x.get("record_list_value"))
        done.add(alnum(x.get("metric")))
        if dv is None or not rv:
            r.add("WARN", "reconciliation not run for %s: the dashboard value or the record list value is missing, "
                          "or the record list value is zero" % x.get("metric"))
            continue
        diff = abs(dv - rv) / abs(rv)
        txt = "%s: dashboard %s against %s over the record list (%.1f%%)" % (x.get("metric"), fmt_num(dv), fmt_num(rv), 100 * diff)
        if diff > tol and empty(x.get("explained_cause")):
            r.add("FAIL", txt + ", no explained cause")
        elif diff > tol:
            r.add("PASS", txt + "; explained: " + str(x.get("explained_cause")))
        else:
            r.add("PASS", txt + ", within %.0f%%" % (100 * tol))
    for h in heads or []:
        if alnum(h) not in done:
            r.add("WARN", "reconciliation not run for %s" % h)


def rep_03(c, r):
    reports = (c.reporting or {}).get("reports")
    if not reports:
        return r.nc("no headline reports listed (reporting.reports)")
    for x in reports:
        n = x.get("name")
        if x.get("sums_amount_after_join"):
            r.add("FAIL", "%r sums deal amount after joining to %s" % (n, x["sums_amount_after_join"]))
        if x.get("report_type_drops_parents"):
            r.add("FAIL", "%r uses a 'with' report type that drops parents the metric needs" % n)
        if x.get("unique_count_on_names"):
            r.add("FAIL", "%r counts unique names instead of IDs" % n)
        if x.get("grain_stated") is False:
            r.add("WARN", "%r does not state its grain in the title or description" % n)
    if not r.items:
        r.add("PASS", "%d headline reports count at the right grain" % len(reports))


def rep_04(c, r):
    s = c.settings
    cur, multi = list(s.get("currencies") or []), s.get("multi_currency")
    if not cur and multi is None:
        return r.nc("currency settings not captured (settings.currencies, settings.multi_currency)")
    if s.get("amounts_summed_without_conversion"):
        r.add("FAIL", "amounts in different currencies are summed without conversion")
    if s.get("company_currency_changed_after_deals") and not s.get("currency_conversion_documented"):
        r.add("FAIL", "company currency changed after deals existed, with no documented conversion")
    many = bool(multi) or len(cur) > 1
    if many and not s.get("exchange_rate_policy_documented"):
        r.add("WARN", "multi-currency is on with no documented exchange-rate policy")
    if not r.items:
        r.add("PASS", "multi-currency with a documented exchange-rate policy" if many else
              "single currency%s" % ((" (%s)" % cur[0]) if cur else ""))


def rep_05(c, r):
    t = c.cfg["REP-05"]
    a = check_ratio(r, c.counts.get("open_deals_past_close_date"), "open deals with a past close date", t["past_close_date"]["warn"], t["past_close_date"]["fail"])
    b = check_ratio(r, c.counts.get("forecast_deals_missing_amount"), "forecast-stage deals without an amount", t["missing_amount"]["warn"], t["missing_amount"]["fail"])
    d = check_ratio(r, c.counts.get("lost_deals_with_reason"), "deals closed lost in %d days with a reason" % t["lookback_days"],
                    t["lost_reason_fill"]["warn"], t["lost_reason_fill"]["fail"], below=True)
    if a is None and b is None and d is None and not r.nc_reasons:
        r.nc("counts.open_deals_past_close_date, forecast_deals_missing_amount and lost_deals_with_reason missing")


def rep_06(c, r):
    t = c.cfg["REP-06"]
    a = check_ratio(r, c.counts.get("recent_contacts_unknown_source"), "contacts created in %d days with an unknown or offline source" % t["lookback_days"],
                    t["unknown_source"]["warn"], t["unknown_source"]["fail"])
    if (c.reporting or {}).get("deal_source_snapshot") is False:
        r.add("WARN", "deals have no source snapshot taken at creation")
    if a is None and not r.items and not r.nc_reasons:
        r.nc("counts.recent_contacts_unknown_source missing")


def rep_07(c, r):
    rep = c.reporting
    if not isinstance(rep, dict):
        return r.nc("reporting missing from the inventory")
    keys = ("test_exclusion_rule_documented", "funnel_on_custom_field_without_timestamps", "reentry_rule_documented")
    if all(rep.get(k) is None for k in keys):
        return r.nc("exclusion and funnel settings not recorded")
    if rep.get("test_exclusion_rule_documented") is False:
        r.add("WARN", "no single documented rule excludes test and internal records in both the CRM and analytics")
    if rep.get("funnel_on_custom_field_without_timestamps"):
        r.add("WARN", "a lifecycle funnel is built on a custom field without stage timestamps")
    if rep.get("reentry_rule_documented") is False:
        r.add("WARN", "the funnel re-entry rule is not documented")
    if not r.items:
        r.add("PASS", "one exclusion rule, timestamped stages and a documented re-entry rule")
