#!/usr/bin/env python3
"""
Build register-map.json from the Experion point export (Sinam_Tag_all.xlsx) ALONE.

The Experion export carries, for every point, its HC900 source/destination
addresses written as Honeywell "named" indexed addresses (e.g. ``C3 LOOP 1 PV``,
``C3 TAG 542 VALUE``, ``C3 MATH_VAR 22 VALUE``) or, for custom-mapped registers,
as raw "non-named" addresses (e.g. ``C3 4:0x789e UINT2``).

Combined with the HC900 *fixed* loop register layout (a controller-firmware
constant taken from the HC900 Communications manual, Table 6-3, and the Experion
"PID loop" named-address reference) this is everything needed to produce the
Modbus register map.  No HC Designer CSV export is required at run time; pass a
CSV with ``--validate-csv`` only if you want the embedded layout cross-checked.

Design (see docs/컨트롤러별-태그매핑-규칙.md):
  * The map is keyed on the **Experion point name** (ItemName) — every downstream
    consumer (crawler, DB, web UI) is Experion-based.
  * For a loop point we register **all** parameters of that loop (one contiguous
    Modbus read covers them anyway), naming the parameters Experion exposes with
    Experion attribute names (.PV/.SP/.OP/.MD/.GAIN/.RESET/.RATE) and the rest
    with their HC900 names.
  * ``.MD`` (Mode) reads from Loop Status (LOOPSTAT) but writes to Auto/Manual
    State (the MODEIN destination) — so it carries a separate ``write_addr``.

Usage:
    python3 build_register_map_from_sinam.py --controller C3 \
        --sinam docs/Sinam_Tag_all.xlsx -o docs/register-map.json
    # optional cross-check against an HC Designer export:
    python3 build_register_map_from_sinam.py --controller C4 \
        --sinam docs/Sinam_Tag_all.xlsx --validate-csv docs/C4-All-Modbus-Map.csv \
        -o docs/register-map-c4.json
"""

import re
import csv
import json
import argparse
import datetime
from pathlib import Path

import openpyxl

# ─────────────────────────── HC900 fixed loop layout ───────────────────────────
# Offset within a loop block (loop base = 0x40 + (n-1)*0x100 for loops 1-24,
# 0x7840 + (n-25)*0x100 for loops 25-32). Validated against C3/C4 HC Designer
# exports and the HC900 Communications manual Table 6-3.
#
#   offset : (default_suffix, count, type, access)
# count 2 = float32 (IEEEFP), count 1 = uint16 (bit-packed / unscaled int).
LOOP_LAYOUT = {
    0x00: ('PV',               2, 'float32', 'R'),
    0x02: ('RSP_SP2',          2, 'float32', 'RW'),
    0x04: ('WSP',              2, 'float32', 'RW'),
    0x06: ('Output',           2, 'float32', 'RW'),
    0x08: ('PV_B',             2, 'float32', 'R'),
    0x0A: ('CarbonPotTemp',    2, 'float32', 'R'),
    0x0C: ('Gain1',            2, 'float32', 'RW'),
    0x0E: ('Direction',        2, 'float32', 'R'),
    0x10: ('Reset1',           2, 'float32', 'RW'),
    0x12: ('Rate1',            2, 'float32', 'RW'),
    0x14: ('CycleTime1',       2, 'float32', 'R'),
    0x16: ('PV_LowRange',      2, 'float32', 'R'),
    0x18: ('PV_HighRange',     2, 'float32', 'R'),
    0x1A: ('Alarm1SP1',        2, 'float32', 'RW'),
    0x1C: ('Alarm1SP2',        2, 'float32', 'RW'),
    0x20: ('Gain2',            2, 'float32', 'RW'),
    0x22: ('StepDeadband',     2, 'float32', 'RW'),
    0x24: ('Reset2',           2, 'float32', 'RW'),
    0x26: ('Rate2',            2, 'float32', 'RW'),
    0x28: ('CycleTime2',       2, 'float32', 'R'),
    0x2A: ('LSP1',             2, 'float32', 'RW'),
    0x2C: ('LSP2',             2, 'float32', 'RW'),
    0x2E: ('Alarm2SP1',        2, 'float32', 'RW'),
    0x30: ('Alarm2SP2',        2, 'float32', 'RW'),
    0x34: ('SP_LowLimit',      2, 'float32', 'RW'),
    0x36: ('SP_HighLimit',     2, 'float32', 'RW'),
    0x38: ('WSP_B',            2, 'float32', 'RW'),
    0x3A: ('Output_LowLimit',  2, 'float32', 'RW'),
    0x3C: ('Output_HighLimit', 2, 'float32', 'RW'),
    0x3E: ('OPWORK',           2, 'float32', 'RW'),
    0x46: ('Ratio',            2, 'float32', 'RW'),
    0x48: ('Bias',             2, 'float32', 'RW'),
    0x4A: ('Deviation',        2, 'float32', 'R'),
    0x4E: ('ManualReset',      2, 'float32', 'RW'),
    0x50: ('FeedforwardGain',  2, 'float32', 'RW'),
    0x52: ('LocalPctCO',       2, 'float32', 'RW'),
    0x54: ('FurnaceFactor',    2, 'float32', 'RW'),
    0x56: ('PercentHydrogen',  2, 'float32', 'RW'),
    0x58: ('OnOffHysteresis',  2, 'float32', 'RW'),
    0x5A: ('CarbPotDewpt',     2, 'float32', 'RW'),
    0x5C: ('StepMotorTime',    2, 'float32', 'RW'),
    0xB7: ('FuzzyEnable',      1, 'uint16',  'RW'),
    0xB8: ('DemandTune',       1, 'uint16',  'RW'),
    0xB9: ('AntiSootEnable',   1, 'uint16',  'RW'),
    0xBA: ('AutoManState',     1, 'uint16',  'RW'),
    0xBB: ('SP_SelectState',   1, 'uint16',  'RW'),
    0xBC: ('RemLocSPState',    1, 'uint16',  'RW'),
    0xBD: ('TuneSetState',     1, 'uint16',  'RW'),
    0xBE: ('LoopStatus',       1, 'uint16',  'R'),
}

