from __future__ import annotations

import argparse
import base64
import hashlib
import json
import secrets
import sys
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Dict, List, Optional


# -------------------- Lexer --------------------

class LexError(Exception):
    def __init__(self, message: str, line: int, col: int):
        super().__init__(f"LexError at {line}:{col} - {message}")
        self.line = line
        self.col = col


@dataclass(frozen=True)
class Token:
    kind: str
    value: Optional[str]
    line: int
    col: int


KEYWORDS = {"swap", "before", "after", "transfer", "from", "to"}


def _is_ident_start(ch: str) -> bool:
    return ch.isalpha() or ch == "_"


def _is_ident_part(ch: str) -> bool:
    return ch.isalnum() or ch == "_"


def tokenise(source: str) -> List[Token]:
    tokens: List[Token] = []
    line = 1
    col = 1
    i = 0

    while i < len(source):
        ch = source[i]

        if ch == "\n":
            tokens.append(Token("NEWLINE", None, line, col))
            line += 1
            col = 1
            i += 1
            continue
        if ch == "\r":
            i += 1
            continue
        if ch.isspace():
            i += 1
            col += 1
            continue

        if ch == "#":
            while i < len(source) and source[i] not in ("\n", "\r"):
                i += 1
                col += 1
            continue

        if ch == "{":
            tokens.append(Token("LBRACE", "{", line, col))
            i += 1
            col += 1
            continue
        if ch == "}":
            tokens.append(Token("RBRACE", "}", line, col))
            i += 1
            col += 1
            continue
        if ch == ":":
            tokens.append(Token("COLON", ":", line, col))
            i += 1
            col += 1
            continue
        if ch == "=":
            tokens.append(Token("EQUAL", "=", line, col))
            i += 1
            col += 1
            continue

        if ch.isdigit():
            start_col = col
            start = i
            while i < len(source) and source[i].isdigit():
                i += 1
                col += 1
            if i < len(source) and source[i] == ".":
                i += 1
                col += 1
                if i >= len(source) or not source[i].isdigit():
                    raise LexError("Invalid number literal", line, col)
                while i < len(source) and source[i].isdigit():
                    i += 1
                    col += 1
            value = source[start:i]
            tokens.append(Token("NUMBER", value, line, start_col))
            continue

        if _is_ident_start(ch):
            start_col = col
            start = i
            i += 1
            col += 1
            while i < len(source) and _is_ident_part(source[i]):
                i += 1
                col += 1
            value = source[start:i]
            kind = "KEYWORD" if value in KEYWORDS else "IDENT"
            tokens.append(Token(kind, value, line, start_col))
            continue

        raise LexError(f"Unexpected character '{ch}'", line, col)

    tokens.append(Token("EOF", None, line, col))
    return tokens


# -------------------- Parser --------------------

class ParseError(Exception):
    def __init__(self, message: str, line: int, col: int):
        super().__init__(f"ParseError at {line}:{col} - {message}")
        self.line = line
        self.col = col


@dataclass(frozen=True)
class Entry:
    party: str
    amount: Decimal
    asset: str


@dataclass(frozen=True)
class Transfer:
    amount: Decimal
    asset: str
    from_party: str
    to_party: str


@dataclass
class Program:
    before: List[Entry]
    after: List[Entry]
    transfers: List[Transfer]


