"""fit.py - score jobs against a local candidate profile (the pack's fit rubric). Python 3.9+, stdlib only.
usage: python3 fit.py candidate-profile.json jobs.json > fit.json     (jobs.json from fetch.py, include_beta=true)

The same file is the server's fit logic (the MCP `fit_report` tool imports it), so the two cannot disagree.
"""
import json, math, re, sys

try:                                                        # the shared source-contradiction detector (R14-F01)
    from . import exclusions as _excl
except ImportError:
    try:
        import exclusions as _excl                          # run from the openrolesindex/ directory
    except ImportError:
        _excl = None                                        # standalone copy of this file: no contradiction check

ORDER = ["strong", "good", "reach", "stretch", "unscored", "excluded"]
TO_YEAR = {"HOUR": 2080, "DAY": 260, "WEEK": 52, "MONTH": 12, "YEAR": 1}   # the API's own annualisation factors


def norm(s):
    return " ".join(str(s or "").lower().replace("’", "'").replace("-", " ").split())


def names(label):
    """-> the forms a label is known by: without bracketed suffixes ("[Required]") and parenthesised parts,
    plus each parenthesised abbreviation on its own ("Basic Life Support (BLS)" -> "basic life support", "bls")."""
    raw = str(label or "")
    out = {norm(re.sub(r"\[[^\]]*\]|\([^)]*\)", " ", raw))}
    for inner in re.findall(r"\(([^)]*)\)", raw):
        if re.fullmatch(r"[A-Za-z][A-Za-z0-9&/.+-]{1,11}", inner.strip()):
            out.add(norm(inner))
    return {n for n in out if n}


def best_claim(req, claims, synonyms):
    """-> (claim, method): exact, then a parenthesised abbreviation the posting itself gives, then the profile's
    synonym list, then containment. Only exact and abbreviation are the data's; the rest are judgements."""
    req_names = names(req.get("label"))
    base = norm(re.sub(r"\[[^\]]*\]|\([^)]*\)", " ", str(req.get("label") or "")))
    same = [c for c in claims if c.get("kind") == req.get("kind")]
    rank = lambda c: (c.get("years") or 0, c.get("level") or 0)
    alts = {norm(a) for key in (req.get("label"), base) for a in synonyms.get(key, [])}

    def within(a, b):                                       # a is inside b as whole words: "cen" is not inside "license"
        return bool(re.search(r"(?<![a-z0-9])" + re.escape(a) + r"s?(?![a-z0-9])", b))

    def contains(c):
        return any(len(a) > 2 and len(b) > 2 and (within(a, b) or within(b, a)) for a in names(c["label"]) for b in req_names)
    for method, test in (("exact", lambda c: norm(c["label"]) == base or norm(c["label"]) == norm(req.get("label"))),
                         ("abbreviation", lambda c: bool(names(c["label"]) & req_names)),
                         ("synonym", lambda c: norm(c["label"]) in alts),
                         ("judgement", contains)):
        hits = [c for c in same if test(c)]
        if hits:
            return max(hits, key=rank), method
    return None, None


def thresholds_met(req, c):
    fails = []
    if req.get("min_years") is not None and (c.get("years") or 0) < req["min_years"]:
        fails.append(f"asks {req['min_years']}+ years, profile has {c.get('years') or 0}")
    if req.get("level") is not None and (c.get("level") or 0) < req["level"]:
        fails.append(f"asks level {req['level']}, profile has {c.get('level') or 'none'} (posting level is indicative)")
    return fails


def km(a_lat, a_lng, b_lat, b_lng):
    p = math.pi / 180
    h = (math.sin((b_lat - a_lat) * p / 2) ** 2 +
         math.cos(a_lat * p) * math.cos(b_lat * p) * math.sin((b_lng - a_lng) * p / 2) ** 2)
    return 12742 * math.asin(math.sqrt(h))


def convert(value, unit, to, flags):
    if value is None or unit == to:
        return value
    if unit in TO_YEAR and to in TO_YEAR:
        flags.append(f"pay converted {unit}->{to} (my arithmetic, {TO_YEAR['HOUR']} h/year)")
        return value * TO_YEAR[unit] / TO_YEAR[to]
    return None