# Honeywell named-address mnemonic → offset within the loop block.
MNEMONIC_OFFSET = {
    'PV': 0x00, 'RSP': 0x02, 'SP2': 0x02, 'WSP': 0x04, 'OP': 0x06, 'PVB': 0x08,
    'TEMP': 0x0A, 'GAIN1': 0x0C, 'PROP1': 0x0C, 'DIR': 0x0E, 'RESET1': 0x10,
    'RATE1': 0x12, 'CYCLE1': 0x14, 'PVLOW': 0x16, 'PVHIGH': 0x18, 'AL1SP1': 0x1A,
    'AL1SP2': 0x1C, 'GAIN2': 0x20, 'PROP2': 0x20, 'DB': 0x22, 'RESET2': 0x24,
    'RATE2': 0x26, 'CYCLE2': 0x28, 'LSP1': 0x2A, 'LSP2': 0x2C, 'AL2SP1': 0x2E,
    'AL2SP2': 0x30, 'SPLOW': 0x34, 'SPHIGH': 0x36, 'SPWORK': 0x38, 'OPLOW': 0x3A,
    'OPHIGH': 0x3C, 'OPWORK': 0x3E, 'RATIO': 0x46, 'BIAS': 0x48, 'DEV': 0x4A,
    'MAN_RESET': 0x4E, 'FF': 0x50, 'PCTCO': 0x52, 'FFCTR': 0x54, 'H2': 0x56,
    'OUT_HYST': 0x58, 'CPD': 0x5A, 'MOTOR': 0x5C, 'AMSTAT': 0xBA, 'LOOPSTAT': 0xBE,
    'MODEIN': 0xBA,  # mode write destination = Auto/Manual State register
}

# Mnemonic → Experion point-parameter (attribute) name. Only these get renamed;
# every other loop register keeps its HC900 name from LOOP_LAYOUT.
EXPERION_ATTR = {
    'PV': 'PV',
    'WSP': 'SP', 'SPWORK': 'SP',
    'OP': 'OP', 'OPWORK': 'OP',
    'GAIN1': 'GAIN',
    'RESET1': 'RESET',
    'RATE1': 'RATE',
    'LOOPSTAT': 'MD',   # mode read source
}