class Parser:
    def __init__(self, tokens: List[Token]):
        self.tokens = tokens
        self.pos = 0

    def _peek(self) -> Token:
        return self.tokens[self.pos]

    def _advance(self) -> Token:
        tok = self.tokens[self.pos]
        self.pos += 1
        return tok

    def _match(self, kind: str, value: Optional[str] = None) -> bool:
        tok = self._peek()
        if tok.kind != kind:
            return False
        if value is not None and tok.value != value:
            return False
        self._advance()
        return True

    def _expect(self, kind: str, value: Optional[str] = None) -> Token:
        tok = self._peek()
        if tok.kind != kind or (value is not None and tok.value != value):
            expected = f"{kind} {value}" if value else kind
            raise ParseError(f"Expected {expected}", tok.line, tok.col)
        return self._advance()

    def _skip_newlines(self) -> None:
        while self._match("NEWLINE"):
            pass

    def parse(self) -> Program:
        self._skip_newlines()
        self._expect("KEYWORD", "swap")
        self._skip_newlines()
        self._expect("LBRACE")

        before: List[Entry] = []
        after: List[Entry] = []
        transfers: List[Transfer] = []
        seen_before = False
        seen_after = False

        while True:
            self._skip_newlines()
            if self._match("RBRACE"):
                break

            tok = self._peek()
            if tok.kind == "KEYWORD" and tok.value in ("before", "after"):
                section = tok.value
                if section == "before" and seen_before:
                    raise ParseError("Duplicate 'before' block", tok.line, tok.col)
                if section == "after" and seen_after:
                    raise ParseError("Duplicate 'after' block", tok.line, tok.col)

                self._advance()
                self._expect("COLON")
                if section == "before":
                    seen_before = True
                    before.extend(self._parse_entries_until_section_or_end())
                else:
                    seen_after = True
                    after.extend(self._parse_entries_until_section_or_end())
                continue
            if tok.kind == "KEYWORD" and tok.value == "transfer":
                transfers.append(self._parse_transfer())
                continue
            if tok.kind == "KEYWORD" and tok.value == "proof":
                raise ParseError("Proof blocks are no longer allowed in .sph", tok.line, tok.col)

            raise ParseError("Expected 'before', 'after', or 'transfer'", tok.line, tok.col)

        if transfers and (before or after):
            raise ParseError("Cannot mix 'transfer' syntax with 'before/after' blocks", tok.line, tok.col)

        if transfers and not (before or after):
            before, after = _derive_states_from_transfers(transfers)

        if not transfers and (not seen_before or not seen_after):
            tok = self._peek()
            raise ParseError("Both 'before' and 'after' blocks are required", tok.line, tok.col)

        return Program(before=before, after=after, transfers=transfers)

    def _parse_entries_until_section_or_end(self) -> List[Entry]:
        entries: List[Entry] = []
        while True:
            self._skip_newlines()
            tok = self._peek()
            if tok.kind == "RBRACE":
                break
            if tok.kind == "KEYWORD" and tok.value in ("before", "after", "transfer"):
                break

            party_tok = self._expect("IDENT")
            self._expect("EQUAL")
            amount_tok = self._expect("NUMBER")
            asset_tok = self._expect("IDENT")
            entries.append(
                Entry(
                    party=party_tok.value,
                    amount=Decimal(amount_tok.value),
                    asset=asset_tok.value,
                )
            )
            self._skip_newlines()
        return entries

    def _parse_transfer(self) -> Transfer:
        self._expect("KEYWORD", "transfer")
        amount_tok = self._expect("NUMBER")
        asset_tok = self._expect("IDENT")
        self._expect("KEYWORD", "from")
        from_tok = self._expect("IDENT")
        self._expect("KEYWORD", "to")
        to_tok = self._expect("IDENT")
        self._skip_newlines()
        return Transfer(
            amount=Decimal(amount_tok.value),
            asset=asset_tok.value,
            from_party=from_tok.value,
            to_party=to_tok.value,
        )



def parse(tokens: List[Token]) -> Program:
    return Parser(tokens).parse()


def _derive_states_from_transfers(transfers: List[Transfer]) -> tuple[list[Entry], list[Entry]]:
    before_map: Dict[str, Dict[str, Decimal]] = {}
    after_map: Dict[str, Dict[str, Decimal]] = {}
    for t in transfers:
        before_map.setdefault(t.from_party, {})
        before_map[t.from_party][t.asset] = before_map[t.from_party].get(t.asset, Decimal("0")) + t.amount
        after_map.setdefault(t.to_party, {})
        after_map[t.to_party][t.asset] = after_map[t.to_party].get(t.asset, Decimal("0")) + t.amount

    before_entries: List[Entry] = []
    after_entries: List[Entry] = []
    for party, assets in before_map.items():
        for asset, amount in assets.items():
            before_entries.append(Entry(party=party, amount=amount, asset=asset))
    for party, assets in after_map.items():
        for asset, amount in assets.items():
            after_entries.append(Entry(party=party, amount=amount, asset=asset))
    return before_entries, after_entries


# -------------------- Verifier --------------------

class VerifyError(Exception):
    def __init__(self, message: str):
        super().__init__(f"VerifyError - {message}")


@dataclass
class VerificationSummary:
    valid: bool
    conservation: Dict[str, Dict[str, Decimal]]
    delta: Dict[str, Dict[str, Decimal]]


