#!/usr/bin/env python3
"""
model-resolve: deterministic model_id resolver for C-2b judgment path.

Reads a room message JSON and applies resolution rules R1-R5 (and R2a)
in frozen order to determine the model_id field.

R2a parser duties:
(a) Strip matching quote pairs ("x" or 'x')
(b) Cut at space-hash ` #`, trim trailing whitespace
(c) Dash line can carry any key; entry starts at dash
(d) Only keys at entry indentation level count; nested maps never shadow
(e) Parse nicks: as YAML flow list [a, b]
Output guarantee: emitted model_id must pass C-1a ERE, else exit 2 + stderr

R2a DOCUMENTED LIMITS (C-2b, noted-not-specified — all outside the real
deploy/roster.yaml form, so none is contractual behaviour):
- Flow-style entries `kinder: [{name: x, modell: y}]` are not parsed (the
  real file uses block-style list entries) -> fall back to claude/default.
- Duplicate top-level `kinder:` blocks merge last-wins (same as PyYAML on a
  duplicate key); the real file has one.
- A dash with a double space (`-  name:`) shifts the entry indent and drops
  the entry's keys; the real file uses a single space.
- A block-style sub-list (`units:` with its own dash lines) INSIDE a
  modell-bearing entry would split it; the five real modell-entries all use
  flow-style sub-lists (`nicks: [x]`, `units: [a, b]`), so this does not fire.
If the roster ever adopts one of these shapes, the fix is here, not a
contract change — the pinned rule is "hardened parser, not a grammar pin".
"""

import json
import sys
import re
import argparse