MD_READ_OFFSET = 0xBE   # Loop Status (read)
MD_WRITE_OFFSET = 0xBA  # Auto/Manual State (MODEIN destination, write)

# Fixed bases for the other named-address spaces.
SIGNAL_TAG_BASE = 0x2000   # TAG n   → base + (n-1)*2  (read-only float32)
MATH_VAR_BASE = 0x18C0     # MATH_VAR n → base + (n-1)*2 (read/write float32)


def loop_base(n: int) -> int:
    """Base holding-register address of loop n (1-32)."""
    if 1 <= n <= 24:
        return 0x0040 + (n - 1) * 0x0100
    if 25 <= n <= 32:
        return 0x7840 + (n - 25) * 0x0100
    raise ValueError(f'loop number {n} out of range 1-32')


# ─────────────────────────── Sinam (Experion export) parsing ───────────────────────────

# Named addresses:    C3 LOOP 1 PV   /   C3 LOOPX 25 OPWORK   /   C3 TAG 542 VALUE
#                     C3 MATH_VAR 22 VALUE
# Non-named address:  C3 4:0x789e   /   C3 4:0x78aa UINT2
_RE_LOOP = re.compile(r'^C(\d+)\s+(LOOPX?)\s+(\d+)\s+(\w+)\s*$', re.I)
_RE_TAG = re.compile(r'^C(\d+)\s+TAG\s+(\d+)\s+VALUE\s*$', re.I)
_RE_VAR = re.compile(r'^C(\d+)\s+MATH_VAR\s+(\d+)\s+VALUE\s*$', re.I)
_RE_RAW = re.compile(r'^C(\d+)\s+(\d+):0x([0-9a-fA-F]+)(?:\s+(\w+))?\s*$', re.I)


def _raw_type(fmt: str | None) -> tuple[int, str]:
    """Map a non-named address format suffix to (count, type)."""
    f = (fmt or '').upper()
    if f in ('', 'IEEEFP'):
        return 2, 'float32'
    # UINT2 / MODE / bit-field (numeric) are all single 16-bit registers.
    return 1, 'uint16'


def scan_point(cells: list[str], controller_num: str) -> dict | None:
    """Classify an Experion point from all of its non-empty cells.

    Returns a descriptor dict identifying the point's HC900 binding for the
    requested controller, or None if the point does not reference it.
    Priority: LOOP/LOOPX > TAG > MATH_VAR > non-named raw address.
    """
    loop_refs: list[tuple[int, str]] = []   # (loop_no, mnemonic) — a point may reference
                                            # several loops (e.g. tuning from another loop)
    tag_n = var_n = None
    raw = None

    for cell in cells:
        s = cell.strip()
        m = _RE_LOOP.match(s)
        if m and m.group(1) == controller_num:
            loop_refs.append((int(m.group(3)), m.group(4).upper()))
            continue
        m = _RE_TAG.match(s)
        if m and m.group(1) == controller_num:
            tag_n = int(m.group(2)); continue
        m = _RE_VAR.match(s)
        if m and m.group(1) == controller_num:
            var_n = int(m.group(2)); continue
        m = _RE_RAW.match(s)
        if m and m.group(1) == controller_num:
            count, dtype = _raw_type(m.group(4))
            raw = {'addr': int(m.group(3), 16), 'table': int(m.group(2)),
                   'count': count, 'type': dtype}
            continue

    if loop_refs:
        # Primary loop = the loop whose PV this point reads (its own loop). If no PV
        # ref, fall back to the most-referenced loop. (A point can cross-reference
        # another loop's tuning constants, so "last match wins" is wrong.)
        from collections import Counter
        counts = Counter(n for n, _ in loop_refs)
        pv_loops = [n for n, mn in loop_refs if mn == 'PV']
        primary = (max(pv_loops, key=lambda n: counts[n]) if pv_loops
                   else counts.most_common(1)[0][0])
        mnems = {mn for n, mn in loop_refs if n == primary}
        return {'kind': 'loop', 'n': primary, 'mnemonics': mnems}
    if tag_n is not None:
        return {'kind': 'tag', 'n': tag_n}
    if var_n is not None:
        return {'kind': 'var', 'n': var_n}
    if raw is not None:
        return {'kind': 'raw', **raw}
    return None


