"""M4 smoke test — stage start/complete/approve/transfer (docs/30 §D.3, FR-MFG-09..12).

FIRST STOCK-TOUCHING MILESTONE. Everything happens in a dedicated `SMOKE-PRD` warehouse so
the effects are isolated from real data and easy to inspect or clean up:

    DELETE FROM stock_ledger WHERE "WarehouseId" = (SELECT "WarehouseId" FROM warehouses WHERE "Code"='SMOKE-PRD');

Self-contained — builds its own template and run, so it does not depend on M2/M3 leftovers:

    python Backend/smoke/m4_stage_actions.py

Leaves the terminal stage InProgress for m5_receipt.py to complete and approve.
"""

from __future__ import annotations

import json
import sys

from smoke_common import bootstrap, drain_stock, seed_costed_stock

WAREHOUSE_CODE = "SMOKE-PRD"
TEMPLATE_CODE = "SMOKE-PT-M4"
TARGET_QTY = 50
SEED_RAW = 1000      # base units of the raw item
SEED_PACK = 500      # base units of the packaging item
# Deliberately awkward unit costs: the raw item is seeded in two layers at different costs
# so FIFO consumption produces a genuinely weighted value rather than a round number.
RAW_COST_1 = 2.5
RAW_COST_2 = 4.75
PACK_COST = 1.25
STATE_FILE = "m4_state.json"


def ensure_warehouse(c):
    for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]:
        if w["code"] == WAREHOUSE_CODE:
            return w["warehouseId"]
    created = c.post("/warehouses", {"code": WAREHOUSE_CODE, "name": "Production smoke warehouse"})
    if created.status != 201:
        sys.exit(f"FATAL: could not create the smoke warehouse: {created.status} {created.body}")
    return created.body["warehouseId"]


def adjustment_reason(c):
    codes = c.get("/reason-codes?context=Adjustment&pageSize=50").body["items"]
    if not codes:
        sys.exit("FATAL: no Adjustment reason codes seeded.")
    return codes[0]["reasonCodeId"]


def production_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 on_hand(c, item, wh):
    return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"])


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


def seed_stock(c, wh, raw, pack):
    """
    Seed on-hand at explicit unit costs. Two raw layers at different costs mean the FIFO
    consumption at start has to weight them, so `consumedValue` is a real number the
    assertions can check rather than the 0.00 a positive adjustment would produce.
    """
    seed_costed_stock(c, wh, [
        (raw, SEED_RAW * 0.4, RAW_COST_1),
        (raw, SEED_RAW * 0.6, RAW_COST_2),
        (pack, SEED_PACK, PACK_COST),
    ])