def verify(program: Program) -> VerificationSummary:
    _ensure_unique_parties(program.before, "before")
    _ensure_unique_parties(program.after, "after")

    for entry in program.before + program.after:
        if entry.amount < 0:
            raise VerifyError("Negative amounts are not allowed.")

    if program.transfers:
        _verify_transfer_conservation(program.transfers)

    before_by_asset = _sum_by_asset(program.before)
    after_by_asset = _sum_by_asset(program.after)
    conservation = _build_conservation(before_by_asset, after_by_asset)

    return VerificationSummary(
        valid=True,
        conservation=conservation,
        delta=_compute_delta(program.before, program.after),
    )


def _sum_by_asset(entries: List[Entry]) -> Dict[str, Decimal]:
    sums: Dict[str, Decimal] = {}
    for e in entries:
        sums[e.asset] = sums.get(e.asset, Decimal("0")) + e.amount
    return sums


def _verify_transfer_conservation(transfers: List[Transfer]) -> None:
    out_by_asset: Dict[str, Decimal] = {}
    in_by_asset: Dict[str, Decimal] = {}
    for t in transfers:
        out_by_asset[t.asset] = out_by_asset.get(t.asset, Decimal("0")) + t.amount
        in_by_asset[t.asset] = in_by_asset.get(t.asset, Decimal("0")) + t.amount

    assets = set(out_by_asset.keys()) | set(in_by_asset.keys())
    for asset in sorted(assets):
        out_val = out_by_asset.get(asset, Decimal("0"))
        in_val = in_by_asset.get(asset, Decimal("0"))
        if out_val != in_val:
            raise VerifyError(
                f"Conservation failed: {asset} before={_decimal_to_str(out_val)}, "
                f"after={_decimal_to_str(in_val)}"
            )


def _canonical_swap_message(program: Program) -> str:
    if program.transfers:
        payload = {
            "type": "swap",
            "format": "transfer",
            "transfers": [
                {
                    "from": t.from_party,
                    "to": t.to_party,
                    "asset": t.asset,
                    "amount": _decimal_to_str(t.amount),
                }
                for t in program.transfers
            ],
        }
    else:
        payload = {
            "type": "swap",
            "format": "state",
            "before": _entries_to_state_dict(program.before),
            "after": _entries_to_state_dict(program.after),
        }
    return json.dumps(payload, sort_keys=True, separators=(",", ":"))


def _swap_id_from_program(program: Program) -> str:
    message = _canonical_swap_message(program)
    return hashlib.sha256(message.encode("utf-8")).hexdigest()


def _entries_to_state_dict(entries: List[Entry]) -> Dict[str, Dict[str, str]]:
    out: Dict[str, Dict[str, str]] = {}
    for e in entries:
        out.setdefault(e.party, {})
        out[e.party][e.asset] = _decimal_to_str(e.amount)
    return out


def _bitcoin_message_hash(message: bytes) -> bytes:
    prefix = b"Bitcoin Signed Message:\n"
    data = _encode_varint(len(prefix)) + prefix + _encode_varint(len(message)) + message
    return hashlib.sha256(hashlib.sha256(data).digest()).digest()


def _encode_varint(n: int) -> bytes:
    if n < 0xfd:
        return bytes([n])
    if n <= 0xFFFF:
        return b"\xfd" + n.to_bytes(2, "little")
    if n <= 0xFFFFFFFF:
        return b"\xfe" + n.to_bytes(4, "little")
    return b"\xff" + n.to_bytes(8, "little")


def _verify_bitcoin_signature(address: str, signature: str, digest: bytes) -> bool:
    try:
        sig_bytes = _decode_signature(signature)
    except VerifyError:
        return False

    header = sig_bytes[0]
    recid, compressed = _extract_recovery_id(header)
    sig = sig_bytes[1:]

    try:
        from ecdsa import SECP256k1, VerifyingKey  # type: ignore
        from ecdsa.util import sigdecode_string  # type: ignore
    except Exception as exc:
        raise VerifyError("Missing dependency 'ecdsa' for signature verification.") from exc

    try:
        candidates = VerifyingKey.from_public_key_recovery_with_digest(
            sig,
            digest,
            curve=SECP256k1,
            sigdecode=sigdecode_string,
        )
        if recid < 0 or recid >= len(candidates):
            return False
        pubkey = candidates[recid]
        if not pubkey.verify_digest(sig, digest, sigdecode=sigdecode_string):
            return False
    except Exception:
        return False

    pubkey_bytes = pubkey.to_string()
    x = pubkey_bytes[:32]
    y = pubkey_bytes[32:]
    y_int = int.from_bytes(y, "big")
    prefix = b"\x03" if (y_int & 1) == 1 else b"\x02"
    pubkey_comp = prefix + x
    pubkey_uncomp = b"\x04" + x + y

    network = _infer_network(address)
    candidates = set()
    if network == "main":
        candidates.add(_p2pkh_address(pubkey_comp, mainnet=True))
        candidates.add(_p2pkh_address(pubkey_uncomp, mainnet=True))
        candidates.add(_p2sh_p2wpkh_address(pubkey_comp, mainnet=True))
        candidates.add(_p2wpkh_address(pubkey_comp, hrp="bc"))
    else:
        candidates.add(_p2pkh_address(pubkey_comp, mainnet=False))
        candidates.add(_p2pkh_address(pubkey_uncomp, mainnet=False))
        candidates.add(_p2sh_p2wpkh_address(pubkey_comp, mainnet=False))
        candidates.add(_p2wpkh_address(pubkey_comp, hrp="tb"))

    return address in candidates


