"""
CA Automator — Forensic Audit & Investigation computation engines.

Pure functions only (no DB access), mirrored line-for-line in
web/forensic_engines.js — same discipline as tp_engines.py. Sixteen named
detectors reduce to a handful of real algorithms operating on one normalized
row shape: {date, reference_no, party, description, amount, account, actor,
extra}. Every function here produces a *screening result* for a qualified
professional to review, never a finding of fraud — that framing belongs in
every result's own text, not just this docstring. Beneish M-Score is the one
well-published external methodology used as-is (Beneish, M.D. (1999), "The
Detection of Earnings Manipulation", Financial Analysts Journal) — cited,
not invented.
"""
import math
from collections import defaultdict
from datetime import datetime

R2 = lambda x: round(float(x or 0), 2)


def _parse_date(s):
    if not s:
        return None
    for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y"):
        try:
            return datetime.strptime(s.strip(), fmt)
        except (ValueError, AttributeError):
            continue
    return None


# ------------------------------------------------------------- Benford's Law

BENFORD_EXPECTED = {d: math.log10(1 + 1 / d) for d in range(1, 10)}


def _leading_digit(x):
    x = abs(float(x or 0))
    if x == 0:
        return None
    while x < 1:
        x *= 10
    while x >= 10:
        x /= 10
    return int(x)


def benford_analysis(amounts):
    """Nigrini's MAD (Mean Absolute Deviation) conformity bands for a
    first-digit test: <0.006 close conformity, <0.012 acceptable,
    <0.015 marginally acceptable, otherwise nonconformity. A nonconforming
    population is a screening signal to look closer, not proof of anything —
    genuine skewed populations (e.g. mostly round contract values) can fail
    Benford for entirely innocent reasons."""
    counts = {d: 0 for d in range(1, 10)}
    for a in amounts:
        d = _leading_digit(a)
        if d:
            counts[d] += 1
    n = sum(counts.values())
    if n < 30:
        return {"count": n, "expected": None, "actual": None, "counts": counts, "mad": None,
                "conformity": None, "note": "Fewer than 30 usable values — Benford's Law is not meaningful on a sample this small."}
    actual = {d: counts[d] / n for d in range(1, 10)}
    mad = sum(abs(actual[d] - BENFORD_EXPECTED[d]) for d in range(1, 10)) / 9
    if mad < 0.006:
        conformity = "Close conformity"
    elif mad < 0.012:
        conformity = "Acceptable conformity"
    elif mad < 0.015:
        conformity = "Marginally acceptable"
    else:
        conformity = "Nonconformity"
    return {"count": n, "expected": {d: R2(v * 100) for d, v in BENFORD_EXPECTED.items()},
            "actual": {d: R2(v * 100) for d, v in actual.items()}, "counts": counts,
            "mad": round(mad, 5), "conformity": conformity,
            "note": "A screening signal, not proof — genuinely skewed populations can fail Benford's Law innocently."}


# -------------------------------------------------------------- duplicates

def detect_duplicates(rows, key_fields):
    """rows: list of dicts with at least the fields named in key_fields.
    Returns clusters of 2+ rows sharing the same key — e.g. key_fields=
    ['party','amount'] for duplicate payments, ['party','reference_no'] for
    duplicate invoices. Rows with a blank value in any key field are
    excluded (nothing to compare)."""
    groups = defaultdict(list)
    for i, r in enumerate(rows):
        key = tuple((r.get(f) or "").strip().lower() if isinstance(r.get(f), str) else r.get(f) for f in key_fields)
        if any(v in (None, "") for v in key):
            continue
        groups[key].append(i)
    return [{"key": dict(zip(key_fields, k)), "row_indices": idxs, "count": len(idxs)}
            for k, idxs in groups.items() if len(idxs) > 1]


# ----------------------------------------------------------- round numbers

def detect_round_numbers(rows, round_to=1000, min_amount=10000):
    out = []
    for i, r in enumerate(rows):
        amt = r.get("amount")
        if amt and abs(amt) >= min_amount and abs(amt) % round_to == 0:
            out.append(i)
    return out


# ----------------------------------------------------------- related party

def detect_related_party(rows, related_names):
    names = {n.strip().lower() for n in (related_names or []) if n and n.strip()}
    if not names:
        return []
    return [i for i, r in enumerate(rows) if (r.get("party") or "").strip().lower() in names]


# ------------------------------------------------------ journal entry score

