#!/usr/bin/env python3
"""Is a match row free to work?  ->  FREE | TAKEN by region | TAKEN by inflight

Usage:
    sf3_free 0xADDR [0xADDR ...] [--ledger PATH | --phase N]

Two checks, in order:

1. **config/regions.tsv** -- a CONTAINMENT test, not a string match. A row `A .. B` means
   the region is `[A, B)`, so **B itself is NOT in it**. A naive `grep` reports a free address
   as taken whenever it happens to be the exclusive end of the preceding region.
2. **.run/pN/inflight.tsv** -- the write-ahead ledger, read with **LAST-ROW-WINS**. The
   effective state of an address is its *last* row: `wip` and `claimed` mean held,
   `released` means free. `claimed` is HELD, and that is a correction -- see below.

*** THE LEDGER IS PER-PHASE, AND ONE PHASE'S LEDGER IS DEAD TO THE NEXT ***

`.run/` is git-ignored and the ledger is session-scoped by design: it coordinates the workers
who are alive *now*. Phase 11 hard-coded this tool to `.run/p11/inflight.tsv`, so in Phase 12 it
would have silently read a 306-line ledger of `wip` rows whose holders no longer exist -- which
is cookbook 187's stale-taken failure, reintroduced by a hard-coded path.

Resolution order:

  * `--ledger PATH` -- explicit, always wins;
  * `--phase N`    -- `.run/pN/inflight.tsv`;
  * default        -- the **highest-numbered existing** `.run/pN/` directory, because that is the
                     current phase. A previous phase's ledger is never consulted: a row held
                     `wip` by a retired worker must read FREE, and it does.

If the resolved ledger does not exist, every address reports FREE (nothing is in flight), which
is the correct state at the open of a phase.

The resolved path is printed to **stderr** (`sf3_free: ledger=...`) so "which ledger did that
read?" is always answerable, while stdout stays parseable.

*** WHY THIS IS A TRACKED TOOL AND NOT A STAGING SCRIPT ***

The first version lived in a worker's staging directory and was copied by everyone on the
orchestrator's instruction. It was WRONG: it scanned the ledger and reported TAKEN if **any**
row for the address was not `released`, so a row that was `wip` and later `released` stayed
blocked **forever**. Worker E found it and measured 13 released rows reading as taken -- several
of them the cheapest rows left in the project.

The lesson is not "fix the script", it is **"a rule that every worker must follow belongs in a
tracked tool, not in a file each worker copies."** A copied script cannot be fixed for the
people who already copied it.
"""
from __future__ import annotations

import re
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
RUN_DIR = REPO / ".run"
REGIONS = REPO / "config/regions.tsv"
INFLIGHT = REPO / ".run/p11/inflight.tsv"  # legacy default; see resolve_ledger()

_PHASE_DIR = re.compile(r"^p(\d+)$")


def parse_addr(text: str) -> int:
    try:
        return int(text, 16)
    except ValueError:
        raise SystemExit(f"not a hex address: {text!r}")


def _phase_dir(run_dir: Path, phase: int) -> Path:
    return run_dir / f"p{phase}"


def current_phase(run_dir: Path | None = None) -> int | None:
    """The highest-numbered existing `.run/pN/` directory, or None."""
    run_dir = RUN_DIR if run_dir is None else Path(run_dir)
    if not run_dir.is_dir():
        return None
    phases = [
        int(m.group(1))
        for entry in run_dir.iterdir()
        if entry.is_dir() and (m := _PHASE_DIR.match(entry.name))
    ]
    return max(phases) if phases else None


def resolve_ledger(
    explicit: str | None = None,
    phase: int | None = None,
    run_dir: Path | None = None,
) -> Path | None:
    """The ledger to read, or None when the current phase has none yet.

    A previous phase's ledger is deliberately NOT a fallback: a `wip` row whose holder was
    retired at the close of that phase must not block the row in this phase.
    """
    if explicit is not None:
        return Path(explicit)
    if phase is not None:
        path = _phase_dir(RUN_DIR if run_dir is None else Path(run_dir), phase) / "inflight.tsv"
        return path if path.exists() else None
    found = current_phase(run_dir)
    if found is None:
        return None
    path = _phase_dir(RUN_DIR if run_dir is None else Path(run_dir), found) / "inflight.tsv"
    return path if path.exists() else None


def region_owner(addr: int, regions: Path | None = None) -> str | None:
    regions = REGIONS if regions is None else Path(regions)
    if not regions.exists():
        return None
    for line in regions.read_text().splitlines():
        if line.startswith("#") or not line.strip():
            continue
        fields = line.split("\t")
        if len(fields) >= 2 and int(fields[0], 16) <= addr < int(fields[1], 16):
            return line.strip()
    return None


