#!/usr/bin/env python3
"""
WI-248 TDA08B 多段校正（零點＋1～50 段，業主一般用 3～5 段）建置／驗證。

設計：docs/design/wi248-tda08b-three-point-calibration.md（v2 多段版）
需要韌體 ≥ 6.0.118（動作輸出 outputMode 6：寫 Modbus 的值取自 cmd/signal/<N>）。
與「磅秤發重量範本」(cmd/signal/0、1) 共存；本精靈用 cmd/signal/2～6，只佔 2 個信號。

現場流程（命令 topic 前綴 mes/gateway/<UID>/cmd/signal/）：
  3 ← 1 再 0   標零（空籃）；同時開放寫入
  4 ← N        段號（1～50）
  5 ← W        該段砝碼重量（面板顯示值 × 10^小數位）
  6 ← 1 再 0   寫入這一段（每段重複 4→5→6）
  收尾：4 ← 最後段號+1、5 ← 0、6 ← 1 再 0

用法:
  python3 scripts/scale_multical_build.py plan
  python3 scripts/scale_multical_build.py apply  --gw 192.168.72.46
  python3 scripts/scale_multical_build.py reset  --gw 192.168.72.46
  python3 scripts/scale_multical_build.py show   --gw 192.168.72.46
  python3 scripts/scale_multical_build.py remove --gw 192.168.72.46
"""
import argparse
import json
import logging
import os
import sys
import time
import urllib.error
import urllib.request

log = logging.getLogger("multical")

# ---------------------------------------------------------------- 常數
PREFIX = "多段校正"
TCP_PREFIX = "mc-st"
CMD_STATE, CMD_ZERO, CMD_SEG, CMD_WEIGHT, CMD_WRITE = 2, 3, 4, 5, 6
OWN_CMD_INDICES = {CMD_STATE, CMD_ZERO, CMD_SEG, CMD_WEIGHT, CMD_WRITE}
SEGMENT_COMMIT = 100
MIN_FW = (6, 0, 118)

REG_CAL_CH, REG_CAL_TRIG = 107, 113
REG_SEG_NO, REG_SEG_WT, REG_SEG_COMMIT = 194, 195, 199
NEW_REGS = [(REG_SEG_NO, "多段段號"), (REG_SEG_WT, "段重量"), (REG_SEG_COMMIT, "段生效")]
REQUIRED_EXISTING = [(REG_CAL_CH, "操作通道"), (REG_CAL_TRIG, "標定操作")]

CH_DO, CH_MODBUS, CH_MQTT, CH_TCP = 2, 5, 6, 7
OP_EQ, OP_GE = 0, 4
TRIG_CHANGE = 3
OUT_FORCE_ON, OUT_MAPPED, OUT_PULSE, OUT_FORCE_OFF, OUT_FROM_MQTT = 0, 1, 2, 4, 6
FC_WRITE_SINGLE, MODE_WRITE, DT_UINT16 = 6, 1, 5

LIMITS = {"signals": 8, "actions": 8, "rules": 16, "outputs": 8, "regs": 32}
DO_VALID = set(range(0, 4)) | set(range(16, 16 + 5 * 16))
DEFAULT_READY_DO, DEFAULT_ACK_DO, DEFAULT_ACK_MS = 0, 1, 800
HTTP_TIMEOUT_S, HTTP_RETRIES, SAVE_SETTLE_S, READBACK_TRIES = 10, 5, 3, 6


class WizardError(Exception):
    """建置前檢查或讀回驗證失敗；訊息直接給操作者看。"""


# ---------------------------------------------------------------- 物件產生（純函式）
def mb_fixed(slot, value):
    return {"type": CH_MODBUS, "index": slot, "outputMode": OUT_MAPPED, "writeValue": value}


def mb_from_mqtt(slot, cmd_index):
    # 不可帶 writeValue：韌體看到 writeValue 會把 outputMode 強制改成 1
    return {"type": CH_MODBUS, "index": slot, "outputMode": OUT_FROM_MQTT, "pulseMs": cmd_index}