def build_template(c, raw, pack, finished, uom):
    """Cut (entry, stock input) → Assemble (terminal, upstream + a late stock input)."""
    payload = {
        "code": TEMPLATE_CODE,
        "name": "Smoke M4 line",
        "description": "Created by m4_stage_actions.py",
        "stages": [
            {
                "key": "tmp-cut", "name": "Cut", "roleLabel": "Carpentry",
                "estimatedMinutes": 60, "posX": 80, "posY": 100,
                "fieldDefs": [{"key": "moisture_ok", "label": "Moisture check",
                               "type": "Checkbox", "required": True}],
                "inputs": [{"source": "Stock", "itemId": raw, "qtyPerBatch": 8}],
                "outputs": [{"key": "tmp-frame", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}],
            },
            {
                # A Stock input on a non-entry stage — FR-MFG-04 allows material to join late
                # (packaging), which is exactly what this covers.
                "key": "tmp-asm", "name": "Assemble", "roleLabel": "QA",
                "estimatedMinutes": 45, "posX": 520, "posY": 100, "fieldDefs": [],
                "inputs": [
                    {"source": "Upstream", "fromOutputKey": "tmp-frame", "qtyPerBatch": 1},
                    {"source": "Stock", "itemId": pack, "qtyPerBatch": 2},
                ],
                "outputs": [{"key": "tmp-chair", "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:
        head = c.get(f"/production-templates/{existing['templateId']}")
        res = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag)
        if res.status == 409:
            # An earlier smoke run is still InProgress; reuse the template as-is.
            return existing["templateId"]
        if res.status != 200:
            sys.exit(f"FATAL: could not update the M4 template: {res.status} {res.body}")
        return res.body["templateId"]

    res = c.post("/production-templates", payload)
    if res.status != 201:
        sys.exit(f"FATAL: could not create the M4 template: {res.status} {res.body}")
    return res.body["templateId"]


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

    items = c.get("/items?pageSize=5&status=Active").body["items"]
    if len(items) < 3:
        sys.exit("FATAL: need at least 3 active items.")
    raw, pack, finished = items[0]["itemId"], items[1]["itemId"], items[2]["itemId"]
    uom = items[0]["baseUomId"]

    wh = ensure_warehouse(c)
    drained = drain_stock(c, wh)
    if drained:
        print(f"drained {len(drained)} leftover item(s) from a previous execution")
    seed_stock(c, wh, raw, pack)
    raw_before = on_hand(c, raw, wh)
    pack_before = on_hand(c, pack, wh)
    print(f"warehouse={wh} raw={raw}(on-hand {raw_before}) pack={pack}(on-hand {pack_before}) finished={finished}")

    tid = build_template(c, raw, pack, finished, uom)
    created = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": wh})
    if not chk.status("create the run", created, 201):
        return chk.finish("M4")

    run = created.body
    rid = run["runId"]
    cut = next(s for s in run["stages"] if s["name"] == "Cut")
    asm = next(s for s in run["stages"] if s["name"] == "Assemble")
    cut_id, asm_id = cut["runStageId"], asm["runStageId"]
    print(f"run={rid} {run['docNo']}  cut={cut_id} assemble={asm_id}")

    # ------------------------------------------------------------------- start
    chk.section("1. Stage start FIFO-consumes its stock inputs (FR-MFG-10)")
    started = c.post(f"/production-runs/{rid}/stages/{cut_id}/start", idempotency_key="m4-start-cut")
    if not chk.status("POST .../start", started, 200):
        return chk.finish("M4")

    chk.check("stage now InProgress", started.body["status"], "InProgress")
    chk.check("actualStartAt stamped", started.body["actualStartAt"] is not None, True)
    chk.check("one input consumed", len(started.body["consumed"]), 1)

    con = started.body["consumed"][0]
    chk.check("consumed the scaled quantity (8 x 50)", float(con["qty"]), 400.0)
    chk.check("consumed layers reported", len(con["consumedLayers"]) >= 1, True)
    chk.check("consumed value = qty x layer cost",
              round(float(con["value"]), 4),
              round(sum(float(l["qty"]) * float(l["unitCost"]) for l in con["consumedLayers"]), 4))
    # The whole point of seeding two layers at different costs: prove the value is genuinely
    # FIFO-weighted rather than zero or a single flat rate.
    chk.check("consumed value is non-zero", float(con["value"]) > 0, True)
    layer_costs = {float(l["unitCost"]) for l in con["consumedLayers"]}
    chk.check("consumption drew from the cheaper layer first (FR-STK-03)",
              min(layer_costs), RAW_COST_1)
    chk.check("ledgerRefs returned", len(started.body["ledgerRefs"]), 1)

    chk.check("on-hand fell by exactly the consumed qty", on_hand(c, raw, wh), raw_before - 400.0)

    prdi = ledger_rows(c, rid, "PRDI")
    chk.check("one PRDI ledger row", len(prdi), 1)
    if prdi:
        chk.check("PRDI direction is Out", prdi[0]["direction"], "Out")
        chk.check("PRDI qty is base-UOM 400", float(prdi[0]["qtyBase"]), 400.0)

    detail = c.get(f"/production-runs/{rid}").body
    cut_now = next(s for s in detail["stages"] if s["runStageId"] == cut_id)
    chk.check("consumedQty recorded on the input", float(cut_now["inputs"][0]["consumedQty"]), 400.0)
    chk.check("cost pool now reflects the consumption",
              float(detail["costPool"]["net"]), round(float(con["value"]), 4))

    chk.section("2. Guards after starting")
    chk.status("start again", c.post(f"/production-runs/{rid}/stages/{cut_id}/start"),
               409, "STAGE_NOT_READY")
    chk.status("start a Waiting stage", c.post(f"/production-runs/{rid}/stages/{asm_id}/start"),
               409, "STAGE_NOT_READY")
    chk.status("edit quantities on a started stage",
               c.put(f"/production-runs/{rid}/stages/{cut_id}/quantities",
                     {"inputs": [{"id": cut_now["inputs"][0]["runInputId"], "plannedQty": 500}], "outputs": []}),
               409, "STAGE_NOT_EDITABLE")

    # ---------------------------------------------------------------- complete
    chk.section("3. Stage complete records produced/scrap/fields (FR-MFG-11)")
    cut_out = cut_now["outputs"][0]["runOutputId"]

    chk.status("complete without the required custom field",
               c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
                      {"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 0}]}),
               400, "REQUIRED_FIELD_MISSING")

    chk.status("complete with scrap but no reason code",
               c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
                      {"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 2}],
                       "fieldValues": {"moisture_ok": True}}),
               400, "REASON_CODE_REQUIRED")

    chk.status("complete with an Adjustment-context reason",
               c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
                      {"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 2,
                                    "scrapReasonCodeId": adjustment_reason(c)}],
                       "fieldValues": {"moisture_ok": True}}),
               422)

    chk.status("complete with scrapped > produced",
               c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
                      {"outputs": [{"runOutputId": cut_out, "producedQty": 5, "scrappedQty": 9,
                                    "scrapReasonCodeId": production_reason(c, "PRD-SCRAP")}],
                       "fieldValues": {"moisture_ok": True}}),
               422)

    done = c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
                  {"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 0}],
                   "fieldValues": {"moisture_ok": True}})
    chk.status("complete properly", done, 200)
    if done.status == 200:
        chk.check("stage now Done", done.body["status"], "Done")
        chk.check("actualEndAt stamped", done.body["actualEndAt"] is not None, True)
        chk.check("actualMinutes computed", done.body["actualMinutes"] is not None, True)
        chk.check("producedQty recorded", float(done.body["outputs"][0]["producedQty"]), 50.0)
        chk.check("availableToTransfer = produced - scrapped - transferred",
                  float(done.body["outputs"][0]["availableToTransfer"]), 50.0)
        chk.check("fieldValues persisted", done.body["fieldValues"], {"moisture_ok": True})

    # ----------------------------------------------------------------- approve
    chk.section("4. Approve with a partial transfer (FR-MFG-12)")
    approved = c.post(f"/production-runs/{rid}/stages/{cut_id}/approve",
                      {"transfers": [{"runOutputId": cut_out, "qty": 30}]})
    chk.status("approve transferring 30 of 50", approved, 200)
    if approved.status == 200:
        chk.check("stage now Approved", approved.body["status"], "Approved")
        chk.check("run still InProgress (non-terminal)", approved.body["runStatus"], "InProgress")
        chk.check("no receipt on a non-terminal approve", approved.body["receipt"], None)
        chk.check("one transfer reported", len(approved.body["transfers"]), 1)
        t = approved.body["transfers"][0]
        chk.check("transferred 30", float(t["qty"]), 30.0)
        chk.check("child delivered 30", float(t["childDeliveredQty"]), 30.0)
        chk.check("child still Waiting (30 < 50 planned)", t["childStatus"], "Waiting")
        chk.check("remainder held on the stage",
                  float(approved.body["stage"]["outputs"][0]["availableToTransfer"]), 20.0)

    chk.section("5. Transfer the remainder, then over-transfer (FR-MFG-12)")
    chk.status("transfer 21 (more than the 20 remaining)",
               c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer",
                      {"runOutputId": cut_out, "qty": 21}),
               422, "TRANSFER_EXCEEDS_AVAILABLE")

    moved = c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer",
                   {"runOutputId": cut_out, "qty": 20})
    chk.status("transfer the remaining 20", moved, 200)
    if moved.status == 200:
        chk.check("child delivered 50", float(moved.body["transfers"][0]["childDeliveredQty"]), 50.0)
        chk.check("child flipped to Ready (FR-MFG-09)", moved.body["transfers"][0]["childStatus"], "Ready")
        chk.check("nothing left to transfer",
                  float(moved.body["stage"]["outputs"][0]["availableToTransfer"]), 0.0)

    chk.status("transfer once everything is gone",
               c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer",
                      {"runOutputId": cut_out, "qty": 1}),
               422, "TRANSFER_EXCEEDS_AVAILABLE")

    # -------------------------------------------------- late stock input start
    chk.section("6. Start the terminal stage — a late Stock input (FR-MFG-04)")
    started2 = c.post(f"/production-runs/{rid}/stages/{asm_id}/start")
    chk.status("start Assemble", started2, 200)
    if started2.status == 200:
        chk.check("only the Stock input consumed (upstream is WIP)", len(started2.body["consumed"]), 1)
        chk.check("packaging consumed 2 x 50", float(started2.body["consumed"][0]["qty"]), 100.0)
        chk.check("packaging on-hand fell by 100", on_hand(c, pack, wh), pack_before - 100.0)
        chk.check("no ledger row for the upstream WIP input", len(ledger_rows(c, rid, "PRDI")), 2)

    chk.section("7. Insufficient stock is refused (FR-MFG-10)")
    big = c.post("/production-runs", {"templateId": tid, "targetQty": 100000, "warehouseId": wh})
    if big.status == 201:
        big_cut = next(s for s in big.body["stages"] if s["name"] == "Cut")["runStageId"]
        chk.status("start a stage needing more than on-hand",
                   c.post(f"/production-runs/{big.body['runId']}/stages/{big_cut}/start"),
                   409, "STOCK_NEGATIVE_BLOCKED")
        chk.check("on-hand untouched by the failed start", on_hand(c, raw, wh), raw_before - 400.0)
    else:
        chk.check("could create the oversized run", big.status, 201)

    chk.section("8. Event history")
    events = c.get(f"/production-runs/{rid}").body["events"]
    kinds = [e["eventType"] for e in events]
    chk.check("Start logged twice", kinds.count("Start"), 2)
    chk.check("Complete logged once", kinds.count("Complete"), 1)
    chk.check("Approve logged once", kinds.count("Approve"), 1)
    chk.check("Transfer logged once", kinds.count("Transfer"), 1)
    chk.check("no event for the rejected actions", kinds.count("QuantityEdit"), 0)

    # Hand off to M5.
    with open(STATE_FILE, "w") as f:
        json.dump({"runId": rid, "templateId": tid, "warehouseId": wh,
                   "assembleStageId": asm_id, "finishedItemId": finished,
                   "rawItemId": raw, "packItemId": pack}, f)
    print(f"\nwrote {STATE_FILE}; run {rid} has Assemble InProgress for m5_receipt.py")

    return chk.finish("M4")


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