def hard_filters(job, cand, prefs):
    """-> (reasons it is excluded, flags about what could not be checked, distance in km or None)."""
    out, flags, dist = [], [], None
    arr = job.get("ai_work_arrangement")
    remote = arr in ("Remote OK", "Remote Solely")
    wanted = cand.get("work_arrangement") or []
    if wanted and arr and arr not in wanted:
        out.append(f"work arrangement is {arr}, you want {', '.join(wanted)}")
    if wanted and not arr:
        flags.append("work arrangement not stated")
    places = cand.get("locations") or []
    pts = list(zip(job.get("lats_derived") or [], job.get("lngs_derived") or []))
    for p in places:
        if p.get("lat") is not None and p.get("lng") is not None and pts:
            d = min(km(p["lat"], p["lng"], la, ln) for la, ln in pts)
            dist = d if dist is None else min(dist, d)
    if places and not remote and not cand.get("relocation"):
        derived = [norm(x) for x in job.get("locations_derived") or []]
        ok = False
        for p in places:
            if p.get("lat") is not None and p.get("radius_km") and pts:
                ok = ok or any(km(p["lat"], p["lng"], la, ln) <= p["radius_km"] for la, ln in pts)
            else:
                name = norm(p.get("city") or p.get("region") or p.get("country"))
                ok = ok or any(name and name in d for d in derived)
        if not derived and not pts:
            flags.append("no location on the posting")
        elif not ok:
            out.append("outside your locations: " + "; ".join(job.get("locations_derived") or []))
    types = cand.get("employment_types") or []
    if types and job.get("ai_employment_type") and not set(types) & set(job["ai_employment_type"]):
        out.append(f"employment type {', '.join(job['ai_employment_type'])}")
    floor = cand.get("pay_floor") or {}
    unit = job.get("ai_salary_unit_text")
    top = job.get("ai_salary_max_value") or job.get("ai_salary_value") or job.get("ai_salary_min_value")
    bottom = job.get("ai_salary_min_value") or job.get("ai_salary_value") or job.get("ai_salary_max_value")
    if floor.get("value") and top and unit:
        hi = convert(top, unit, floor.get("unit"), flags)
        lo = convert(bottom, unit, floor.get("unit"), [])
        if hi is None:
            flags.append(f"pay in {unit}, floor in {floor.get('unit')}: not compared")
        elif hi < floor["value"]:
            out.append(f"pay tops out at {top} per {unit}, below your floor")
        elif lo is not None and lo < floor["value"]:
            flags.append("your floor is inside the posted range; the bottom is below it")
    elif floor.get("value"):
        flags.append("pay not stated")
    text = norm(job.get("title")) + " | " + norm((job.get("source_fields") or {}).get("category"))
    for x in prefs.get("exclusions") or []:
        if norm(x) and norm(x) in text:
            out.append(f"matches your exclusion '{x}'")
    return out, flags, dist