def strip_quotes(value):
    """Strip matching enclosing quote pair if present."""
    if len(value) >= 2:
        if (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'"):
            return value[1:-1]
    return value


def parse_value(line_content):
    """Parse a YAML value: strip quotes, cut at space-hash, trim."""
    # Cut at space-hash (inline comment)
    if ' #' in line_content:
        line_content = line_content[:line_content.index(' #')]

    # Trim trailing whitespace
    line_content = line_content.rstrip()

    # Strip quotes
    return strip_quotes(line_content)


def parse_nicks_flow_list(value_str):
    """Parse YAML flow list for nicks: [a, b] or nicks: [a].

    Apply (b) before (e): strip comments first, then parse flow list.
    """
    nicks = set()

    # Apply (b): cut at space-hash (inline comment)
    if ' #' in value_str:
        value_str = value_str[:value_str.index(' #')]

    # Apply (e): parse flow list
    # Match flow list pattern: [a, b, c]
    match = re.match(r'^\s*\[(.*?)\]\s*$', value_str.strip())
    if match:
        content = match.group(1)
        # Split by comma and strip each
        for nick in content.split(','):
            nick_clean = strip_quotes(nick.strip())
            if nick_clean:
                nicks.add(nick_clean)
    return nicks


def validate_model_id(model_id):
    """Validate model_id against C-1a ERE: ^[A-Za-z0-9][A-Za-z0-9/_.:-]{0,127}$"""
    model_id_re = re.compile(r"^[A-Za-z0-9][A-Za-z0-9/_.:-]{0,127}$")
    return bool(model_id_re.match(model_id))


def emit_result(model_id, reason):
    """Central output function: validate model_id and emit JSON or exit 2.

    Output guarantee: schema-invalid model_id never reaches stdout.
    All paths (R1, R2, R2a, R3, R4/R5) use this.
    """
    # If model_id is not None, it must pass validation
    if model_id is not None:
        if not validate_model_id(model_id):
            print(f"model-resolve: error: model_id '{model_id}' does not match C-1a schema", file=sys.stderr)
            sys.exit(2)

    output = {"model_id": model_id, "model_id_reason": reason}
    print(json.dumps(output, separators=(',', ': ')))
    return 0


def parse_roster(roster_path):
    """Parse roster.yaml for the kinder: list with name:/modell:/nicks: entries.

    Returns list of entries, each a dict with extracted name, modell, nicks.

    Parser duties:
    (a) Strip quotes from values
    (b) Cut at space-hash, trim trailing whitespace
    (c) Dash line can carry any key; entry starts at dash
    (d) Only count keys at entry's own indentation depth
    (e) Parse nicks: as flow list [a, b]
    """
    entries = []
    try:
        with open(roster_path, 'r') as f:
            lines = f.readlines()
    except (FileNotFoundError, IOError) as e:
        raise ValueError(f"cannot read roster file: {e}")

    in_kinder = False
    current_entry = None
    entry_indent = None

    for line in lines:
        line_stripped = line.rstrip('\n')

        # Skip blank lines and comments
        if not line_stripped or line_stripped.lstrip().startswith('#'):
            continue

        # Check for top-level "kinder:" (no leading whitespace)
        if line_stripped == "kinder:":
            in_kinder = True
            continue

        # Check if we're exiting the kinder: block (another top-level key)
        if in_kinder and line_stripped and line_stripped[0] not in (' ', '\t'):
            # This is a top-level key; we've exited kinder:
            in_kinder = False
            if current_entry is not None:
                entries.append(current_entry)
                current_entry = None
            continue

        # Only process if we're in the kinder: block
        if not in_kinder:
            continue

        # Detect list entry start: line starts (after strip) with "- "
        # FIX V1: use startswith, not substring search
        if line_stripped.lstrip().startswith('- '):
            # Save previous entry
            if current_entry is not None:
                entries.append(current_entry)

            # Start new entry
            current_entry = {}
            entry_indent = len(line_stripped) - len(line_stripped.lstrip())

            # Extract any key-value from the dash line
            # Line format: "  - key: value" or "  - "
            dash_part = line_stripped.lstrip()[2:].strip()  # Remove "- "
            if ':' in dash_part:
                key, val = dash_part.split(':', 1)
                key = key.strip()
                val = parse_value(val.strip())
                if key and val:
                    current_entry[key] = val
            continue

        # Process key-value pairs at entry level
        if current_entry is None:
            continue

        current_indent = len(line_stripped) - len(line_stripped.lstrip())

        # Only process keys at the entry's own indentation depth
        # (depth directly under the dash, not nested deeper)
        if current_indent != entry_indent + 2:  # +2 for typical 2-space indent
            continue

        # Parse "key: value"
        if ':' in line_stripped.lstrip():
            key_val = line_stripped.lstrip()
            parts = key_val.split(':', 1)
            key = parts[0].strip()
            value_raw = parts[1].strip() if len(parts) > 1 else ""

            # Special handling for nicks: (flow list)
            if key == 'nicks':
                nicks = parse_nicks_flow_list(value_raw)
                if nicks:
                    current_entry['nicks'] = nicks
            else:
                # Regular key: parse value (quotes, comments, etc.)
                value = parse_value(value_raw)
                if key and value:
                    current_entry[key] = value

    # Save the last entry
    if current_entry is not None:
        entries.append(current_entry)

    return entries


def find_roster_entry(entries, from_nick):
    """Find entry matching from_nick via JOIN rule.

    JOIN: entry whose nicks list CONTAINS from_nick, OR name == from_nick.
    Returns the entry dict, or None if not found.
    """
    for entry in entries:
        # Path 1: from_nick in nicks list
        if 'nicks' in entry and isinstance(entry['nicks'], set):
            if from_nick in entry['nicks']:
                return entry

        # Path 2: name == from_nick
        if entry.get('name') == from_nick:
            return entry

    return None


def main():
    parser = argparse.ArgumentParser(
        description="Resolve model_id from a reply message",
        add_help=False
    )
    parser.add_argument("--reply", required=True, metavar="FILE",
                        help="Room message JSON object file")
    parser.add_argument("--model-nicks", default="coder,py,godev,jsdev", metavar="CSV",
                        help="Comma-separated nicks expected to carry model marker")
    parser.add_argument("--roster", default=None, metavar="PATH",
                        help="YAML roster file for R2a model resolution")

    try:
        args = parser.parse_args()
    except SystemExit:
        print("model-resolve: error: argument parsing failed", file=sys.stderr)
        sys.exit(2)

    # Parse roster if provided
    roster_entries = None
    if args.roster is not None:
        try:
            roster_entries = parse_roster(args.roster)
        except ValueError as e:
            print(f"model-resolve: error: {e}", file=sys.stderr)
            sys.exit(2)

    # Parse model-nicks
    model_nicks = set(n.strip() for n in args.model_nicks.split(","))

    # Read and parse the reply file
    try:
        with open(args.reply, 'r') as f:
            reply = json.load(f)
    except (FileNotFoundError, IOError) as e:
        print(f"model-resolve: error: cannot read {args.reply}: {e}", file=sys.stderr)
        sys.exit(2)
    except json.JSONDecodeError as e:
        print(f"model-resolve: error: {args.reply} is not valid JSON: {e}", file=sys.stderr)
        sys.exit(2)

    # Validate that it's a dict with required fields
    if not isinstance(reply, dict):
        print("model-resolve: error: reply is not a JSON object", file=sys.stderr)
        sys.exit(2)

    required_fields = {"id", "ts", "from", "to", "type", "body"}
    missing = required_fields - set(reply.keys())
    if missing:
        print(f"model-resolve: error: missing required fields: {', '.join(missing)}", file=sys.stderr)
        sys.exit(2)

    body = reply.get("body", "")
    if not isinstance(body, str):
        print("model-resolve: error: body field is not a string", file=sys.stderr)
        sys.exit(2)

    from_nick = reply.get("from", "")

    # Apply resolution rules R1-R5 (and R2a) in frozen order
    # Use central emit_result() for all output paths

    # R1: fallback marker anywhere in body
    fallback_match = re.search(r"answered by the fallback: ([A-Za-z0-9/_.:-]+)", body)
    if fallback_match:
        model_id = fallback_match.group(1)
        return emit_result(model_id, None)

    # R2: lurker model tail - LAST line only, fully anchored
    lines = body.splitlines()
    if lines:
        last_line = lines[-1]
        r2_match = re.match(r"^\(model: claude ([A-Za-z0-9/_.:-]+)\)$", last_line)
        if r2_match:
            captured = r2_match.group(1)

            # R2a: if captured is "default" and roster is provided, look up the nick
            if captured == "default" and roster_entries is not None:
                entry = find_roster_entry(roster_entries, from_nick)
                if entry and 'modell' in entry:
                    model_id = entry['modell']
                else:
                    model_id = "claude/default"
            else:
                model_id = "claude/" + captured

            return emit_result(model_id, None)

    # R3: coder body marker
    r3_match = re.search(r"model ([A-Za-z0-9/_.:-]+)\)", body)
    if r3_match:
        model_id = r3_match.group(1)
        return emit_result(model_id, None)

    # R4/R5: no match - check if from is in model-nicks
    if from_nick in model_nicks:
        # R4: expected a marker but didn't find one
        reason = "parse_failed"
    else:
        # R5: role doesn't carry markers
        reason = "role_no_model"

    return emit_result(None, reason)


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