def build_actions(reg, tcp_ids, ready_do, ack_do, ack_ms):
    """[(名稱, outputs)]：標零、寫入段。"""
    zero = [mb_fixed(reg[REG_CAL_CH], 1), mb_fixed(reg[REG_CAL_TRIG], 1),
            {"type": CH_TCP, "index": tcp_ids[1], "outputMode": OUT_FORCE_ON}]
    write = [mb_fixed(reg[REG_CAL_CH], 1),
             mb_from_mqtt(reg[REG_SEG_NO], CMD_SEG),
             mb_from_mqtt(reg[REG_SEG_WT], CMD_WEIGHT),
             mb_fixed(reg[REG_SEG_COMMIT], SEGMENT_COMMIT)]
    if ready_do >= 0:
        zero.append({"type": CH_DO, "index": ready_do, "outputMode": OUT_FORCE_ON})   # 已標零、可寫入
    if ack_do >= 0:
        write.append({"type": CH_DO, "index": ack_do, "outputMode": OUT_PULSE, "pulseMs": ack_ms})  # 每寫一段閃一下
    acts = [(f"{PREFIX}-標零", zero), (f"{PREFIX}-寫入段", write)]
    for name, outs in acts:
        if len(outs) > LIMITS["outputs"]:
            raise WizardError(f"動作「{name}」輸出 {len(outs)} 超過上限 {LIMITS['outputs']}")
    return acts


def build_signals():
    def mq(index, op, threshold):
        return {"type": CH_MQTT, "index": index, "op": op, "threshold": threshold,
                "and": True, "expIndex": 0, "deadband": 0}
    return [(f"{PREFIX}-標零", [mq(CMD_ZERO, OP_GE, 0.5)]),
            (f"{PREFIX}-寫入段", [mq(CMD_STATE, OP_EQ, 1), mq(CMD_WRITE, OP_GE, 0.5)])]


def tcp_names():
    return [f"{TCP_PREFIX}0", f"{TCP_PREFIX}1"]


# ---------------------------------------------------------------- HTTP
class Gateway:
    def __init__(self, host, token):
        self.base = f"http://{host}"
        self.token = token

    def _req(self, method, path, body=None):
        data = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode() if body is not None else None
        r = urllib.request.Request(self.base + path, data=data, method=method, headers={
            "Authorization": f"Bearer {self.token}", "Connection": "close",
            "Content-Type": "application/json", "Accept-Encoding": "identity"})
        last = None
        for attempt in range(1, HTTP_RETRIES + 1):
            try:
                with urllib.request.urlopen(r, timeout=HTTP_TIMEOUT_S) as resp:
                    return resp.status, resp.read().decode("utf-8", "replace")
            except urllib.error.HTTPError as e:
                if 400 <= e.code < 500:
                    return e.code, e.read().decode("utf-8", "replace")
                last = e
            except Exception as e:
                last = e
            log.warning("%s %s 第 %d 次失敗：%s", method, path, attempt, last)
            time.sleep(1.5)
        raise WizardError(f"{method} {path} 連續 {HTTP_RETRIES} 次失敗：{last}")

    def get(self, path):
        st, t = self._req("GET", path)
        if st != 200:
            raise WizardError(f"GET {path} 回 {st}：{t[:200]}")
        return json.loads(t)

    def post(self, path, body):
        st, t = self._req("POST", path, body)
        if st != 200 or '"success":true' not in t.replace(" ", ""):
            raise WizardError(f"POST {path} 回 {st}：{t[:200]}")
        time.sleep(SAVE_SETTLE_S)
        try:
            return json.loads(t)
        except ValueError:
            log.warning("POST %s 回應不完整，以讀回驗證為準：%r", path, t[:80])
            return {}

    def delete(self, path):
        st, t = self._req("DELETE", path)
        if st != 200:
            raise WizardError(f"DELETE {path} 回 {st}：{t[:200]}")
        time.sleep(SAVE_SETTLE_S)


def lst(gw, ep, key):
    return gw.get(f"/api/{ep}").get(key, []) or []


def wait_for(fetch, pred, what):
    for _ in range(READBACK_TRIES):
        hit = next((x for x in fetch() if pred(x)), None)
        if hit:
            return hit
        time.sleep(1)
    raise WizardError(f"讀回驗證失敗：{what}")


def ours(name):
    return name.startswith(PREFIX)


# ---------------------------------------------------------------- 設備檢查
def check_firmware(gw):
    fw = gw.get("/api/diag").get("fwVersion", "0.0.0")
    try:
        ver = tuple(int(x) for x in fw.split("."))
    except ValueError:
        raise WizardError(f"無法解析韌體版本 {fw!r}")
    if ver < MIN_FW:
        raise WizardError(f"韌體 {fw} 太舊：多段校正需要 ≥ {'.'.join(map(str, MIN_FW))}（outputMode 6）")
    return fw


def find_tda(conf, slave_arg):
    devs = conf.get("modbusDevices", [])
    cands = [(i, d) for i, d in enumerate(devs) if d.get("enabled")
             and (d.get("slaveId") == slave_arg if slave_arg else d.get("type") == "tda08b")]
    if len(cands) != 1:
        raise WizardError(f"找不到唯一一台 TDA08B（符合 {len(cands)} 台）；請用 --slave 指定 Slave ID")
    return cands[0]