def score_journal_entry(row):
    """A point-factor screen, same shape as the Transfer Pricing risk
    scorer — weekend/after-hours posting, suspiciously round amounts,
    missing narration or reference. Low(<2)/Medium(<5)/High(>=5)."""
    factors = []

    def add(name, hit, points, note):
        if hit:
            factors.append({"factor": name, "points": points, "note": note})

    d = _parse_date(row.get("date"))
    add("Weekend posting", bool(d and d.weekday() >= 5), 2, "Posted on a Saturday or Sunday — unusual for routine entries.")
    amt = row.get("amount") or 0
    add("Round amount", bool(amt and abs(amt) % 1000 == 0 and abs(amt) >= 10000), 2,
        "A suspiciously round amount for a journal entry.")
    add("Missing narration", not (row.get("description") or "").strip(), 2, "No description recorded for this entry.")
    add("Missing reference", not (row.get("reference_no") or "").strip(), 1, "No reference/voucher number recorded.")
    total = sum(f["points"] for f in factors)
    level = "Low" if total < 2 else ("Medium" if total < 5 else "High")
    return {"factors": factors, "score": total, "risk_level": level}


# ------------------------------------------------------------------ payroll
# Covers both "Ghost Employee Detector" (duplicate bank/PAN, missing master
# data) and "Payroll Fraud Analyzer" (round-number pay, missing data) — the
# same underlying checks serve both named tools.

def analyze_payroll(rows):
    findings = []
    by_bank, by_pan = defaultdict(list), defaultdict(list)
    for i, r in enumerate(rows):
        extra = r.get("extra") or {}
        bank = (extra.get("bank_account") or "").strip()
        pan = (extra.get("pan_no") or "").strip()
        if bank:
            by_bank[bank].append(i)
        if pan:
            by_pan[pan].append(i)
    for bank, idxs in by_bank.items():
        if len(idxs) > 1:
            findings.append({"type": "duplicate_bank_account", "row_indices": idxs,
                             "detail": f"{len(idxs)} employees share bank account {bank} — possible ghost employee."})
    for pan, idxs in by_pan.items():
        if len(idxs) > 1:
            findings.append({"type": "duplicate_pan", "row_indices": idxs,
                             "detail": f"{len(idxs)} employees share PAN {pan} — possible ghost employee."})
    for i, r in enumerate(rows):
        extra = r.get("extra") or {}
        missing = [f for f in ("pan_no", "bank_account", "address") if not (extra.get(f) or "").strip()]
        if missing:
            findings.append({"type": "missing_employee_data", "row_indices": [i],
                             "detail": f"Missing {', '.join(missing)} — incomplete master data is a ghost-employee risk indicator."})
    for i in detect_round_numbers(rows, round_to=1000, min_amount=5000):
        findings.append({"type": "round_number_pay", "row_indices": [i], "detail": "Round-number gross pay figure."})
    return findings


# ------------------------------------------------------------------- vendor

def analyze_vendors(rows, employee_rows=None):
    findings = []
    by_bank = defaultdict(list)
    for i, r in enumerate(rows):
        bank = ((r.get("extra") or {}).get("bank_account") or "").strip()
        if bank:
            by_bank[bank].append(i)
    for bank, idxs in by_bank.items():
        if len(idxs) > 1:
            findings.append({"type": "duplicate_vendor_bank_account", "row_indices": idxs,
                             "detail": f"{len(idxs)} vendors share bank account {bank}."})
    if employee_rows:
        emp_banks = {((r.get("extra") or {}).get("bank_account") or "").strip() for r in employee_rows}
        emp_banks.discard("")
        emp_addresses = {((r.get("extra") or {}).get("address") or "").strip().lower() for r in employee_rows}
        emp_addresses.discard("")
        for i, r in enumerate(rows):
            extra = r.get("extra") or {}
            b = (extra.get("bank_account") or "").strip()
            addr = (extra.get("address") or "").strip().lower()
            if b and b in emp_banks:
                findings.append({"type": "vendor_matches_employee_bank", "row_indices": [i],
                                 "detail": "Vendor bank account matches an employee bank account — possible fictitious vendor."})
            if addr and addr in emp_addresses:
                findings.append({"type": "vendor_matches_employee_address", "row_indices": [i],
                                 "detail": "Vendor address matches an employee address — possible fictitious vendor."})
    return findings


# ------------------------------------------------------------------ expense

def analyze_expenses(rows, approval_threshold=None):
    findings = []
    if approval_threshold:
        for i, r in enumerate(rows):
            amt = r.get("amount") or 0
            gap = approval_threshold - amt
            if 0 < gap <= approval_threshold * 0.05:
                findings.append({"type": "just_under_threshold", "row_indices": [i],
                                 "detail": f"Amount {amt:,.2f} is just under the {approval_threshold:,.2f} approval threshold."})
    for i in detect_round_numbers(rows, round_to=500, min_amount=2000):
        findings.append({"type": "round_number_expense", "row_indices": [i], "detail": "Round-number expense claim."})
    for i, r in enumerate(rows):
        d = _parse_date(r.get("date"))
        if d and d.weekday() >= 5:
            findings.append({"type": "weekend_expense", "row_indices": [i], "detail": "Expense dated on a weekend."})
    for dup in detect_duplicates(rows, ["party", "amount", "date"]):
        findings.append({"type": "duplicate_expense_claim", "row_indices": dup["row_indices"],
                         "detail": "Same claimant, amount and date submitted more than once."})
    return findings