def verify_proof(compiled: CompiledProof, proof_json: Dict[str, Any]) -> Dict[str, Any]:
    if "swap_id" not in proof_json:
        raise VerifyError("Proof JSON missing swap_id.")
    if proof_json["swap_id"] != compiled.swap_id:
        raise VerifyError("Proof JSON swap_id does not match compiled swap_id.")

    signatures = proof_json.get("signatures", {})
    if not isinstance(signatures, dict):
        raise VerifyError("Proof JSON signatures must be an object.")

    parties = {e.party for e in compiled.program.before} | {e.party for e in compiled.program.after}
    digest = _bitcoin_message_hash(compiled.swap_id.encode("utf-8"))

    for party in sorted(parties):
        if party not in signatures:
            raise VerifyError(f"Missing signature for party '{party}'.")
        entry = signatures[party]
        address = entry.get("address")
        signature = entry.get("signature")
        if not address or not signature:
            raise VerifyError(f"Incomplete signature for party '{party}'.")
        if not _verify_bitcoin_signature(address, signature, digest):
            raise VerifyError(f"Invalid signature for party '{party}'.")

    return {"status": "signatures_valid", "swap_id": compiled.swap_id}


def _decode_signature(signature: str) -> bytes:
    if not signature:
        raise VerifyError("Empty signature.")
    sig = signature.strip()
    try:
        if _is_hex(sig):
            sig_bytes = bytes.fromhex(sig)
        else:
            sig_bytes = base64.b64decode(sig)
    except Exception as exc:
        raise VerifyError("Invalid signature encoding.") from exc
    if len(sig_bytes) != 65:
        raise VerifyError("Invalid signature length.")
    return sig_bytes


def _is_hex(value: str) -> bool:
    return all(ch in "0123456789abcdefABCDEF" for ch in value) and len(value) % 2 == 0


def _extract_recovery_id(header: int) -> tuple[int, bool]:
    if 27 <= header <= 34:
        recid = (header - 27) & 3
        compressed = header >= 31
        return recid, compressed
    if 35 <= header <= 38:
        recid = header - 35
        return recid, True
    if 39 <= header <= 42:
        recid = header - 39
        return recid, True
    raise VerifyError("Invalid signature header.")


def _infer_network(address: str) -> str:
    if address.startswith("bc1") or address.startswith("1") or address.startswith("3"):
        return "main"
    if address.startswith("tb1") or address.startswith("m") or address.startswith("n") or address.startswith("2"):
        return "test"
    return "main"


def _hash160(data: bytes) -> bytes:
    sha = hashlib.sha256(data).digest()
    ripe = hashlib.new("ripemd160", sha).digest()
    return ripe


_BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"


def _base58check_encode(payload: bytes) -> str:
    checksum = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4]
    data = payload + checksum
    num = int.from_bytes(data, "big")
    enc = ""
    while num > 0:
        num, mod = divmod(num, 58)
        enc = _BASE58_ALPHABET[mod] + enc
    pad = 0
    for b in data:
        if b == 0:
            pad += 1
        else:
            break
    return "1" * pad + enc


def _p2pkh_address(pubkey: bytes, mainnet: bool) -> str:
    prefix = b"\x00" if mainnet else b"\x6f"
    payload = prefix + _hash160(pubkey)
    return _base58check_encode(payload)


def _p2sh_p2wpkh_address(pubkey: bytes, mainnet: bool) -> str:
    redeem = b"\x00\x14" + _hash160(pubkey)
    prefix = b"\x05" if mainnet else b"\xc4"
    payload = prefix + _hash160(redeem)
    return _base58check_encode(payload)