def reg_belongs(r, dev_idx, slave):
    return r.get("name") and (r.get("slaveId") == slave if r.get("slaveId") else r.get("deviceIndex") == dev_idx)


def plan_registers(regs, dev_idx, slave):
    """(位址→槽位, 需新增的 [(槽位, 物件)])。只填空槽，不動既有位置（動作以槽位引用）。"""
    slots = {}
    for addr, label in REQUIRED_EXISTING:
        hit = [i for i, r in enumerate(regs) if reg_belongs(r, dev_idx, slave) and r.get("regAddress") == addr]
        if not hit:
            raise WizardError(f"TDA08B 缺暫存器 {label}(@{addr})；請先在網頁套用「TDA08B 磅秤」預設暫存器")
        r = regs[hit[0]]
        if not (r.get("enabled") and r.get("mode") == MODE_WRITE):
            raise WizardError(f"暫存器 {label}(@{addr}) 未啟用或不是寫入模式 → 韌體會靜默跳過寫入")
        slots[addr] = hit[0]
    empty = [i for i, r in enumerate(regs) if not r.get("name")] + list(range(len(regs), LIMITS["regs"]))
    adds = []
    for addr, name in NEW_REGS:
        hit = [i for i, r in enumerate(regs) if reg_belongs(r, dev_idx, slave) and r.get("regAddress") == addr]
        if hit:
            slots[addr] = hit[0]
            continue
        if not empty:
            raise WizardError(f"Modbus 暫存器已滿（{LIMITS['regs']}），無法新增 {name}(@{addr})")
        slot = empty.pop(0)
        slots[addr] = slot
        adds.append((slot, {"enabled": True, "deviceIndex": dev_idx, "slaveId": slave, "mode": MODE_WRITE,
                            "functionCode": FC_WRITE_SINGLE, "regAddress": addr, "dataType": DT_UINT16,
                            "scale": 1, "offset": 0, "name": name}))
    return slots, adds


def preflight(sigs, acts, rules):
    need = len(build_signals())
    foreign_sigs = [s for s in sigs if not ours(s.get("name", ""))]
    problems = []
    if len(foreign_sigs) + need > LIMITS["signals"]:
        problems.append(f"信號：既有 {len(foreign_sigs)} + 本精靈 {need} > 上限 {LIMITS['signals']}"
                        f"（既有：{[s['name'] for s in foreign_sigs]}）")
    if len([a for a in acts if not ours(a.get("name", ""))]) + need > LIMITS["actions"]:
        problems.append(f"動作已滿（上限 {LIMITS['actions']}）")
    if len([r for r in rules if not ours(r.get("name", ""))]) + need > LIMITS["rules"]:
        problems.append(f"規則已滿（上限 {LIMITS['rules']}）")
    for s in foreign_sigs:
        for src in s.get("sources", []):
            if src.get("type") == CH_MQTT and src.get("index") in OWN_CMD_INDICES:
                problems.append(f"命令通道 cmd/signal/{src['index']} 已被信號「{s['name']}」使用")
    if problems:
        raise WizardError("建置前檢查未通過，未寫入任何設定：\n  - " + "\n  - ".join(problems))


def reset_state(gw, uid, dos):
    """步驟與命令通道歸 0、燈號 DO 關閉。WI-161 會把 cmd/signal 值存 flash、開機還原，重裝或放棄後須歸零。"""
    for idx in sorted(OWN_CMD_INDICES):
        st, t = gw._req("POST", "/api/mqtt/publish", {"topic": f"mes/gateway/{uid}/cmd/signal/{idx}",
                                                      "payload": "0", "qos": 0})
        if st != 200 or '"success":true' not in t.replace(" ", ""):
            raise WizardError(f"歸零 cmd/signal/{idx} 失敗：{st} {t[:120]}")
    for d in dos:
        if d < 0:
            continue
        st, t = gw._req("POST", "/api/io/do", {"index": d, "value": False})
        if st != 200:
            raise WizardError(f"關閉 DO{d} 失敗：{st} {t[:120]}")
    time.sleep(SAVE_SETTLE_S)
    log.info("已歸零：cmd/signal/%s = 0，DO %s 關閉", sorted(OWN_CMD_INDICES), [d for d in dos if d >= 0])


def device_uid(gw):
    uid = gw.get("/api/system").get("network", {}).get("deviceUid", "")
    if not uid:
        raise WizardError("讀不到 deviceUid，無法組 cmd/signal topic")
    return uid


