"""Plan quality: rules Q01 to Q13 and the plan verdict (references/plan-quality.md).

Run through audit_plan.py quality. drift.py reuses quality_rules() and verdict() to set the drift depth.
"""
import json
import re
from collections import defaultdict

from common import (ROUTE_TOOLS, STATUSES, TOOL_COLUMNS, Plan, first_items, ga4_event_name_problem,
                    ga4_param_name_problem, is_money, is_pii_name, is_quantity, is_template_text, join_and,
                    lines_text, list_items, load_vendor, parse_date, plural, reserved_lists, summary_counts, t2_count)

# ---------- plan quality, Q01 to Q13 ----------

def rule(rid, name, total, unit, fails, warns, pass_text, note="", not_checked=None, bad=None):
    """One rule result. Offenders count once each: by the text before ':' unless bad names them."""
    if not_checked:
        return {"id": rid, "name": name, "severity": "NOT CHECKED", "message": not_checked}
    severity = "FAIL" if fails else ("WARN" if warns else "PASS")
    if bad is None:
        bad = {x.split(":")[0] for x in fails + warns}
    passed = max(total - len(bad), 0)
    parts = [f"{passed} of {total} {unit} pass"]
    if fails:
        parts.append("FAIL: " + first_items(fails))
    if warns:
        parts.append("WARN: " + first_items(warns))
    if severity == "PASS":
        parts = [f"{total} of {total} {unit} pass; {pass_text}"]
    if note:
        parts.append(note)
    return {"id": rid, "name": name, "severity": severity, "message": ". ".join(parts) + ".",
            "passed": passed, "total": total}