def build_loop_entries(item_name: str, n: int, mnemonics: set[str]) -> list[dict]:
    """Register every parameter of loop n, keyed on the Experion ItemName.

    The parameters Experion actually exposes (per the mnemonics referenced in the
    export) are renamed to Experion attribute names; the rest keep their HC900
    names so the whole loop block is available.
    """
    base = loop_base(n)

    # offset → Experion attribute, derived from the mnemonics this point uses.
    offset_attr: dict[int, str] = {}
    for mn in mnemonics:
        attr = EXPERION_ATTR.get(mn)
        if attr and mn in MNEMONIC_OFFSET:
            offset_attr[MNEMONIC_OFFSET[mn]] = attr
    has_mode = 'LOOPSTAT' in mnemonics or 'MODEIN' in mnemonics

    entries = []
    for off, (suffix, count, dtype, access) in sorted(LOOP_LAYOUT.items()):
        if off == MD_READ_OFFSET and has_mode:
            continue  # emitted below as ".MD" with a distinct write_addr
        name = offset_attr.get(off, suffix)
        entries.append({
            'tag': f'{item_name}.{name}',
            'addr': base + off,
            'write_addr': base + off,
            'count': count, 'type': dtype, 'access': access,
            'description': f'LOOP #{n} {suffix}',
        })

    if has_mode:
        # MODESTAT / Loop Status (0xBE) is read-only and encodes the combined mode
        # the way Experion reads it: 0=RSP AUTO, 1=RSP MAN, 4=LSP AUTO, 5=LSP MAN
        # (bit0 = Auto/Man, bit2 = LSP/RSP). The mode is *changed* by writing the
        # dedicated RW registers below: .AutoManState (0xBA) and .RemLocSPState (0xBC).
        entries.append({
            'tag': f'{item_name}.MD',
            'addr': base + MD_READ_OFFSET,
            'write_addr': base + MD_READ_OFFSET,
            'count': 1, 'type': 'uint16', 'access': 'R',
            'description': f'LOOP #{n} Mode status (MODESTAT 0=RSP AUTO,1=RSP MAN,'
                           f'4=LSP AUTO,5=LSP MAN; write via .AutoManState / .RemLocSPState)',
        })
    return entries


def build_point_entry(item_name: str, desc: dict) -> dict | None:
    """Build the register entry for a signal-tag / variable / raw point."""
    kind = desc['kind']
    if kind == 'tag':
        addr = SIGNAL_TAG_BASE + (desc['n'] - 1) * 2
        return {'tag': item_name, 'addr': addr, 'write_addr': addr,
                'count': 2, 'type': 'float32', 'access': 'R',
                'description': f'Signal Tag #{desc["n"]}'}
    if kind == 'var':
        addr = MATH_VAR_BASE + (desc['n'] - 1) * 2
        return {'tag': item_name, 'addr': addr, 'write_addr': addr,
                'count': 2, 'type': 'float32', 'access': 'RW',
                'description': f'Variable (MATH_VAR) #{desc["n"]}'}
    if kind == 'raw':
        if desc['table'] != 4:
            print(f'  ⚠ {item_name}: non-named table {desc["table"]} (not holding '
                  f'registers) — skipping')
            return None
        return {'tag': item_name, 'addr': desc['addr'], 'write_addr': desc['addr'],
                'count': desc['count'], 'type': desc['type'], 'access': 'R',
                'description': f'Custom address 0x{desc["addr"]:04X}'}
    return None