def _p2wpkh_address(pubkey: bytes, hrp: str) -> str:
    witprog = _hash160(pubkey)
    data = [0] + _convertbits(witprog, 8, 5, True)
    return _bech32_encode(hrp, data)


def _bech32_encode(hrp: str, data: List[int]) -> str:
    combined = data + _bech32_create_checksum(hrp, data)
    return hrp + "1" + "".join(_bech32_charset()[d] for d in combined)


def _bech32_charset() -> str:
    return "qpzry9x8gf2tvdw0s3jn54khce6mua7l"


def _bech32_create_checksum(hrp: str, data: List[int]) -> List[int]:
    values = _bech32_hrp_expand(hrp) + data
    polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
    return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]


def _bech32_hrp_expand(hrp: str) -> List[int]:
    return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]


def _bech32_polymod(values: List[int]) -> int:
    generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
    chk = 1
    for v in values:
        top = chk >> 25
        chk = ((chk & 0x1FFFFFF) << 5) ^ v
        for i in range(5):
            if (top >> i) & 1:
                chk ^= generators[i]
    return chk


def _convertbits(data: bytes, from_bits: int, to_bits: int, pad: bool) -> List[int]:
    acc = 0
    bits = 0
    ret = []
    maxv = (1 << to_bits) - 1
    for b in data:
        acc = (acc << from_bits) | b
        bits += from_bits
        while bits >= to_bits:
            bits -= to_bits
            ret.append((acc >> bits) & maxv)
    if pad and bits:
        ret.append((acc << (to_bits - bits)) & maxv)
    elif bits >= from_bits or ((acc << (to_bits - bits)) & maxv):
        raise VerifyError("Invalid bech32 data.")
    return ret


def _ensure_unique_parties(entries: List[Entry], label: str) -> None:
    seen = set()
    for e in entries:
        if e.party in seen:
            raise VerifyError(f"Duplicate party '{e.party}' in {label}.")
        seen.add(e.party)


def _build_conservation(
    before_by_asset: Dict[str, Decimal],
    after_by_asset: Dict[str, Decimal],
) -> Dict[str, Dict[str, Decimal]]:
    conservation: Dict[str, Dict[str, Decimal]] = {}
    assets = set(before_by_asset.keys()) | set(after_by_asset.keys())
    for asset in sorted(assets):
        before_val = before_by_asset.get(asset, Decimal("0"))
        after_val = after_by_asset.get(asset, Decimal("0"))
        if before_val != after_val:
            raise VerifyError(
                f"Conservation failed: {asset} before={_decimal_to_str(before_val)}, "
                f"after={_decimal_to_str(after_val)}"
            )
        conservation[asset] = {"before": before_val, "after": after_val}
    return conservation


def _compute_delta(before: List[Entry], after: List[Entry]) -> Dict[str, Dict[str, Decimal]]:
    delta: Dict[str, Dict[str, Decimal]] = {}
    for e in before:
        delta.setdefault(e.party, {})
        delta[e.party][e.asset] = delta[e.party].get(e.asset, Decimal("0")) - e.amount
    for e in after:
        delta.setdefault(e.party, {})
        delta[e.party][e.asset] = delta[e.party].get(e.asset, Decimal("0")) + e.amount
    return delta


# -------------------- Compiler --------------------

class CompileError(Exception):
    pass


@dataclass
class CompiledProof:
    program: Program
    summary: VerificationSummary
    swap_id: str

    def to_dict(self) -> Dict[str, Any]:
        return {
            "swap_id": self.swap_id,
            "status": "valid" if self.summary.valid else "invalid",
            "conservation": _conservation_to_dict(self.summary.conservation),
            "delta": _delta_to_dict(self.summary.delta),
            "before": _entries_to_dict(self.program.before),
            "after": _entries_to_dict(self.program.after),
            "htlc": _build_htlc(self.program, self.summary),
        }

    def to_json(self) -> str:
        return json.dumps(self.to_dict(), indent=2, sort_keys=True)


def compile_source(source: str) -> CompiledProof:
    try:
        tokens = tokenise(source)
        program = parse(tokens)
        summary = verify(program)
        swap_id = _swap_id_from_program(program)
        return CompiledProof(program=program, summary=summary, swap_id=swap_id)
    except (LexError, ParseError, VerifyError) as exc:
        raise CompileError(str(exc)) from exc