def quality_rules(plan, ga4_limit=25, reserved=None):
    """reserved: {"events": (names, prefixes), "params": (names, prefixes)} from the @ga4-reserved sections."""
    cols = plan.event_columns
    active = [e for e in plan.events.values() if e["status"] != "retired"]
    reserved = reserved or {}
    results = []

    def tier1(e):
        return e["tier"] == 1

    # Q01 contract core
    fails, warns = [], []
    intent = re.compile(r"(?i)^\s*(user\s+)?(click|clicked|clicks|submit|submits|submitted|open|opens|opened|load|"
                        r"loads|loaded|view|views|viewed|on click|button)")
    commit = re.compile(r"(?i)commit|persist|saved|stored|confirm|succe|complete|created|validated|accepted|"
                        r"processed|paid|returns")
    for e in active:
        row, missing = e["row"], []
        for field in ("trigger", "grain"):
            if is_template_text(row.get(field, "")):
                missing.append(field)
        if len(list_items(row.get("must_not_fire", ""))) < 2:
            commas = "," in row.get("must_not_fire", "")
            missing.append("must_not_fire (fewer than 2 items" + ("; separate items with ';', not commas)" if commas
                                                                   else ")"))
        explicit = [t for t in e["overrides"] if t not in e["derived"]]
        if not e["sources"] and not explicit:
            missing.append("sources")
        if e["tier"] is None:
            missing.append("risk_tier")
        if missing:
            (fails if tier1(e) else warns).append(f"{e['name']}: no {', '.join(missing)}")
        success = not re.search(r"_(started|clicked|viewed|opened|submitted|attempted|requested|failed|shown|loaded)$",
                                e["name"])
        trig = row.get("trigger", "")
        if success and intent.search(trig) and not commit.search(trig):
            warns.append(f"{e['name']}: intent trigger ('{trig[:40]}')")
    results.append(rule("Q01", "contract-core", len(active), "events", fails, warns,
                        "every event has a trigger, 2 or more must_not_fire items, a grain, sources and a tier"))

    # Q02 consumer
    fails, warns = [], []
    for e in active:
        if is_template_text(e["row"].get("consumer", "")):
            (fails if tier1(e) else warns).append(f"{e['name']}: no consumer")
    results.append(rule("Q02", "consumer", len(active), "events", fails, warns,
                        "every event names a metric, report, experiment or workflow"))

    # Q03 owners
    fails, warns = [], []
    roles_all = ("business_owner", "implementation_owner", "data_owner")
    for e in active:
        roles = [r for r in roles_all if not is_template_text(e["row"].get(r, ""))]
        if not roles:
            fails.append(f"{e['name']}: no owner")
        elif len(roles) < 3:
            warns.append(f"{e['name']}: no {', '.join(r for r in roles_all if r not in roles)}")
    results.append(rule("Q03", "owners", len(active), "events", fails, warns,
                        "business, implementation and data owners are named"))

    # Q04 identifiers
    fails, warns = [], []
    account_re = re.compile(r"\b(account_id|workspace_id|organization_id|org_id|company_id|group_id)\b")
    account_model = any(account_re.search(e["row"].get("identifiers", "")) for e in active)
    for e in active:
        ids = e["row"].get("identifiers", "")
        if re.search(r"(?i)user_id\s*[:=(]\s*(the\s+)?e-?mail", ids):
            fails.append(f"{e['name']}: user_id is an email")
        if not re.search(r"\b(event_id|occurrence_id)\b", ids):
            (fails if tier1(e) else warns).append(f"{e['name']}: no occurrence ID rule (event_id)")
        if not re.search(r"\b(user_id|actor_user_id|distinct_id)\b", ids):
            warns.append(f"{e['name']}: no actor ID")
        if account_model and not account_re.search(ids):
            warns.append(f"{e['name']}: no account or workspace at event time")
    results.append(rule("Q04", "identifiers", len(active), "events", fails, warns,
                        "occurrence, actor and account identifiers are stated" if account_model
                        else "occurrence and actor identifiers are stated"))

    # Q05 typed properties (blocking)
    if not plan.props:
        results.append({"id": "Q05", "name": "typed-properties", "severity": "FAIL", "blocking": True,
                        "message": "0 property rows; the plan defines no properties, so type, enum and "
                                   "completeness rules cannot run."})
    else:
        fails, warns = [], []
        no_type_column = "type" not in plan.prop_columns
        for p in plan.props:
            if p["type"]:
                continue
            label = f"{p['event']}.{p['name']}: " + (f"unknown type '{p['raw_type']}'" if p["raw_type"] else "no type")
            (fails if p["required"] or no_type_column else warns).append(label)
        r = rule("Q05", "typed-properties", len(plan.props), "property rows", fails, warns,
                 "every row has string, integer, number, boolean, timestamp, array or object")
        r["blocking"] = True
        results.append(r)

    # Q06 value rules
    warns = []
    for p in plan.props:
        gaps = []
        if p["type"] in (None, "string") and not p["format"].strip():
            gaps.append("no allowed values or format")
        if not p["policy"]:
            gaps.append("no unknown_value_policy")
        if not p["null_means"].strip():
            gaps.append("no null_means")
        if gaps:
            warns.append(f"{p['event']}.{p['name']}: {', '.join(gaps)}")
    results.append(rule("Q06", "value-rules", len(plan.props), "property rows", [], warns,
                        "allowed values or formats, unknown-value policies and null meanings are stated"))

    # Q07 one definition per event and per property (blocking)
    fails, warns, bad = [], [], set()
    for name, lines in plan.repeated_events().items():
        fails.append(f"event {name} is defined on {lines_text(lines)}")
        bad.add(("event", name))
    for (event, prop), lines in plan.repeated_props().items():
        fails.append(f"property {event}.{prop} is defined on {lines_text(lines)}")
        bad.add(("property", prop))
    unknown = plan.unknown_prop_events()
    if unknown:
        rows = sum(len(lines) for lines in unknown.values())
        fails.append(f"{plural(rows, 'property row')} {'names an event that is' if rows == 1 else 'name events that are'}"
                     " not in the events tab: "
                     + first_items([f"{ev} ({lines_text(lines)})" for ev, lines in unknown.items()], 4))
        bad.update(("unknown", ev) for ev in unknown)
    by_name = defaultdict(set)
    for p in plan.props:
        if p["type"]:
            by_name[p["name"]].add(p["type"])
    for n, t in sorted(by_name.items()):
        if len(t) > 1:
            fails.append(f"{n}: {' and '.join(sorted(t))}")
            bad.add(("property", n))
    for names in plan.event_collisions():
        warns.append(f"events {join_and(names)} differ only by case or separators")
        bad.update(("event", n) for n in names)
    total = len(plan.events) + len({p["name"] for p in plan.props}) + len(unknown)
    r = rule("Q07", "one-definition", total, "event and property names", fails, warns,
             "each event and each property is defined once, every property row names a planned event, and each "
             "property name has one type", note="Compare shared names with data-dictionary.md by hand", bad=bad)
    r["blocking"] = True
    results.append(r)

    # Q08 destination names (blocking)
    name_columns = [t for t, c in TOOL_COLUMNS.items() if c in cols]
    if "delivery_paths" not in cols and not name_columns:
        r = {"id": "Q08", "name": "destination-names", "severity": "NOT CHECKED", "blocking": True,
             "message": "no delivery_paths or tool name columns; drift compares canonical names."}
    else:
        fails, warns, bad = [], [], set()
        finals = defaultdict(list)  # one final name per tool must come from one active event
        for e in active:
            for tool in name_columns:
                fn = plan.final_name(e, tool)[0]
                if fn:
                    finals[(tool, fn)].append(e["name"])
        for (tool, fn), events in finals.items():
            if len(events) > 1:
                fails.append(f"{tool} name {fn} is mapped from events {join_and(events)}")
                bad.update(events)
        for e in active if "delivery_paths" in cols else []:
            dests = set()
            for part in e["row"].get("delivery_paths", "").split(";"):
                word = part.strip().lower().split(" via ")[0].split(" ")[0] if part.strip() else ""
                if word in TOOL_COLUMNS:
                    dests.add(word)
            for tool in sorted(dests):
                if plan.final_name(e, tool)[0] is None or TOOL_COLUMNS[tool] not in cols:
                    fails.append(f"{e['name']}: routed to {tool} but {TOOL_COLUMNS[tool]} is empty")
                    bad.add(e["name"])
            for tool in name_columns:
                if tool in ROUTE_TOOLS or tool in dests:
                    continue
                if plan.final_name(e, tool)[0]:
                    warns.append(f"{e['name']}: {TOOL_COLUMNS[tool]} is set but delivery_paths does not list {tool}")
                    bad.add(e["name"])
        routes = "" if "delivery_paths" in cols else ("No delivery_paths column, so routes were not compared with the "
                                                      "name columns; drift maps each audited tool from its name column")
        r = rule("Q08", "destination-names", len(active), "events", fails, warns,
                 "no two active events share a final name in one tool" + (
                     ", and every tool in delivery_paths has a final name" if "delivery_paths" in cols else ""),
                 note=routes, bad=bad)
        r["blocking"] = True
    results.append(r)

    # Q09 destination limits and names (GA4)
    if "ga4_name" not in cols:
        results.append(rule("Q09", "destination-limits", 0, "events", [], [], "", not_checked="no ga4_name column."))
    else:
        event_reserved = reserved.get("events") or (set(), [])
        param_reserved = reserved.get("params") or (set(), [])
        fails, mapped, reserved_hit = [], 0, False
        for e in active:
            name = plan.final_name(e, "ga4")[0]
            if not name:
                continue
            mapped += 1
            if len(name) > 40:
                fails.append(f"{e['name']}: GA4 name {name} is {len(name)} characters, over 40")
            problem = ga4_event_name_problem(name, event_reserved)
            if problem:
                fails.append(f"{e['name']}: GA4 name {name} {problem}")
                reserved_hit = reserved_hit or "reserves" in problem
            params = {Plan.tool_prop_name(p, "ga4") for p in plan.props_for(e["name"], "ga4")}
            if len(params) > ga4_limit:
                fails.append(f"{e['name']}: {len(params)} GA4 parameters planned, over {ga4_limit}")
            for pn in sorted(params):
                problem = ga4_param_name_problem(pn, param_reserved)
                if problem:
                    fails.append(f"{e['name']}: GA4 parameter {pn} {problem}")
        notes = ["Parameters the tag adds are not counted; take them from one DebugView capture"]
        if reserved_hit:
            notes.append("a reserved name is allowed only for Google's own event with its documented meaning, "
                         "recorded as an exception in decisions.md")
        if not (event_reserved[0] or event_reserved[1]):
            notes.append("the vendor events list has no @ga4-reserved section, so reserved event names were not "
                         "checked")
        if not param_reserved[1]:
            notes.append("the vendor parameters list has no @ga4-reserved section, so reserved parameter prefixes "
                         "were not checked")
        results.append(rule("Q09", "destination-limits", mapped, "GA4 events", fails, [],
                            "GA4 names are 40 characters or fewer, follow GA4's naming rule and are not reserved, "
                            f"and each event plans {ga4_limit} parameters or fewer, none reserved",
                            note="; ".join(notes)))

    # Q10 lifecycle
    fails, warns = [], []
    for e in plan.events.values():
        if e["status"] not in STATUSES:
            fails.append(f"{e['name']}: status '{e['row'].get('status', '')}' is not one of {', '.join(STATUSES)}")
        if "version" in cols and not re.fullmatch(r"\d+", e["row"].get("version", "")):
            warns.append(f"{e['name']}: version is not an integer")
        if (e["status"] in ("deprecated", "retired") and "status_changed_at" in cols
                and not parse_date(e["row"].get("status_changed_at", ""))):
            warns.append(f"{e['name']}: {e['status']} without a status_changed_at date")
    notes = []
    if "version" not in cols:
        notes.append("no version column")
    if "status_changed_at" not in cols and any(e["status"] in ("deprecated", "retired") for e in plan.events.values()):
        notes.append("no status_changed_at column for deprecated or retired events")
    results.append(rule("Q10", "lifecycle", len(plan.events), "events", fails, warns,
                        "statuses are valid, versions are integers and retired rows keep their dates",
                        note="; ".join(notes)))

    # Q11 privacy
    fails, warns = [], []
    for p in plan.props:
        if is_pii_name(p["name"]):
            fails.append(f"{p['event']}.{p['name']}: named like personal data")
    for e in active:
        ids = e["row"].get("identifiers", "")
        if not re.search(r"\b(user_id|anonymous_id|distinct_id|email)\b", ids):
            continue
        if "consent_category" in cols and is_template_text(e["row"].get("consent_category", "")):
            warns.append(f"{e['name']}: no consent_category")
        if "pii_exclusions" in cols and is_template_text(e["row"].get("pii_exclusions", "")):
            warns.append(f"{e['name']}: no pii_exclusions")
    absent = [c for c in ("consent_category", "pii_exclusions") if c not in cols]
    results.append(rule("Q11", "privacy", len(plan.props), "property rows", fails, warns,
                        "no property is named like personal data",
                        note=(f"No {' or '.join(absent)} column; check the contracts" if absent else "")))

    # Q12 money and units
    warns, counted = [], 0
    for p in plan.props:
        money, quantity = is_money(p["name"], p["unit"]), is_quantity(p["name"])
        if not money and not quantity:
            continue
        counted += 1
        gaps = []
        if not p["unit"].strip():
            gaps.append("no unit")
        if money:
            siblings = {q["name"] for q in plan.props if q["event"] in (p["event"], "*")}
            if not any(s == "currency" or s.endswith("_currency") for s in siblings) and p["event"] != "*":
                gaps.append("no ISO 4217 currency property on the event")
            if not p["description"].strip():
                gaps.append("no business definition")
        if gaps:
            warns.append(f"{p['event']}.{p['name']}: {', '.join(gaps)}")
    results.append(rule("Q12", "money-and-units", counted, "money or quantity rows", [], warns,
                        "units, currency and definitions are stated" if counted
                        else "no money or quantity properties"))

    # Q13 acceptance tests
    fails, warns = [], []
    for e in active:
        tests = e["row"].get("acceptance_tests", "")
        if is_template_text(tests):
            (fails if tier1(e) else warns).append(f"{e['name']}: no acceptance tests")
            continue
        found, expected = t2_count(tests), len(list_items(e["row"].get("must_not_fire", "")))
        if found is not None and expected and found != expected:
            warns.append(f"{e['name']}: {found} negative cases for {expected} must_not_fire items")
    results.append(rule("Q13", "acceptance", len(active), "events", fails, warns,
                        "tests are named and negative cases match must_not_fire one to one",
                        note="The agent still judges whether each negative case tests its must_not_fire item"))
    return results