def resolve_one(s: str, controller_num: str) -> dict | None:
    """Resolve a single source-address string to {addr,count,type,access}, or None.

    Used for FlexibleParameters, whose SourceAddress points at one specific
    register (a loop parameter, a signal tag, a variable, or a raw address).
    Types come from the physical HC900 layout, not the Experion-declared type.
    """
    s = s.strip()
    m = _RE_LOOP.match(s)
    if m and m.group(1) == controller_num:
        n, mn = int(m.group(3)), m.group(4).upper()
        off = MNEMONIC_OFFSET.get(mn)
        if off is None:
            return None
        if off in LOOP_LAYOUT:
            _suf, count, dtype, access = LOOP_LAYOUT[off]
        else:
            count, dtype, access = 2, 'float32', 'RW'
        return {'addr': loop_base(n) + off, 'count': count, 'type': dtype, 'access': access}
    m = _RE_TAG.match(s)
    if m and m.group(1) == controller_num:
        return {'addr': SIGNAL_TAG_BASE + (int(m.group(2)) - 1) * 2,
                'count': 2, 'type': 'float32', 'access': 'R'}
    m = _RE_VAR.match(s)
    if m and m.group(1) == controller_num:
        return {'addr': MATH_VAR_BASE + (int(m.group(2)) - 1) * 2,
                'count': 2, 'type': 'float32', 'access': 'RW'}
    m = _RE_RAW.match(s)
    if m and m.group(1) == controller_num and int(m.group(2)) == 4:
        count, dtype = _raw_type(m.group(4))
        return {'addr': int(m.group(3), 16), 'count': count, 'type': dtype, 'access': 'R'}
    return None


def build_registers(sinam_path: Path, controller: str) -> list[dict]:
    controller_num = controller.lstrip('Cc')  # "C3" → "3"
    wb = openpyxl.load_workbook(sinam_path, read_only=True, data_only=True)
    ws = wb['Sheet1']

    registers: list[dict] = []
    seen_tags: set[str] = set()
    seen_loops: set[int] = set()

    for row in ws.iter_rows(min_row=2, values_only=True):
        item_name = row[0]
        if not item_name:
            continue
        item_name = str(item_name).strip()
        cells = [str(v) for v in row if v is not None]

        # FlexibleParameters: one extra named parameter per row, keyed on the parent
        # point. col1=Class, col2=ParamName, source address sits among the cells
        # (e.g. "C4 LOOP 22 AL1SP1", "C4 TAG 359 VALUE", "C4 MATH_VAR 114 VALUE").
        cls = str(row[1]).strip() if len(row) > 1 and row[1] else ''
        if cls == 'FlexibleParameters':
            param_name = str(row[2]).strip() if len(row) > 2 and row[2] else ''
            if param_name:
                res = next((r for r in (resolve_one(c, controller_num) for c in cells) if r), None)
                if res:
                    tag = f'{item_name}.{param_name}'
                    if tag not in seen_tags:
                        seen_tags.add(tag)
                        registers.append({
                            'tag': tag, 'addr': res['addr'], 'write_addr': res['addr'],
                            'count': res['count'], 'type': res['type'], 'access': res['access'],
                            'description': f'FlexibleParameter {param_name}',
                        })
            continue

        desc = scan_point(cells, controller_num)
        if desc is None:
            continue

        if desc['kind'] == 'loop':
            if desc['n'] in seen_loops:
                continue  # this loop already registered by another point
            seen_loops.add(desc['n'])
            new = build_loop_entries(item_name, desc['n'], desc['mnemonics'])
        else:
            if item_name in seen_tags:
                continue
            one = build_point_entry(item_name, desc)
            new = [one] if one else []

        for e in new:
            if e['tag'] in seen_tags:
                continue
            seen_tags.add(e['tag'])
            registers.append(e)

    wb.close()
    registers.sort(key=lambda r: r['addr'])
    return registers


# ─────────────────────────── optional CSV cross-check ───────────────────────────

