"""M6 + M7 smoke test — leftover return, reject-intake, terminal reject, run cancel.

Covers FR-MFG-14 (leftover return at the consumed weighted cost), FR-MFG-15 (downstream
reject pulls work back to the parent), FR-MFG-16 (terminal reject resets the run for a
rework pass) and FR-MFG-17 (cancel returns net consumed stock).

Self-contained: builds its own two-stage template and three separate runs, and drains the
warehouse first so FIFO costs are known. Run after m4 so the warehouse exists:

    python Backend/smoke/m4_stage_actions.py
    python Backend/smoke/m6_m7_leftover_rework_cancel.py

The assertions that matter most and are easiest to get silently wrong:
  * a FULL leftover return must leave returnedValue == consumedValue EXACTLY (no crumb)
  * reject-intake must DECREMENT the parent's transferredQty, not zero it
  * a terminal reject must PRESERVE consumedQty/consumedValue and plannedQty
  * a re-complete after rework must OVERWRITE producedQty, not add to it
  * a rework restart with an unchanged planned qty must consume NOTHING
"""

from __future__ import annotations

import sys

from smoke_common import bootstrap, drain_stock, seed_costed_stock

WAREHOUSE_CODE = "SMOKE-PRD"
TEMPLATE_CODE = "SMOKE-PT-M67"
TARGET = 10
RAW_QPB = 5          # 5 raw per batch -> 50 base units at target 10
UNIT_COST = 3.0
SEED = 4000


def on_hand(c, item, wh):
    return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"])


def ledger(c, run_id, source):
    return c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"]


def prod_reason(c, code):
    for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]:
        if r["code"] == code:
            return r["reasonCodeId"]
    sys.exit(f"FATAL: Production reason {code} not seeded.")


def ensure_template(c, raw, finished, uom):
    """Cut (entry) -> Assemble (terminal). Cut has the stock input we return leftovers from."""
    payload = {
        "code": TEMPLATE_CODE, "name": "Smoke M6/M7 line",
        "stages": [
            {"key": "tmp-cut", "name": "Cut", "estimatedMinutes": 10, "posX": 0, "posY": 0,
             "fieldDefs": [],
             "inputs": [{"source": "Stock", "itemId": raw, "qtyPerBatch": RAW_QPB}],
             "outputs": [{"key": "tmp-f", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}]},
            {"key": "tmp-asm", "name": "Assemble", "estimatedMinutes": 10, "posX": 400, "posY": 0,
             "fieldDefs": [],
             "inputs": [{"source": "Upstream", "fromOutputKey": "tmp-f", "qtyPerBatch": 1}],
             "outputs": [{"key": "tmp-c", "name": "Chair", "itemId": finished,
                          "qtyPerBatch": 1}]},
        ],
        "edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}],
    }
    existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
                     if t["code"] == TEMPLATE_CODE), None)
    if existing:
        h = c.get(f"/production-templates/{existing['templateId']}")
        r = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=h.etag)
        if r.status in (200, 409):
            return existing["templateId"]
        sys.exit(f"FATAL: could not update the template: {r.status} {r.body}")
    r = c.post("/production-templates", payload)
    if r.status != 201:
        sys.exit(f"FATAL: could not create the template: {r.status} {r.body}")
    return r.body["templateId"]


def new_run(c, tid, wh):
    r = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET, "warehouseId": wh})
    if r.status != 201:
        sys.exit(f"FATAL: could not create a run: {r.status} {r.body}")
    cut = next(s for s in r.body["stages"] if s["name"] == "Cut")
    asm = next(s for s in r.body["stages"] if s["name"] == "Assemble")
    return r.body, cut, asm