# ---------------------------------------------------------------- 指令
def cmd_plan(args):
    reg = {a: f"<reg@{a}>" for a in (REG_CAL_CH, REG_CAL_TRIG, REG_SEG_NO, REG_SEG_WT, REG_SEG_COMMIT)}
    tcp = {0: "<mc-st0>", 1: "<mc-st1>"}
    for (sn, srcs), (an, outs) in zip(build_signals(), build_actions(reg, tcp, args.ready_do, args.ack_do, args.ack_ms)):
        cond = " ∧ ".join(f"sig{s['index']}{'==' if s['op'] == OP_EQ else '≥'}{s['threshold']}" for s in srcs)
        print(f"\n[{sn}]  條件 {cond}")
        for o in outs:
            print("   ", json.dumps(o, ensure_ascii=False))
        print(f"    → 輸出 {len(outs)}/{LIMITS['outputs']}")
    print("\nplan OK")


def cmd_apply(args):
    gw = Gateway(args.gw, args.token)
    fw = check_firmware(gw)
    uid = device_uid(gw)
    state_topic = f"mes/gateway/{uid}/cmd/signal/{CMD_STATE}"
    conf = gw.get("/api/config")
    dev_idx, dev = find_tda(conf, args.slave)
    slave = dev["slaveId"]
    log.info("韌體 %s；TDA08B = modbusDevices[%d]「%s」slave %d；UID %s", fw, dev_idx, dev.get("name"), slave, uid)

    preflight(lst(gw, "signals", "signals"), lst(gw, "actions", "actions"), lst(gw, "rules", "rules"))
    regs = conf.get("modbusRegisters", [])
    slots, adds = plan_registers(regs, dev_idx, slave)

    if adds:   # 整批原樣送回，只填空槽 → 既有槽位不變
        full = list(regs) + [{}] * (LIMITS["regs"] - len(regs))
        for slot, obj in adds:
            full[slot] = obj
        last = max(i for i, r in enumerate(full) if r.get("name"))
        gw.post("/api/config", {"modbusRegisters": [r if r.get("name") else {"enabled": False, "name": ""}
                                                    for r in full[:last + 1]]})
        after = gw.get("/api/config").get("modbusRegisters", [])
        for slot, obj in adds:
            got = after[slot] if slot < len(after) else {}
            if got.get("regAddress") != obj["regAddress"] or got.get("mode") != MODE_WRITE:
                raise WizardError(f"暫存器槽位 {slot} 讀回不符：{got}")
        for addr, label in REQUIRED_EXISTING:
            if after[slots[addr]].get("regAddress") != addr:
                raise WizardError(f"既有暫存器 {label} 槽位位移了！立刻檢查設定")
        log.info("暫存器：新增 %s", [(s, o["name"]) for s, o in adds])

    tcp_ids = {}
    for s, name in enumerate(tcp_names()):
        ex = next((c for c in lst(gw, "tcpio", "channels") if c.get("name") == name), None)
        body = {"name": name, "protocol": 0, "direction": 1, "mode": 0, "mqttQos": 0,
                "mqttTopic": state_topic, "mqttPayload": str(s), "enabled": True}
        if ex:
            body["channelId"] = ex["channelId"]
        res = gw.post("/api/tcpio", body)
        tcp_ids[s] = res.get("channelId") or wait_for(
            lambda: lst(gw, "tcpio", "channels"), lambda c, n=name: c.get("name") == n, f"TCP IO {name}")["channelId"]

    sig_ids = []
    for name, srcs in build_signals():
        cur = lst(gw, "signals", "signals")
        ex = next((x for x in cur if x.get("name") == name), None)
        gw.post("/api/signals", {"index": ex["index"] if ex else len(cur), "enabled": True, "name": name,
                                 "trigger": TRIG_CHANGE, "debounceMs": 0, "sources": srcs})
        sig_ids.append(wait_for(lambda: lst(gw, "signals", "signals"),
                                lambda x, n=name, k=len(srcs): x.get("name") == n and x.get("sourceCount") == k,
                                f"信號 {name}")["signalId"])

    act_ids = []
    for name, outs in build_actions(slots, tcp_ids, args.ready_do, args.ack_do, args.ack_ms):
        cur = lst(gw, "actions", "actions")
        ex = next((x for x in cur if x.get("name") == name), None)
        gw.post("/api/actions", {"index": ex["index"] if ex else len(cur), "enabled": True, "name": name,
                                 "deviceTag": "磅秤", "valueType": 1, "actuationMask": 255, "outputs": outs})
        got = wait_for(lambda: lst(gw, "actions", "actions"),
                       lambda x, n=name, k=len(outs): x.get("name") == n and x.get("outputCount") == k, f"動作 {name}")
        modes = [o.get("outputMode") for o in got.get("outputs", [])]
        want = [o["outputMode"] for o in outs]
        if modes != want:
            raise WizardError(f"動作 {name} 輸出模式讀回不符：{modes} ≠ {want}")
        act_ids.append(got["actionId"])

    for (name, _), sid, aid in zip(build_signals(), sig_ids, act_ids):
        cur = lst(gw, "rules", "rules")
        ex = next((x for x in cur if x.get("name") == name), None)
        gw.post("/api/rules", {"index": ex["index"] if ex else len(cur), "name": name, "enabled": True,
                               "signalIndex": sid, "triggerOnTrue": True, "actionCount": 1,
                               "actionIndices": [aid], "priority": 0})
        wait_for(lambda: lst(gw, "rules", "rules"),
                 lambda x, n=name: x.get("name") == n and x.get("signalId") == sid and x.get("actionId") == aid,
                 f"規則 {name}")

    reset_state(gw, uid, [args.ready_do, args.ack_do])
    pre = f"mes/gateway/{uid}/cmd/signal"
    print(f"""
✅ 多段校正已建立並歸零。現場操作（每個「按」= 送 1 再送 0）：
  1. 空籃放上秤、面板穩定 → 按 {pre}/{CMD_ZERO}
  2. 每一段：放砝碼、面板穩定 → 送 {pre}/{CMD_SEG} = 段號、{pre}/{CMD_WEIGHT} = 重量 → 按 {pre}/{CMD_WRITE}
  3. 收尾：{pre}/{CMD_SEG} = 最後段號+1、{pre}/{CMD_WEIGHT} = 0 → 按 {pre}/{CMD_WRITE}
  重量單位 = 面板顯示值 × 10^小數位（兩位小數：5.00 kg → 500）""")