# ------------------------------------------------------------------ revenue

def analyze_revenue(rows, period_end_date=None):
    findings = []
    pe = _parse_date(period_end_date) if period_end_date else None
    if pe:
        for i, r in enumerate(rows):
            d = _parse_date(r.get("date"))
            amt = r.get("amount") or 0
            if d and 0 <= (pe - d).days <= 3 and amt > 0:
                findings.append({"type": "period_end_spike", "row_indices": [i],
                                 "detail": "Revenue recognised in the last 3 days of the period — check cut-off and channel-stuffing risk."})
    for i in detect_round_numbers(rows, round_to=100000, min_amount=500000):
        findings.append({"type": "round_number_revenue", "row_indices": [i], "detail": "Unusually round revenue figure."})
    return findings


# --------------------------------------------------------------------- cash

def analyze_cash(rows):
    findings = []
    for i in detect_round_numbers(rows, round_to=1000, min_amount=5000):
        r = rows[i]
        desc = (r.get("description") or "").lower()
        if (r.get("amount") or 0) < 0 or "withdraw" in desc or "cash" in desc:
            findings.append({"type": "round_cash_movement", "row_indices": [i], "detail": "Round-number cash movement."})
    return findings


# ---------------------------------------------------------- Beneish M-Score

def beneish_m_score(curr, prior):
    """curr/prior: {receivables, sales, cogs, current_assets, ppe,
    total_assets, depreciation, sga, current_liabilities, total_debt,
    net_income, cfo} — two periods of aggregate financial figures, the
    firm's own numbers, not statutory rates, so nothing here is UNVERIFIED
    the way a tax figure would be. The published formula and -1.78 cutoff
    are Beneish (1999)'s own — cited, not invented."""
    def sd(a, b):
        return a / b if (a is not None and b) else None

    dsri = sd(sd(curr.get("receivables"), curr.get("sales")), sd(prior.get("receivables"), prior.get("sales")))
    gm_c = sd((curr.get("sales") or 0) - (curr.get("cogs") or 0), curr.get("sales"))
    gm_p = sd((prior.get("sales") or 0) - (prior.get("cogs") or 0), prior.get("sales"))
    gmi = sd(gm_p, gm_c)
    aqi_c = (1 - sd((curr.get("current_assets") or 0) + (curr.get("ppe") or 0), curr.get("total_assets"))) if curr.get("total_assets") else None
    aqi_p = (1 - sd((prior.get("current_assets") or 0) + (prior.get("ppe") or 0), prior.get("total_assets"))) if prior.get("total_assets") else None
    aqi = sd(aqi_c, aqi_p)
    sgi = sd(curr.get("sales"), prior.get("sales"))
    depi_p = sd(prior.get("depreciation"), (prior.get("depreciation") or 0) + (prior.get("ppe") or 0))
    depi_c = sd(curr.get("depreciation"), (curr.get("depreciation") or 0) + (curr.get("ppe") or 0))
    depi = sd(depi_p, depi_c)
    sgai = sd(sd(curr.get("sga"), curr.get("sales")), sd(prior.get("sga"), prior.get("sales")))
    lvgi_c = sd((curr.get("current_liabilities") or 0) + (curr.get("total_debt") or 0), curr.get("total_assets"))
    lvgi_p = sd((prior.get("current_liabilities") or 0) + (prior.get("total_debt") or 0), prior.get("total_assets"))
    lvgi = sd(lvgi_c, lvgi_p)
    tata = sd((curr.get("net_income") or 0) - (curr.get("cfo") or 0), curr.get("total_assets"))

    components = {"dsri": dsri, "gmi": gmi, "aqi": aqi, "sgi": sgi, "depi": depi, "sgai": sgai, "lvgi": lvgi, "tata": tata}
    if any(v is None for v in components.values()):
        return {"m_score": None, "components": components,
                "note": "Some ratios could not be computed — check that every required figure was supplied for both periods."}
    m = (-4.84 + 0.920 * dsri + 0.528 * gmi + 0.404 * aqi + 0.892 * sgi + 0.115 * depi
         - 0.172 * sgai + 4.679 * tata - 0.327 * lvgi)
    interpretation = "Possible earnings manipulation indicated by this screen" if m > -1.78 else "No strong indication from this screen"
    return {"m_score": round(m, 3), "components": {k: round(v, 3) for k, v in components.items()},
            "threshold": -1.78, "interpretation": interpretation,
            "note": "Beneish (1999) M-Score — a statistical screen used in forensic accounting, not a conclusion of fraud. "
                    "Source: Beneish, M.D. (1999), 'The Detection of Earnings Manipulation', Financial Analysts Journal."}