def validate_against_csv(csv_path: Path) -> None:
    """Warn if the embedded LOOP_LAYOUT disagrees with an HC Designer export.

    Handles both report formats: the 8-column "Modbus Full Address Report"
    (Fixed map, C3) and the 15-column "Modbus All Partitions Report" (Custom
    map, C4) which inserts a Partition Name column.
    """
    rows = list(csv.reader(open(csv_path, encoding='utf-8-sig')))
    custom = any(len(r) > 1 and r[0].strip() == 'Hex Addr' and 'Partition Name' in r
                 for r in rows)
    tag_col = 3 if custom else 2
    type_col = 5 if custom else 4

    def norm(s):
        return re.sub(r'[^a-z0-9]', '', s.lower())

    expected = {off: norm(suf) for off, (suf, *_ ) in LOOP_LAYOUT.items()}
    # a few HC900-name aliases between the manual layout and HC Designer text
    aliases = {0x3e: {'opwork', 'outputb'}, 0x38: {'wspb', 'spwork'},
               0x0a: {'carbonpottemp', 'temp'}, 0x14: {'cycletime1', 'scancycletime'},
               0x28: {'cycletime2', 'cycletimescan', 'scancycletimeb'},
               0x52: {'localpctco', 'localpercentcarbmonoxide'},
               0x58: {'onoffhysteresis', 'onoffouthysterisis'},
               0x5a: {'carbpotdewpt', 'carbpotdewpt'}, 0x5c: {'stepmotortime', '3posstepmotortime'},
               0x22: {'stepdeadband', '3posstepdeadband'},
               0xbb: {'spselectstate', 'lspselectstate'},
               0xbe: {'loopstatus', 'loopstatusregister'},
               0xb9: {'antisootenable', 'antisootsplimenable'},
               0xb7: {'fuzzyenable', 'enabledisablefuzzy'},
               0xb8: {'demandtune', 'demandtunereq'}, 0x4e: {'manualreset'},
               0x50: {'feedforwardgain'}, 0x08: {'pvb', 'pv'}}
    mism = 0
    for r in rows:
        if len(r) <= type_col or not r[0].startswith('0x'):
            continue
        if r[type_col].strip() != 'PID':
            continue
        addr = int(r[0], 16)
        off = (addr - 0x40) % 0x100  # offset within the loop block
        if off not in LOOP_LAYOUT:
            continue
        suff = r[tag_col].split('.', 1)[1] if '.' in r[tag_col] else r[tag_col]
        got = norm(suff)
        ok = got == expected[off] or got in aliases.get(off, set()) \
            or expected[off] in got or got in expected[off]
        if not ok:
            mism += 1
            if mism <= 12:
                print(f'  ⚠ offset 0x{off:02X}: layout={expected[off]!r} csv={got!r} '
                      f'({r[0]})')
    print(f'  CSV cross-check: {"OK" if mism == 0 else f"{mism} mismatch(es)"} '
          f'({"Custom" if custom else "Fixed"} map)')


# ─────────────────────────── main ───────────────────────────

def build(sinam_path: Path, controller: str, output_path: Path,
          validate_csv: Path | None) -> None:
    if validate_csv:
        print(f'Validating embedded loop layout against {validate_csv}...')
        validate_against_csv(validate_csv)

    print(f'Parsing {controller} points from {sinam_path}...')
    registers = build_registers(sinam_path, controller)

    output = {
        'controller': controller,
        'report_generated': datetime.date.today().isoformat(),
        'float_format': 'FP_B',
        'notes': f'Register map built from {sinam_path.name} ({controller} only). '
                 'Experion point names are the keys; addresses are resolved from the '
                 'HC900 fixed loop layout (named addresses) and from explicit '
                 'non-named addresses in the export. .MD reads LoopStatus and writes '
                 'Auto/Manual State (write_addr).',
        'register_count': len(registers),
        'registers': registers,
    }
    output_path.write_text(json.dumps(output, indent=2, ensure_ascii=False),
                           encoding='utf-8')

    n_loops = len({r['description'].split()[1] for r in registers
                   if r['description'].startswith('LOOP')})
    print(f'\n✓ Wrote {output_path}')
    print(f'  {len(registers)} registers  ({n_loops} loops expanded)')
    by_access = {}
    for r in registers:
        by_access[r['access']] = by_access.get(r['access'], 0) + 1
    print(f'  by access: {by_access}')


if __name__ == '__main__':
    p = argparse.ArgumentParser(
        description='Build register-map.json from the Experion export (xlsx) alone')
    p.add_argument('--controller', required=True,
                   help='Experion controller name / indexed-address prefix, e.g. C3')
    p.add_argument('--sinam', required=True, help='Path to Sinam_Tag_all.xlsx')
    p.add_argument('-o', '--output', default='docs/register-map.json',
                   help='Output JSON path')
    p.add_argument('--validate-csv', default=None,
                   help='Optional HC Designer CSV export to cross-check the embedded layout')
    args = p.parse_args()

    build(Path(args.sinam), args.controller.upper(), Path(args.output),
          Path(args.validate_csv) if args.validate_csv else None)