def score(job, profile):
    cand, prefs = profile.get("candidate") or {}, profile.get("preferences") or {}
    claims, synonyms = profile.get("claims") or [], profile.get("synonyms") or {}
    r = {"id": job.get("id"), "title": job.get("title"), "url": job.get("url"), "matches": [], "partials": [],
         "gaps": [], "implied": [], "judgements": []}
    r["filters"], r["flags"], dist = hard_filters(job, cand, prefs)
    r["distance_km"] = round(dist, 1) if dist is not None else None
    top, unit = job.get("ai_salary_max_value") or job.get("ai_salary_value"), job.get("ai_salary_unit_text")
    r["pay_top_annual"] = round(top * TO_YEAR[unit]) if top and unit in TO_YEAR else None
    reqs = job.get("ai_requirements") or []
    counts = {"required": [0, 0], "preferred": [0, 0]}
    groups = {}                                             # group id -> [met?] of its members ("RN or LPN licence")
    # R14-F01: an alternative the posting itself rules out ("LPN applicants are not eligible") never satisfies its group.
    members = {}
    for q in reqs:
        if (q.get("group") or {}).get("id") and q.get("label"):
            members.setdefault((q["group"]["id"], q.get("importance")), []).append(q["label"])
    text = job.get("description_text") or ""
    # stored first (DATA-CORE gap 5): a record whose alternatives were checked is the truth; the detector only reads
    # records enriched before eligibility was stored
    stored, checked = {}, set()
    for q in reqs:
        gk = ((q.get("group") or {}).get("id"), q.get("importance"))
        if gk[0] and q.get("eligibility_checked"):
            checked.add(gk)
        if gk[0] and q.get("eligible") is False and isinstance(q.get("exclusion"), dict) and q.get("label"):
            stored.setdefault(gk, {})[q["label"]] = {"quote": q["exclusion"].get("quote"), "span": q["exclusion"].get("span")}
    ruled = {k: (stored.get(k, {}) if k in checked else (_excl.excluded(text, v) if (_excl and text) else {}))
             for k, v in members.items() if len(v) > 1}
    contradicted = {}                                       # (gid, importance) -> items supported only by a ruled-out alternative
    for q in reqs:
        c, method = best_claim(q, claims, synonyms)
        item = {"label": q.get("label"), "kind": q.get("kind"), "importance": q.get("importance"),
                "evidence": q.get("evidence")}
        gid = (q.get("group") or {}).get("id")
        if gid:
            item["group"] = gid
        if c:
            item.update(claim=c["label"], verification=c.get("verification", "self_reported"), method=method)
            if method in ("synonym", "judgement"):
                r["judgements"].append(f"{q.get('label')} ~ {c['label']} ({method})")
        if q.get("importance") == "implied":
            r["implied"].append(item)                       # reported, never scored
            continue
        bucket = counts.setdefault(q.get("importance"), [0, 0])
        bucket[1] += 1
        fails = thresholds_met(q, c) if c else ["no matching claim"]
        hit = (ruled.get((gid, q.get("importance"))) or {}).get(q.get("label")) if gid else None
        if hit and c and not fails:
            item["source_exclusions"] = [{"alternative": q.get("label"), "quote": hit["quote"], "span": hit["span"]}]
            fails = [f"the posting rules this out: \"{hit['quote']}\""]
            contradicted.setdefault((gid, q.get("importance")), []).append(item)
        if gid:
            groups.setdefault((gid, q.get("importance")), []).append(not fails)
        if not fails:
            bucket[0] += 1
            r["matches"].append(item)
        else:
            item["why"] = fails
            (r["partials"] if c else r["gaps"]).append(item)
    # An OR-group counts once: met when any member is; the unused alternatives leave the gaps (and one_cert_away).
    for (gid, imp), met in groups.items():
        bucket = counts[imp]
        bucket[1] -= len(met) - 1
        bucket[0] -= sum(met) - (1 if any(met) else 0)
        if any(met):
            for lst in (r["gaps"], r["partials"]):
                lst[:] = [g for g in lst if g.get("group") != gid]
        else:
            for it in contradicted.get((gid, imp), []):
                line = next((m.get("evidence") for m in reqs if m.get("label") == it["label"]), None) or it["label"]
                r["flags"].append(f"source_contradiction: the posting lists \"{line}\" but also says \"{it['source_exclusions'][0]['quote']}\"; "
                                  f"your claim '{it['claim']}' cannot satisfy the {imp} group")
    (rm, rt), (pm, pt) = counts["required"], counts["preferred"]
    r.update(required_met=rm, required_total=rt, preferred_met=pm, preferred_total=pt)
    unmet = rt - rm
    if r["filters"]:
        r["tier"] = "excluded"
    elif not reqs:
        r["tier"] = "unscored"                              # no ai_requirements: read the description instead
    elif unmet == 0:
        r["tier"] = "strong" if (pt == 0 or pm / pt >= 0.5) else "good"
    else:
        r["tier"] = "reach" if unmet == 1 else "stretch"
    return r


def sort_key(r):
    """Tier, then (within a tier) distance, then pay. Shift match is the agent's step: re-sort after reading shifts."""
    return (ORDER.index(r["tier"]), r["distance_km"] if r["distance_km"] is not None else float("inf"),
            -(r["pay_top_annual"] or 0), -(r["required_met"] / (r["required_total"] or 1)))


def report(profile, jobs):
    """-> the scored rows, strongest first. The script's output and the MCP `fit_report` tool are both this."""
    return sorted((score(j, profile) for j in (jobs if isinstance(jobs, list) else [jobs])), key=sort_key)


def one_cert_away(rows):
    """-> [{label, jobs, job_ids}]: certifications that are the ONLY unmet required item of a job (not excluded by
    a hard filter), most jobs first. "This certification unlocks N of these jobs"."""
    out = {}
    for r in rows:
        if r["tier"] == "excluded":
            continue
        unmet = [g for g in r["gaps"] + r["partials"] if g.get("importance") == "required" and not g.get("source_exclusions")]
        units = {}                                          # an unmet OR-group is one unit: "RN licence or LPN licence"
        for g in unmet:
            units.setdefault(g.get("group") or id(g), []).append(g)
        if len(units) == 1 and all(g.get("kind") == "certification" for g in unmet):
            label = " or ".join(g["label"] for g in unmet)
            hit = out.setdefault(label, {"label": label, "jobs": 0, "job_ids": []})
            hit["jobs"] += 1
            hit["job_ids"].append(r["id"])
    return sorted(out.values(), key=lambda c: (-c["jobs"], str(c["label"])))


def main(argv=None):
    argv = sys.argv[1:] if argv is None else argv
    if len(argv) != 2:
        sys.exit("usage: python3 fit.py candidate-profile.json jobs.json")
    with open(argv[0], encoding="utf-8") as f:
        profile = json.load(f)
    with open(argv[1], encoding="utf-8") as f:
        jobs = json.load(f)
    json.dump(report(profile, jobs), sys.stdout, indent=2, ensure_ascii=False)


if __name__ == "__main__":
    main()