def main():
    c, chk, args = bootstrap(__doc__)
    print(f"API {args.api}")

    wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
               if w["code"] == WAREHOUSE_CODE), None)
    if wh is None:
        sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")

    items = c.get("/items?pageSize=5&status=Active").body["items"]
    raw, finished = items[0]["itemId"], items[1]["itemId"]
    uom = items[0]["baseUomId"]

    drain_stock(c, wh)
    seed_costed_stock(c, wh, [(raw, SEED, UNIT_COST)])
    tid = ensure_template(c, raw, finished, uom)
    consumed_units = RAW_QPB * TARGET      # 50
    consumed_value = consumed_units * UNIT_COST   # 150.00
    print(f"warehouse={wh} raw={raw} unitCost={UNIT_COST} consumesPerRun={consumed_units}")

    # =====================================================================
    # M6 — leftover return
    # =====================================================================
    chk.section("M6-1. Partial leftover return at the consumed weighted cost (FR-MFG-14)")
    run, cut, asm = new_run(c, tid, wh)
    rid = run["runId"]
    cut_in = cut["inputs"][0]["runInputId"]

    c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/start")
    before = on_hand(c, raw, wh)
    pool0 = float(c.get(f"/production-runs/{rid}").body["costPool"]["net"])
    chk.check("cost pool after start", pool0, consumed_value)

    ret = c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
                 {"qty": 5, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")})
    chk.status("return 5 of 50 consumed", ret, 200)
    if ret.status == 200:
        chk.check("returnedQty echoed", float(ret.body["returnedQty"]), 5.0)
        chk.check("returned at the consumed weighted cost",
                  float(ret.body["createdLayer"]["unitCost"]), UNIT_COST)
        chk.check("returnedValue = qty x weighted cost", float(ret.body["returnedValue"]), 15.0)
        chk.check("layer id populated", ret.body["createdLayer"]["layerId"] > 0, True)
        chk.check("pool reduced by the returned value",
                  float(ret.body["costPool"]["net"]), consumed_value - 15.0)
    chk.check("on-hand rose by the returned qty", on_hand(c, raw, wh), before + 5.0)

    prdl = ledger(c, rid, "PRDL")
    chk.check("one PRDL row", len(prdl), 1)
    if prdl:
        chk.check("PRDL direction is In", prdl[0]["direction"], "In")
        chk.check("PRDL has no bin (raw material, not the output bin)", prdl[0]["binId"], None)

    chk.section("M6-2. Guards")
    chk.status("return more than remains",
               c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
                      {"qty": 46, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}),
               422, "LEFTOVER_EXCEEDS_CONSUMED")
    chk.status("return with no reason code",
               c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover", {"qty": 1}),
               400, "REASON_CODE_REQUIRED")
    adj_reason = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"][0]["reasonCodeId"]
    chk.status("return with an Adjustment-context reason",
               c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
                      {"qty": 1, "reasonCodeId": adj_reason}),
               422)
    chk.status("return against an upstream (WIP) input",
               c.post(f"/production-runs/{rid}/inputs/{asm['inputs'][0]['runInputId']}/return-leftover",
                      {"qty": 1, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}),
               422)

    chk.section("M6-3. A FULL return must net the input to exactly zero")
    rest = c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
                  {"qty": 45, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")})
    chk.status("return the remaining 45", rest, 200)
    if rest.status == 200:
        chk.check("pool is exactly zero after a full return", float(rest.body["costPool"]["net"]), 0.0)
    detail = c.get(f"/production-runs/{rid}").body
    ci = detail["stages"][0]["inputs"][0]
    chk.check("returnedQty == consumedQty exactly",
              float(ci["returnedQty"]), float(ci["consumedQty"]))
    chk.check("returnedValue == consumedValue exactly",
              float(ci["returnedValue"]), float(ci["consumedValue"]))

    # Close this run out so the RUN_COST_CLOSED guard can be checked. Each step is asserted:
    # letting an intermediate call fail silently here previously made the *next* assertion
    # look like the bug.
    cut_out = next(s for s in detail["stages"] if s["name"] == "Cut")["outputs"][0]["runOutputId"]
    chk.status("close-out: complete Cut",
               c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/complete",
                      {"outputs": [{"runOutputId": cut_out, "producedQty": TARGET, "scrappedQty": 0}]}), 200)
    chk.status("close-out: approve Cut",
               c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/approve"), 200)
    asm_out = next(s for s in c.get(f"/production-runs/{rid}").body["stages"]
                   if s["name"] == "Assemble")["outputs"][0]["runOutputId"]
    chk.status("close-out: start Assemble",
               c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/start"), 200)
    chk.status("close-out: complete Assemble",
               c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/complete",
                      {"outputs": [{"runOutputId": asm_out, "producedQty": TARGET, "scrappedQty": 0}]}), 200)
    fin = c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/approve")
    chk.status("close-out: approve the terminal", fin, 200)
    if fin.status == 200:
        chk.check("run completed even though the pool was fully returned (cost 0)",
                  fin.body["runStatus"], "Completed")
        chk.check("zero-cost receipt still creates a layer", fin.body["receipt"]["layerId"] > 0, True)
    chk.status("return a leftover after the receipt closed the pool",
               c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
                      {"qty": 1, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}),
               409, "RUN_COST_CLOSED")

    # =====================================================================
    # M7a — reject-intake
    # =====================================================================
    chk.section("M7-1. Reject-intake pulls work back to the parent (FR-MFG-15)")
    run2, cut2, asm2 = new_run(c, tid, wh)
    rid2 = run2["runId"]
    c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/start")
    d2 = c.get(f"/production-runs/{rid2}").body
    cut2_out = next(s for s in d2["stages"] if s["name"] == "Cut")["outputs"][0]["runOutputId"]
    c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/complete",
           {"outputs": [{"runOutputId": cut2_out, "producedQty": TARGET, "scrappedQty": 0}]})
    start_at = next(s for s in c.get(f"/production-runs/{rid2}").body["stages"]
                    if s["name"] == "Cut")["actualStartAt"]
    c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/approve")

    after_approve = c.get(f"/production-runs/{rid2}").body
    a_cut = next(s for s in after_approve["stages"] if s["name"] == "Cut")
    a_asm = next(s for s in after_approve["stages"] if s["name"] == "Assemble")
    chk.check("child Ready before the reject", a_asm["status"], "Ready")
    chk.check("parent transferred the full quantity",
              float(a_cut["outputs"][0]["transferredQty"]), float(TARGET))

    rej = c.post(f"/production-runs/{rid2}/stages/{asm2['runStageId']}/reject-intake",
                 {"note": "Frames warped"})
    chk.status("reject-intake on the Ready child", rej, 200)
    if rej.status == 200:
        chk.check("rejecting stage back to Waiting", rej.body["status"], "Waiting")
        chk.check("one parent pulled back", len(rej.body["pulledBack"]), 1)
        pb = rej.body["pulledBack"][0]
        chk.check("pulled-back qty", float(pb["qty"]), float(TARGET))
        chk.check("parent was Approved", pb["priorParentStatus"], "Approved")
        chk.check("parent reverted to InProgress", pb["parentStatus"], "InProgress")

        r_cut = next(s for s in rej.body["run"]["stages"] if s["name"] == "Cut")
        r_asm = next(s for s in rej.body["run"]["stages"] if s["name"] == "Assemble")
        chk.check("parent transferredQty decremented to 0",
                  float(r_cut["outputs"][0]["transferredQty"]), 0.0)
        chk.check("parent available to transfer restored",
                  float(r_cut["outputs"][0]["availableToTransfer"]), float(TARGET))
        chk.check("child deliveredQty cleared", float(r_asm["inputs"][0]["deliveredQty"]), 0.0)
        chk.check("parent ActualStartAt PRESERVED (FR-MFG-19)", r_cut["actualStartAt"], start_at)
        chk.check("parent ActualEndAt cleared for rework", r_cut["actualEndAt"], None)
        chk.check("consumed stock stays consumed",
                  float(r_cut["inputs"][0]["consumedQty"]), float(consumed_units))

    chk.status("reject-intake again with nothing delivered",
               c.post(f"/production-runs/{rid2}/stages/{asm2['runStageId']}/reject-intake", {}),
               409, "STAGE_REJECT_INVALID")

    chk.section("M7-2. Re-complete OVERWRITES rather than accumulating")
    re_done = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/complete",
                     {"outputs": [{"runOutputId": cut2_out, "producedQty": TARGET, "scrappedQty": 0}]})
    chk.status("re-complete the parent", re_done, 200)
    if re_done.status == 200:
        chk.check("producedQty overwritten, not doubled",
                  float(re_done.body["outputs"][0]["producedQty"]), float(TARGET))
    re_app = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/approve")
    chk.status("re-approve the parent", re_app, 200)
    if re_app.status == 200:
        chk.check("child Ready again",
                  next(s for s in re_app.body["stage"]["outputs"]
                       for _ in [0])["transferredQty"] is not None, True)

    # =====================================================================
    # M7b — terminal reject
    # =====================================================================
    chk.section("M7-3. Terminal reject resets the run for a rework pass (FR-MFG-16)")
    d3 = c.get(f"/production-runs/{rid2}").body
    asm3 = next(s for s in d3["stages"] if s["name"] == "Assemble")
    asm3_out = asm3["outputs"][0]["runOutputId"]
    pool_before = float(d3["costPool"]["net"])

    c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/start")
    c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/complete",
           {"outputs": [{"runOutputId": asm3_out, "producedQty": TARGET, "scrappedQty": 0}]})

    trj = c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/reject",
                 {"note": "Final QA failed batch"})
    chk.status("terminal reject", trj, 200)
    if trj.status == 200:
        chk.check("reworkCount incremented", trj.body["reworkCount"], 1)
        run_after = trj.body["run"]
        chk.check("run still InProgress", run_after["status"], "InProgress")
        chk.check("completedAt still null", run_after["completedAt"], None)

        t_cut = next(s for s in run_after["stages"] if s["name"] == "Cut")
        t_asm = next(s for s in run_after["stages"] if s["name"] == "Assemble")
        chk.check("entry stage reset to Ready", t_cut["status"], "Ready")
        chk.check("non-entry stage reset to Waiting", t_asm["status"], "Waiting")
        chk.check("timings cleared", (t_cut["actualStartAt"], t_cut["actualEndAt"]), (None, None))
        chk.check("fieldValues cleared", t_cut["fieldValues"], None)
        chk.check("producedQty cleared", float(t_cut["outputs"][0]["producedQty"]), 0.0)
        chk.check("transferredQty cleared", float(t_cut["outputs"][0]["transferredQty"]), 0.0)
        chk.check("deliveredQty cleared", float(t_asm["inputs"][0]["deliveredQty"]), 0.0)

        # The load-bearing half of FR-MFG-16.
        chk.check("plannedQty PRESERVED", float(t_cut["inputs"][0]["plannedQty"]), float(consumed_units))
        chk.check("consumedQty PRESERVED", float(t_cut["inputs"][0]["consumedQty"]), float(consumed_units))
        chk.check("cost pool PRESERVED across the rework", float(run_after["costPool"]["net"]), pool_before)

        snap = [e for e in run_after["events"] if e["eventType"] == "TerminalReject"]
        chk.check("exactly one snapshot event", len(snap), 1)
        if snap:
            chk.check("snapshot records the rework number", snap[0]["payload"]["reworkNumber"], 1)
            chk.check("snapshot captured every stage", len(snap[0]["payload"]["stages"]), 2)
            chk.check("snapshot kept the pre-reset produced figure",
                      any(float(o["producedQty"]) == TARGET
                          for s in snap[0]["payload"]["stages"] for o in s["outputs"]), True)
            chk.check("reject note recorded", snap[0]["note"], "Final QA failed batch")

    # This is the single behavioural rule that makes FR-MFG-16 work: a start always consumes
    # max(0, plannedBase - consumedQty), never the full planned figure. After a rework the
    # material is still in the pool, so re-consuming it would double-charge the run.
    chk.section("M7-4. Rework restart after RAISING the planned qty consumes only the delta")
    cut2_in = next(i for s in c.get(f"/production-runs/{rid2}").body["stages"]
                   for i in s["inputs"] if s["name"] == "Cut")["runInputId"]
    raised = consumed_units + 10
    chk.status("raise the planned qty on the reset (Ready) stage",
               c.put(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/quantities",
                     {"inputs": [{"id": cut2_in, "plannedQty": raised}], "outputs": []}), 200)

    oh_before = on_hand(c, raw, wh)
    pool_pre = float(c.get(f"/production-runs/{rid2}").body["costPool"]["net"])
    restart = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/start")
    chk.status("restart after the raise", restart, 200)
    if restart.status == 200:
        chk.check("consumed ONLY the 10-unit delta, not the full 60",
                  float(restart.body["consumed"][0]["qty"]), 10.0)
        chk.check("on-hand fell by only the delta", on_hand(c, raw, wh), oh_before - 10.0)
        chk.check("consumedQty accumulated to the new planned total",
                  float(next(i for s in c.get(f"/production-runs/{rid2}").body["stages"]
                             for i in s["inputs"] if i["runInputId"] == cut2_in)["consumedQty"]),
                  float(raised))
        chk.check("pool grew by only the delta's value",
                  float(c.get(f"/production-runs/{rid2}").body["costPool"]["net"]),
                  pool_pre + 10.0 * UNIT_COST)

    chk.section("M7-5. A second rework, restarted UNCHANGED, consumes nothing at all")
    # Drive the run round again to get the entry stage back to Ready with consumed == planned.
    d5 = c.get(f"/production-runs/{rid2}").body
    c5 = next(s for s in d5["stages"] if s["name"] == "Cut")
    a5 = next(s for s in d5["stages"] if s["name"] == "Assemble")
    c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/complete",
           {"outputs": [{"runOutputId": c5["outputs"][0]["runOutputId"],
                         "producedQty": TARGET, "scrappedQty": 0}]})
    c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/approve")
    c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/start")
    c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/complete",
           {"outputs": [{"runOutputId": a5["outputs"][0]["runOutputId"],
                         "producedQty": TARGET, "scrappedQty": 0}]})
    second = c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/reject", {"note": "again"})
    chk.status("second terminal reject", second, 200)
    if second.status == 200:
        chk.check("reworkCount now 2", second.body["reworkCount"], 2)
        chk.check("two snapshot events retained",
                  sum(1 for e in second.body["run"]["events"] if e["eventType"] == "TerminalReject"), 2)

    oh2 = on_hand(c, raw, wh)
    prdi2 = len(ledger(c, rid2, "PRDI"))
    again = c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/start")
    chk.status("restart with planned unchanged", again, 200)
    if again.status == 200:
        chk.check("nothing consumed (delta is zero)", len(again.body["consumed"]), 0)
        chk.check("on-hand unchanged", on_hand(c, raw, wh), oh2)
        chk.check("no new PRDI ledger row", len(ledger(c, rid2, "PRDI")), prdi2)

    chk.section("M7-6. Cancel returns net consumed stock (FR-MFG-17)")
    run4, cut4, _ = new_run(c, tid, wh)
    rid4 = run4["runId"]
    in4 = cut4["inputs"][0]["runInputId"]
    c.post(f"/production-runs/{rid4}/stages/{cut4['runStageId']}/start")
    first = float(next(i for s in c.get(f"/production-runs/{rid4}").body["stages"]
                       for i in s["inputs"] if i["runInputId"] == in4)["consumedQty"])
    chk.check("first start consumed the full planned qty", first, float(consumed_units))

    # =====================================================================
    # M7c — cancel
    # =====================================================================
    oh_pre_cancel = on_hand(c, raw, wh)
    cancelled = c.post(f"/production-runs/{rid4}/cancel",
                       {"reasonCodeId": prod_reason(c, "PRD-CANCEL"), "note": "Order cancelled"})
    chk.status("cancel the run", cancelled, 200)
    if cancelled.status == 200:
        chk.check("run Cancelled", cancelled.body["status"], "Cancelled")
        chk.check("one return posted", len(cancelled.body["returns"]), 1)
        r0 = cancelled.body["returns"][0]
        chk.check("returned the net consumed qty", float(r0["qty"]), float(consumed_units))
        chk.check("returned at the consumed weighted cost", float(r0["unitCost"]), UNIT_COST)
        chk.check("layer id populated", r0["layerId"] > 0, True)
        chk.check("ledgerRefs populated", len(cancelled.body["ledgerRefs"]), 1)
    chk.check("on-hand restored", on_hand(c, raw, wh), oh_pre_cancel + consumed_units)

    prdc = ledger(c, rid4, "PRDC")
    chk.check("one PRDC row", len(prdc), 1)
    if prdc:
        chk.check("PRDC direction is In", prdc[0]["direction"], "In")
        chk.check("PRDC value = the exact consumed residual",
                  float(prdc[0]["value"]), consumed_units * UNIT_COST)

    after_cancel = c.get(f"/production-runs/{rid4}").body
    chk.check("cancel reason recorded", after_cancel["cancelReasonCodeId"] is not None, True)
    chk.check("completedAt stays null on a cancel", after_cancel["completedAt"], None)
    chk.check("pool nets to zero after the cancel return",
              float(after_cancel["costPool"]["net"]), 0.0)
    chk.check("cancel event logged",
              any(e["eventType"] == "Cancel" for e in after_cancel["events"]), True)

    chk.section("M7-7. Cancel guards")
    chk.status("cancel an already-cancelled run",
               c.post(f"/production-runs/{rid4}/cancel", {"reasonCodeId": prod_reason(c, "PRD-CANCEL")}),
               409, "RUN_NOT_CANCELLABLE")
    chk.status("cancel a COMPLETED run",
               c.post(f"/production-runs/{rid}/cancel", {"reasonCodeId": prod_reason(c, "PRD-CANCEL")}),
               409, "RUN_NOT_CANCELLABLE")
    run5, _, _ = new_run(c, tid, wh)
    chk.status("cancel with no reason code",
               c.post(f"/production-runs/{run5['runId']}/cancel", {}), 400, "REASON_CODE_REQUIRED")

    return chk.finish("M6+M7")


if __name__ == "__main__":
    sys.exit(main())
