#!/usr/bin/env python3
"""Calculate the uplink code from lighting state objects, using Python 3 only.

Save the object inside each response's `state` field as JSON, then run:
    python3 internet_code.py red.json blue.json green.json yellow.json purple.json

A file may also contain an array of state objects. With no filenames, read JSON
from standard input. Add --json for structured output, or import derive(states).
Each state needs `lights` (the numeric color ID) and `lightsMeta` (13 strings).
Fewer than five colors produce subtotals and a list of missing colors, not a code.

Collect all five colors in the same game without resetting. State objects alone
cannot reliably identify a reset; discard the collection and start again after
one. Adapted from the investigation's internet_code.py, without recorder inputs.
"""

import argparse
import json
from pathlib import Path
import re
import sys


# Use the returned color ID: a requested color name may have been misinterpreted.
COLORS = {1: "red", 2: "blue", 4: "green", 3: "yellow", 5: "purple"}
PATTERNS = (
    ("four numerals", r"[0-9]{4}"),
    ("four identical letters", r"([A-Z])\1{3}"),
    ("repeated numeral/letter pair", r"([0-9][A-Z])\1"),
    ("consecutive alphabet letters", None),
    ("at least one numeral", r"(?=.*[0-9])[A-Z0-9]{4}"),
)


def infer_outlier(values):
    if not isinstance(values, list) or len(values) != 13:
        raise ValueError("Expected exactly 13 wall strings for each color")
    if any(not isinstance(value, str) or not re.fullmatch(r"[A-Z0-9]{4}", value)
           for value in values):
        raise ValueError("Copy the four-character uppercase letters/numerals exactly from lightsMeta")
    matches = []
    for name, pattern in PATTERNS:
        if pattern is None:
            def ordinary(code):
                return code.isalpha() and all(ord(b) == ord(a) + 1 for a, b in zip(code, code[1:]))
        else:
            def ordinary(code):
                return re.fullmatch(pattern, code) is not None
        exceptions = [index for index, code in enumerate(values) if not ordinary(code)]
        if len(exceptions) == 1:
            matches.append((name, exceptions[0]))
    if not matches:
        raise ValueError("No supported pattern has exactly 12 ordinary strings and one outlier")
    if len({index for _, index in matches}) != 1:
        raise ValueError("Supported patterns disagree about which string is exceptional")
    index = matches[0][1]
    code = values[index]
    return {
        "strings": list(values),
        "pattern": matches[0][0],
        "outlier_index": index,
        "outlier": code,
        "ascii_values": [ord(character) for character in code],
        "subtotal": sum(map(ord, code)),
    }


def derive(states):
    """Return per-color calculations and a code only when all five colors exist."""
    captured = {}
    for state in states:
        if not isinstance(state, dict) or "lightsMeta" not in state:
            raise ValueError("Expected a state object with lights and lightsMeta; copy the response's state field")
        color = state.get("lights")
        if type(color) is not int or color not in COLORS:
            raise ValueError(f"Unknown light color {color!r}; expected a state ID from 1 to 5")
        inferred = infer_outlier(state["lightsMeta"])
        if color in captured and captured[color]["strings"] != inferred["strings"]:
            raise ValueError(f"Conflicting {COLORS[color]} inscriptions; collect all five colors again from one game")
        captured[color] = {"color": COLORS[color], "color_id": color, **inferred}
    missing = [name for color, name in COLORS.items() if color not in captured]
    code = None
    if not missing:
        total = sum(result["subtotal"] for result in captured.values())
        if not 1000 <= total <= 9999:
            raise ValueError(f"Derived sum {total} is not a four-digit code; inspect the inscriptions")
        code = str(total)
    return {
        "code": code,
        "missing_colors": missing,
        "colors": [captured[color] for color in COLORS if color in captured],
    }


def read_states(text, source):
    try:
        value = json.loads(text)
    except ValueError as exc:
        raise ValueError(f"{source}: invalid JSON: {exc}") from exc
    if isinstance(value, dict):
        return [value]
    if isinstance(value, list):
        return value
    raise ValueError(f"{source}: expected a state object or an array of state objects")


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("files", nargs="*", type=Path, metavar="STATE.json")
    parser.add_argument("--json", action="store_true", help="Print calculations as JSON")
    args = parser.parse_args()
    try:
        states = []
        if args.files:
            for path in args.files:
                states.extend(read_states(path.read_text(encoding="utf-8"), str(path)))
        else:
            states = read_states(sys.stdin.read(), "standard input")
        report = derive(states)
    except (OSError, ValueError) as exc:
        parser.exit(1, f"Cannot calculate the code: {exc}\n")
    if args.json:
        print(json.dumps(report, indent=2))
        return
    for result in report["colors"]:
        values = " + ".join(map(str, result["ascii_values"]))
        print(f"{result['color']:6} {result['outlier']}  {values} = {result['subtotal']} ({result['pattern']})")
    if report["missing_colors"]:
        print("Still needed: " + ", ".join(report["missing_colors"]))
    else:
        print(f"Internet code: {report['code']}")


if __name__ == "__main__":
    main()