def cmd_reset(args):
    gw = Gateway(args.gw, args.token)
    reset_state(gw, device_uid(gw), [args.ready_do, args.ack_do])
    print("✅ 已歸零（未標零狀態，燈全關）")


def cmd_show(args):
    gw = Gateway(args.gw, args.token)
    for ep, key in (("signals", "signals"), ("actions", "actions"), ("rules", "rules")):
        items = [x for x in lst(gw, ep, key) if ours(x.get("name", ""))]
        print(f"=== {ep}（本精靈 {len(items)}） ===")
        for x in items:
            print("  ", json.dumps(x, ensure_ascii=False)[:220])


def cmd_remove(args):
    gw = Gateway(args.gw, args.token)
    for ep, key in (("rules", "rules"), ("actions", "actions"), ("signals", "signals")):
        for x in sorted([x for x in lst(gw, ep, key) if ours(x.get("name", ""))], key=lambda x: x["index"], reverse=True):
            gw.post(f"/api/{ep}", {"index": x["index"], "action": "delete"})
    for c in lst(gw, "tcpio", "channels"):
        if c.get("name", "").startswith(TCP_PREFIX):
            gw.delete(f"/api/tcpio?id={c['channelId']}")
    print("✅ 已移除本精靈的信號／動作／規則／TCP IO（暫存器保留）")


def main():
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("cmd", choices=["plan", "apply", "reset", "show", "remove"])
    p.add_argument("--gw")
    p.add_argument("--token", default=os.environ.get("OPTA_TOKEN", "smmsadmin"))
    p.add_argument("--slave", type=int)
    p.add_argument("--ready-do", type=int, default=DEFAULT_READY_DO, help="標零後恆亮的 DO（-1 不用）")
    p.add_argument("--ack-do", type=int, default=DEFAULT_ACK_DO, help="每寫入一段閃一下的 DO（-1 不用）")
    p.add_argument("--ack-ms", type=int, default=DEFAULT_ACK_MS)
    args = p.parse_args()
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
    if args.cmd != "plan" and not args.gw:
        p.error(f"{args.cmd} 需要 --gw")
    for name in ("ready_do", "ack_do"):
        v = getattr(args, name)
        if v >= 0 and v not in DO_VALID:
            p.error(f"--{name.replace('_', '-')} {v} 不是合法 DO")
    try:
        {"plan": cmd_plan, "apply": cmd_apply, "reset": cmd_reset, "show": cmd_show, "remove": cmd_remove}[args.cmd](args)
    except WizardError as e:
        log.error("%s", e)
        return 1
    return 0


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