"""Compute gradiometer differences from a recorded QTFM CSV.

The application records one row per shared millisecond timestamp containing
every sensor's field, without difference columns. This standalone parser
computes any ``X − Y`` channels from such a recording:

    # See which sensors a recording contains
    python gradiometer_parser.py recording_gradiometer.csv --list

    # Write A−B and C−D into recording_differences.csv
    python gradiometer_parser.py recording_gradiometer.csv --pairs A-B C-D

    # Also copy the sensor field columns used by the pairs
    python gradiometer_parser.py recording_gradiometer.csv --pairs A-B \
        --include-fields --output survey_ab.csv

Only the Python standard library is required. Recordings from older
application versions that already contain difference columns are accepted;
their extra columns are simply ignored.
"""

from __future__ import annotations

import argparse
import csv
import re
import sys
from pathlib import Path

_SENSOR_COLUMN = re.compile(r"^sensor_([a-z])_nt(?:\s*\[(.*)\])?$")


def find_sensor_columns(header: list[str]) -> dict[str, int]:
    """Map sensor letters ("A".."H") onto their column indexes."""
    columns: dict[str, int] = {}
    for index, name in enumerate(header):
        match = _SENSOR_COLUMN.match(name.strip())
        if match:
            columns[match.group(1).upper()] = index
    return columns


def sensor_serial(header: list[str], column: int) -> str:
    match = _SENSOR_COLUMN.match(header[column].strip())
    serial = match.group(2) if match else None
    return serial or "unknown"


def parse_pair(text: str) -> tuple[str, str]:
    """Parse a pair such as ``A-B`` (also accepts ``a-b`` or ``A−B``)."""
    normalized = text.strip().upper().replace("−", "-")
    parts = [part.strip() for part in normalized.split("-") if part.strip()]
    if len(parts) != 2 or any(len(part) != 1 for part in parts):
        raise ValueError(
            f"Invalid pair {text!r}: use two sensor letters such as A-B."
        )
    first, second = parts
    if first == second:
        raise ValueError(f"Invalid pair {text!r}: the sensors must differ.")
    return first, second


def difference_header(first: str, second: str) -> str:
    return f"{first.lower()}_minus_{second.lower()}_nt"


def compute_differences(
    input_path: Path,
    pairs: list[tuple[str, str]],
    output_path: Path,
    include_fields: bool = False,
) -> tuple[int, int]:
    """Write the requested difference channels; return (rows written, skipped)."""
    with input_path.open(newline="", encoding="utf-8") as input_file:
        reader = csv.reader(input_file)
        header = next(reader, None)
        if header is None:
            raise ValueError(f"{input_path} is empty.")

        columns = find_sensor_columns(header)
        missing = sorted(
            {key for pair in pairs for key in pair} - set(columns)
        )
        if missing:
            available = ", ".join(sorted(columns)) or "none"
            raise ValueError(
                f"Sensor(s) {', '.join(missing)} not present in {input_path} "
                f"(available: {available})."
            )

        field_keys: list[str] = []
        if include_fields:
            seen: set[str] = set()
            for pair in pairs:
                for key in pair:
                    if key not in seen:
                        seen.add(key)
                        field_keys.append(key)

        try:
            time_column = header.index("pc_time_utc")
            timestamp_column = header.index("timestamp_ms")
        except ValueError as exc:
            raise ValueError(
                f"{input_path} is not a QTFM recording: {exc}"
            ) from None

        written = 0
        skipped = 0
        with output_path.open("w", newline="", encoding="utf-8") as output_file:
            writer = csv.writer(output_file)
            writer.writerow(
                (
                    "pc_time_utc",
                    "timestamp_ms",
                    *(header[columns[key]] for key in field_keys),
                    *(
                        difference_header(first, second)
                        for first, second in pairs
                    ),
                )
            )
            for row in reader:
                try:
                    values = {
                        key: float(row[columns[key]])
                        for pair in pairs
                        for key in pair
                    }
                except (IndexError, ValueError):
                    skipped += 1
                    continue
                writer.writerow(
                    (
                        row[time_column],
                        row[timestamp_column],
                        *(row[columns[key]] for key in field_keys),
                        *(
                            f"{values[first] - values[second]:.9f}"
                            for first, second in pairs
                        ),
                    )
                )
                written += 1
    return written, skipped


def list_sensors(input_path: Path) -> list[tuple[str, str]]:
    """Return (sensor letter, serial number) for every sensor column."""
    with input_path.open(newline="", encoding="utf-8") as input_file:
        header = next(csv.reader(input_file), None)
    if header is None:
        raise ValueError(f"{input_path} is empty.")
    columns = find_sensor_columns(header)
    return [
        (key, sensor_serial(header, column))
        for key, column in sorted(columns.items())
    ]


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("input", type=Path, help="Recorded QTFM CSV file.")
    parser.add_argument(
        "--list",
        action="store_true",
        help="List the sensors in the recording and exit.",
    )
    parser.add_argument(
        "--pairs",
        nargs="+",
        metavar="X-Y",
        help="Difference channels to compute, e.g. --pairs A-B A-C.",
    )
    parser.add_argument(
        "--include-fields",
        action="store_true",
        help="Also copy the sensor field columns used by the pairs.",
    )
    parser.add_argument(
        "--output",
        type=Path,
        help="Output CSV (default: <input>_differences.csv).",
    )
    args = parser.parse_args(argv)

    if not args.input.exists():
        parser.error(f"{args.input} does not exist.")

    if args.list:
        for key, serial in list_sensors(args.input):
            print(f"Sensor {key}  (S/N {serial})")
        return 0

    if not args.pairs:
        parser.error("Choose difference channels with --pairs, e.g. A-B A-C.")
    try:
        pairs = [parse_pair(text) for text in args.pairs]
    except ValueError as exc:
        parser.error(str(exc))

    output_path = args.output
    if output_path is None:
        base = args.input.with_suffix("")
        output_path = Path(f"{base}_differences.csv")

    try:
        written, skipped = compute_differences(
            args.input,
            pairs,
            output_path,
            include_fields=args.include_fields,
        )
    except ValueError as exc:
        parser.error(str(exc))

    channels = ", ".join(f"{a}−{b}" for a, b in pairs)
    print(f"Wrote {written:,} rows of {channels} to {output_path}")
    if skipped:
        print(f"Skipped {skipped:,} rows with missing or unreadable fields.")
    return 0


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