#!/usr/bin/env python3
"""
Reorganize per-file vessel telemetry exports (e.g. Helios's) into
per-device files with structured metadata.

INPUT:  a directory of newline-delimited JSON files, one file per raw
        component name, e.g.:
            BCL25365B.json, BCL25365T.json, BCL25465B.json,
            BMWix349B.json, BMWix349T.json, ACH65B.json,
            speedOverGroundB.json, GD.json, ...

OUTPUT: a directory where every record has been enriched with

            {"component": <base device type>,
             "IP": <int|None>,
             "ID": <int|None>,
             "side": "B"|"T"|None,
             ...original fields...}

        and files are merged by base component. For example
        BCL25365B.json + BCL25365T.json + BCL25465B.json all collapse
        into a single BCL25.json (with each record's own IP/ID/side
        distinguishing which physical instance it came from).

USAGE:
    python3 reorganize_components.py /path/to/helios /path/to/output

    Run once per boat directory (Helios, HydraAix, ...), pointing each
    at its own output directory, since component files are boat-local.
"""

import json
import re
import sys
from pathlib import Path

# ---------------------------------------------------------------------------
# Per-letter-prefix parsing rules.
#
#   type_digit_len : how many of the digits immediately following the
#                     letters belong to the *model name* itself
#                     (e.g. the "25" in BCL25). The remaining digits are
#                     the instance code (IP + ID).
#   has_instance    : False means this device has no separate IP/ID
#                     instance code -- its digits are just part of a
#                     fixed model name (e.g. ACH65 is one physical unit,
#                     addressed only by side, not by IP/ID).
#
# Confirmed from the sample data:
#   BCL25365B  -> BCL25, IP=3, ID=65, side=B
#   BCL25465B  -> BCL25, IP=4, ID=65, side=B
#   BMWix349B  -> BMWix, IP=3, ID=49, side=B
#   BMWix333B  -> BMWix, IP=3, ID=33, side=B
#   ElPtx350T  -> ElPtx, IP=3, ID=50, side=T
#   ACH65B     -> ACH65 (no instance split; one fixed unit per side)
#
# Extend this table if you discover more device families that need
# their own digit-split rule.
# ---------------------------------------------------------------------------
TYPE_RULES = {
    "BCL":   {"type_digit_len": 2,    "has_instance": True},   # BCL25 + instance
    "BMWix": {"type_digit_len": 0,    "has_instance": True},   # BMWix + instance
    "ElPtx": {"type_digit_len": 0,    "has_instance": True},   # ElPtx + instance
    "ACH":   {"type_digit_len": None, "has_instance": False},  # fixed model, no instance
}

# Matches an optional run of letters followed by an optional run of digits,
# e.g. "BCL25365" -> ("BCL", "25365"); "speedOverGround" -> ("speedOverGround", "")
NAME_RE = re.compile(r'^([A-Za-z]+)(\d*)$')


def parse_filename(stem: str):
    """
    Split a raw component filename stem (no extension) into
    (component, ip, id, side).
    """
    side = None
    base = stem
    if stem and stem[-1] in ("B", "T"):
        side = stem[-1]
        base = stem[:-1]

    m = NAME_RE.match(base)
    if not m:
        # Doesn't fit letters(+digits) at all -- pass through untouched.
        return base, None, None, side

    letters, digits = m.group(1), m.group(2)

    if not digits:
        # Plain metric name, e.g. speedOverGround, hmiSmuIp, GD.
        return letters, None, None, side

    rule = TYPE_RULES.get(letters)
    if rule is None:
        # Unknown letter-prefix family that happens to carry digits.
        # Leave the name intact but flag it so a rule can be added.
        print(f"[warn] no TYPE_RULES entry for prefix '{letters}' "
              f"(from '{stem}') -- leaving component name as-is",
              file=sys.stderr)
        return base, None, None, side

    if not rule["has_instance"]:
        # All digits belong to the fixed model name; no IP/ID split.
        return letters + digits, None, None, side

    n = rule["type_digit_len"]
    component = letters + digits[:n]
    instance_digits = digits[n:]

    if not instance_digits:
        return component, None, None, side

    ip = int(instance_digits[0])
    id_ = int(instance_digits[1:]) if len(instance_digits) > 1 else None
    return component, ip, id_, side


def process_directory(in_dir: Path, out_dir: Path):
    out_dir.mkdir(parents=True, exist_ok=True)

    json_files = sorted(in_dir.glob("*.json"))
    if not json_files:
        print(f"No .json files found in {in_dir}", file=sys.stderr)
        return

    # component -> open output file handle
    out_handles = {}

    try:
        for path in json_files:
            stem = path.stem
            component, ip, id_, side = parse_filename(stem)

            if component not in out_handles:
                out_path = out_dir / f"{component}.json"
                # 'w' the first time we see this component in this run,
                # so re-running the script doesn't append onto stale output.
                out_handles[component] = out_path.open("w", encoding="utf-8")
                print(f"{path.name} -> {out_path.name}  "
                      f"(component={component}, IP={ip}, ID={id_}, side={side})")
            else:
                print(f"{path.name} -> {component}.json  "
                      f"(component={component}, IP={ip}, ID={id_}, side={side})")

            out_fh = out_handles[component]

            with path.open("r", encoding="utf-8") as in_fh:
                for line_num, line in enumerate(in_fh, 1):
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        record = json.loads(line)
                    except json.JSONDecodeError as e:
                        print(f"[warn] {path.name}:{line_num} skipped, "
                              f"bad JSON ({e})", file=sys.stderr)
                        continue

                    # Build the record with component/IP/ID/side first,
                    # then all original fields (minus any stale copies
                    # of those same keys), preserving the rest as-is.
                    new_record = {
                        "component": component,
                        "IP": ip,
                        "ID": id_,
                        "side": side,
                    }
                    for k, v in record.items():
                        if k not in ("component", "IP", "ID", "side"):
                            new_record[k] = v

                    out_fh.write(json.dumps(new_record) + "\n")
    finally:
        for fh in out_handles.values():
            fh.close()


def main():
    if len(sys.argv) != 3:
        print(f"Usage: {sys.argv[0]} <input_dir> <output_dir>", file=sys.stderr)
        sys.exit(1)

    in_dir = Path(sys.argv[1])
    out_dir = Path(sys.argv[2])

    if not in_dir.is_dir():
        print(f"Input directory not found: {in_dir}", file=sys.stderr)
        sys.exit(1)

    process_directory(in_dir, out_dir)


if __name__ == "__main__":
    main()