def verdict(results, plan):
    sev = {r["id"]: r["severity"] for r in results}
    if sev.get("Q05") == "FAIL" or sev.get("Q08") == "FAIL":
        why = []
        if sev.get("Q05") == "FAIL":
            why.append("Q05 fails: type, enum and completeness rules run only where a row has a type, a required "
                       "flag or allowed values")
        if sev.get("Q08") == "FAIL":
            why.append("Q08 fails: drift cannot tie every observed name to one planned event in each tool until "
                       "the destination names are fixed")
        return "names only", "; ".join(why)
    core_fail = any(sev.get(q) == "FAIL" for q in ("Q01", "Q02", "Q03", "Q04"))
    q07 = sev.get("Q07") == "FAIL"
    untiered = [e["name"] for e in plan.events.values() if e["tier"] is None and e["status"] != "retired"]
    if core_fail or untiered or q07:
        why = []
        if core_fail or untiered:
            why.append(("triggers, sources, consumers, owners or identifiers are missing for tier 1 events"
                        if core_fail else "some events have no risk tier")
                       + "; source and ratio rules need those fields from the owners")
        if q07:
            why.append("Q07 fails: a repeated or conflicting definition must be settled before its rules mean "
                       "anything")
        return "schema only", "; ".join(why)
    return "contract", "all drift rules run"


def print_rules(results):
    for r in results:
        print(f"{r['severity']} {r['id']} {r['name']}: {r['message']}")


def reserved_names(args):
    """The @ga4-reserved event and parameter names and prefixes from the vendor lists in use."""
    return {"events": reserved_lists(load_vendor(args.vendor_events)),
            "params": reserved_lists(load_vendor(args.vendor_params))}


def cmd_quality(args):
    plan = Plan(args.events, args.properties)
    results = quality_rules(plan, args.ga4_param_limit, reserved_names(args))
    label, why = verdict(results, plan)
    if args.json:
        print(json.dumps({"rules": results, "verdict": label, "verdict_reason": why}, indent=2))
    else:
        print(f"Tracking plan quality: {len(plan.events)} events, {len(plan.props)} property rows")
        print_rules(results)
        print(f"Plan verdict: {label} ({why}).")
        print(summary_counts(results))
    return 1 if any(r["severity"] == "FAIL" for r in results) else 0