def ledger_state(addr: int, ledger: Path | None = None) -> tuple[str, str] | None:
    """The LAST row for this address, per the ledger's own rule."""
    ledger = INFLIGHT if ledger is None else ledger
    if ledger is None or not Path(ledger).exists():
        return None
    last: str | None = None
    for line in Path(ledger).read_text().splitlines():
        if line.startswith("#") or not line.strip():
            continue
        fields = line.split("\t")
        if len(fields) >= 2 and int(fields[0], 16) == addr:
            last = line.strip()
    if last is None:
        return None
    fields = last.split("\t")
    status = fields[3].strip() if len(fields) >= 4 else "wip"
    return status, last


def main(argv: list[str]) -> int:
    explicit: str | None = None
    phase: int | None = None
    addresses: list[str] = []
    claim_as: str | None = None

    index = 0
    while index < len(argv):
        arg = argv[index]
        if arg in ("--ledger", "--phase", "--claim"):
            if index + 1 >= len(argv):
                raise SystemExit(f"{arg} needs a value")
            value = argv[index + 1]
            if arg == "--ledger":
                explicit = value
            elif arg == "--claim":
                claim_as = value
            else:
                try:
                    phase = int(value)
                except ValueError:
                    raise SystemExit(f"--phase wants a number, got {value!r}")
            index += 2
            continue
        if arg == "-h" or arg == "--help":
            print(__doc__.strip())
            return 0
        if arg.startswith("--"):
            raise SystemExit(f"unknown option: {arg}")
        addresses.append(arg)
        index += 1

    if not addresses:
        print(__doc__.strip())
        return 2

    ledger = resolve_ledger(explicit=explicit, phase=phase)
    shown = "none" if ledger is None else (
        str(ledger.relative_to(REPO)) if str(ledger).startswith(str(REPO)) else str(ledger)
    )
    print(f"sf3_free: ledger={shown}", file=sys.stderr)

    # `free` collects the addresses that may be claimed; `failed` is sticky so a batch is
    # all-or-nothing. Both were missing on the first write of this and the test suite caught it
    # immediately -- recorded because the tests earned their keep on the very change that
    # introduced them.
    free: list[int] = []
    failed = False

    for text in addresses:
        addr = parse_addr(text)
        owner = region_owner(addr)
        if owner is not None:
            print(f"{text}  TAKEN by region: {owner}")
            failed = True
            continue
        state = ledger_state(addr, ledger)
        if state is None:
            print(f"{text}  FREE (no ledger row)")
            free.append(addr)
        elif state[0] == "wip":
            print(f"{text}  TAKEN by inflight: {state[1]}")
            failed = True
        elif state[0] == "claimed":
            # HELD, not free. A `claimed` row means a worker has STAGED a verified claim and is
            # waiting for the orchestrator to merge it. Treating that window as free is a false
            # FREE in the dangerous direction: the row is neither registered nor abandoned, so the
            # next worker re-derives it and the merge can then receive two claims for one address.
            # Worker D found this and reported it rather than changing a tracked tool itself.
            # The window is not hypothetical -- it is exactly what the credit outage produced, when
            # all four workers stopped with claims in flight and the pools were re-dispatched.
            # Note a registered row never reaches here: region_owner() above already returns TAKEN,
            # so this branch only ever fires for a claim whose merge has NOT landed.
            print(f"{text}  TAKEN by inflight (claim staged, merge pending): {state[1]}")
            failed = True
        else:
            print(f"{text}  FREE (last ledger row '{state[0]}'): {state[1]}")
            free.append(addr)

    # ---- --claim: check and claim in ONE step -----------------------------------------------
    # Because reading the verdict and appending the `wip` row as two separate shell commands is
    # racy: worker B appended its `wip` row in the same shell line as this check, *before* reading
    # the output, so for a few minutes the ledger's last row for an address said B while D held it.
    # B caught it, did not touch the row, and restored D's hold by appending the `wip` row back
    # (last-row-wins). The slip was in the SAFE direction -- B's own row masked the true holder as
    # TAKEN -- but the inverse ordering, appending on a stale read, is how two workers end up on one
    # address. Making the correct idiom the easy one costs nothing.
    if claim_as is not None:
        if failed:
            print(f"sf3_free: NOT claiming -- at least one address is not free", file=sys.stderr)
            return 1
        with ledger.open("a") as fh:
            for addr in free:
                fh.write(f"{addr:#010x}\t{claim_as}\t-\twip\n")
        print(f"CLAIMED {len(free)} address(es) as {claim_as}", file=sys.stderr)
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