def compile_file(path: str) -> CompiledProof:
    with open(path, "r", encoding="utf-8") as f:
        return compile_source(f.read())


def _entries_to_dict(entries: List[Entry]) -> Dict[str, str]:
    out: Dict[str, str] = {}
    for e in entries:
        out[e.party] = f"{_decimal_to_str(e.amount)} {e.asset}"
    return out


def _decimal_to_str(value: Decimal) -> str:
    text = format(value, "f")
    if "." in text:
        text = text.rstrip("0").rstrip(".")
    return text or "0"


def _decimal_to_signed_str(value: Decimal) -> str:
    base = _decimal_to_str(abs(value))
    sign = "+" if value >= 0 else "-"
    return f"{sign}{base}"


def _conservation_to_dict(conservation: Dict[str, Dict[str, Decimal]]) -> Dict[str, Dict[str, str]]:
    out: Dict[str, Dict[str, str]] = {}
    for asset, values in conservation.items():
        out[asset] = {
            "before": _decimal_to_str(values["before"]),
            "after": _decimal_to_str(values["after"]),
        }
    return out


def _delta_to_dict(delta: Dict[str, Dict[str, Decimal]]) -> Dict[str, Dict[str, str]]:
    out: Dict[str, Dict[str, str]] = {}
    for party, assets in delta.items():
        out[party] = {asset: _decimal_to_signed_str(amount) for asset, amount in assets.items()}
    return out


def _build_htlc(program: Program, summary: VerificationSummary) -> Dict[str, Any]:
    secret = secrets.token_hex(32)
    secret_hash = hashlib.sha256(secret.encode("utf-8")).hexdigest()
    if program.transfers:
        legs = _legs_from_transfers(program.transfers)
    else:
        legs = _legs_from_delta(summary.delta)
    return {
        "secret_hash": secret_hash,
        "timeout_blocks": 144,
        "legs": legs,
    }


def _legs_from_transfers(transfers: List[Transfer]) -> List[Dict[str, str]]:
    legs: List[Dict[str, str]] = []
    for t in transfers:
        legs.append(
            {
                "from": t.from_party,
                "to": t.to_party,
                "asset": t.asset,
                "amount": _decimal_to_str(t.amount),
            }
        )
    return legs


def _legs_from_delta(delta: Dict[str, Dict[str, Decimal]]) -> List[Dict[str, str]]:
    legs: List[Dict[str, str]] = []
    assets = set()
    for assets_map in delta.values():
        assets.update(assets_map.keys())

    for asset in sorted(assets):
        senders: List[List[Any]] = []
        receivers: List[List[Any]] = []
        for party, assets_map in delta.items():
            amt = assets_map.get(asset, Decimal("0"))
            if amt < 0:
                senders.append([party, -amt])
            elif amt > 0:
                receivers.append([party, amt])

        i = 0
        j = 0
        while i < len(senders) and j < len(receivers):
            sender_party, sender_amt = senders[i]
            receiver_party, receiver_amt = receivers[j]
            amount = sender_amt if sender_amt <= receiver_amt else receiver_amt
            legs.append(
                {
                    "from": sender_party,
                    "to": receiver_party,
                    "asset": asset,
                    "amount": _decimal_to_str(amount),
                }
            )
            sender_amt -= amount
            receiver_amt -= amount
            senders[i][1] = sender_amt
            receivers[j][1] = receiver_amt
            if sender_amt == 0:
                i += 1
            if receiver_amt == 0:
                j += 1
    return legs


# Backwards-friendly alias
compile_swap = compile_source


# -------------------- CLI --------------------

def main() -> int:
    parser = argparse.ArgumentParser(
        prog="sophi",
        description="Sophi language compiler - generates portable validity proofs.",
    )
    sub = parser.add_subparsers(dest="command")

    compile_p = sub.add_parser("compile", help="Compile a .sph file into a proof")
    compile_p.add_argument("file", help="Path to .sph source file")
    compile_p.add_argument("-o", "--out", help="Write proof JSON to a file instead of stdout")

    args = parser.parse_args()

    if args.command != "compile":
        parser.print_help()
        return 2

    try:
        compiled = compile_file(args.file)
    except CompileError as exc:
        print(str(exc), file=sys.stderr)
        return 1

    output = compiled.to_json()
    if args.out:
        with open(args.out, "w", encoding="utf-8") as f:
            f.write(output)
    else:
        print(output)

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