# --------------------------------------------------------------- dispatcher

DETECTORS_BY_DATASET_TYPE = {
    "journal_entries": ["benford", "round_number", "unusual_je", "related_party"],
    "payments": ["benford", "duplicate_payment", "round_number", "related_party"],
    "invoices": ["benford", "duplicate_invoice", "round_number", "related_party"],
    "payroll": ["payroll_fraud"],
    "vendors": ["vendor_fraud"],
    "expenses": ["expense_fraud"],
}


def run_all_detectors(dataset_type, rows, params=None, related_rows=None):
    """rows: list of normalized dicts (date, reference_no, party, description,
    amount, account, actor, extra). related_rows: the payroll/vendor rows to
    cross-check against, when analyzing vendors. params: {related_party_names,
    round_to, min_amount, approval_threshold, period_end_date}. Returns a
    flat list of {detector, severity, title, description, row_indices,
    evidence}."""
    params = params or {}
    out = []
    amounts = [r.get("amount") for r in rows if r.get("amount")]
    applicable = DETECTORS_BY_DATASET_TYPE.get(dataset_type, [])

    if "benford" in applicable:
        b = benford_analysis(amounts)
        if b["conformity"] in ("Marginally acceptable", "Nonconformity"):
            out.append({"detector": "benford", "severity": "Medium" if b["conformity"] == "Marginally acceptable" else "High",
                        "title": f"Benford's Law — {b['conformity']}", "description": b["note"], "row_indices": [], "evidence": b})

    if "duplicate_payment" in applicable:
        for dup in detect_duplicates(rows, ["party", "amount"]):
            out.append({"detector": "duplicate_payment", "severity": "High",
                        "title": f"Possible duplicate payment — {dup['count']} occurrences", "row_indices": dup["row_indices"],
                        "description": f"Same party and amount appear {dup['count']} times.", "evidence": dup})

    if "duplicate_invoice" in applicable:
        for dup in detect_duplicates(rows, ["party", "reference_no"]):
            out.append({"detector": "duplicate_invoice", "severity": "High",
                        "title": f"Possible duplicate invoice — {dup['count']} occurrences", "row_indices": dup["row_indices"],
                        "description": f"Same party and reference number appear {dup['count']} times.", "evidence": dup})

    if "round_number" in applicable:
        idxs = detect_round_numbers(rows, params.get("round_to", 1000), params.get("min_amount", 10000))
        for i in idxs:
            out.append({"detector": "round_number", "severity": "Low", "title": "Round-number transaction",
                        "row_indices": [i], "description": "Amount is an unusually round figure.", "evidence": {}})

    if "related_party" in applicable and params.get("related_party_names"):
        idxs = detect_related_party(rows, params["related_party_names"])
        for i in idxs:
            out.append({"detector": "related_party", "severity": "Medium", "title": "Related-party transaction",
                        "row_indices": [i], "description": "Counterparty matches the related-party list supplied.", "evidence": {}})

    if "unusual_je" in applicable:
        for i, r in enumerate(rows):
            s = score_journal_entry(r)
            if s["risk_level"] != "Low":
                out.append({"detector": "unusual_je", "severity": s["risk_level"], "title": "Unusual journal entry",
                            "row_indices": [i], "description": "; ".join(f["note"] for f in s["factors"]), "evidence": s})

    if "payroll_fraud" in applicable:
        for f in analyze_payroll(rows):
            sev = "High" if f["type"] in ("duplicate_bank_account", "duplicate_pan") else "Medium"
            out.append({"detector": "payroll_fraud" if f["type"] not in ("duplicate_bank_account", "duplicate_pan") else "ghost_employee",
                        "severity": sev, "title": f["type"].replace("_", " ").title(), "row_indices": f["row_indices"],
                        "description": f["detail"], "evidence": {}})

    if "vendor_fraud" in applicable:
        for f in analyze_vendors(rows, related_rows):
            out.append({"detector": "vendor_fraud", "severity": "High", "title": f["type"].replace("_", " ").title(),
                        "row_indices": f["row_indices"], "description": f["detail"], "evidence": {}})

    if "expense_fraud" in applicable:
        for f in analyze_expenses(rows, params.get("approval_threshold")):
            sev = "High" if f["type"] == "duplicate_expense_claim" else "Medium" if f["type"] == "just_under_threshold" else "Low"
            out.append({"detector": "expense_fraud", "severity": sev, "title": f["type"].replace("_", " ").title(),
                        "row_indices": f["row_indices"], "description": f["detail"], "evidence": {}})

    return out
