#!/usr/bin/env python3
"""label-append: C-2a label write chokepoint.

Validates and appends label rows to a JSONL file under exclusive lock,
enforcing the frozen C-1a schema and file-level context invariants.
"""

import sys
import json
import re
import time
import fcntl
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple


# === Constants from C-1a schema ===
LABEL_ENUM = {"good", "partial", "bad", "error"}
MODEL_REASON_ENUM = {"role_no_model", "legacy_pre_schema", "parse_failed"}

# Regular expressions (C-1a §3)
AUTHOR_NICK_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9/_.:-]{0,127}$")
PROMPT_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
TEMPLATE_VERSION_RE = re.compile(r"^v[0-9]{1,4}$")
TS_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$")


class ValidationError:
    """Represents a single validation error (C-1a §2.2 format)."""

    def __init__(self, code: str, field: str, detail: str):
        self.code = code
        self.field = field
        self.detail = detail

    def format_reject(self) -> str:
        """Format as C-2a rejection line."""
        return f"label-append: reject: {self.code} {self.field}: {self.detail}"


def parse_args() -> Tuple[Dict[str, Any], int]:
    """Parse command-line flags. Return (args_dict, exit_code).

    Exit code 0 on success, 2 on structural error.
    """
    args = {
        "file": None,
        "label": None,
        "author": None,
        "verified": None,
        "ask_id": None,
        "model_id": None,
        "model_reason": None,
        "prompt_hash": None,
        "template_version": None,
        "dry_run": False,
        "lock_timeout": 10,
    }

    i = 1
    while i < len(sys.argv):
        arg = sys.argv[i]

        if arg == "--file":
            if i + 1 >= len(sys.argv):
                print("label-append: error: --file requires an argument", file=sys.stderr)
                return None, 2
            i += 1
            args["file"] = sys.argv[i]

        elif arg == "--label":
            if i + 1 >= len(sys.argv):
                print("label-append: error: --label requires an argument", file=sys.stderr)
                return None, 2
            i += 1
            args["label"] = sys.argv[i]

        elif arg == "--author":
            if i + 1 >= len(sys.argv):
                print("label-append: error: --author requires an argument", file=sys.stderr)
                return None, 2
            i += 1
            args["author"] = sys.argv[i]

        elif arg == "--verified":
            if i + 1 >= len(sys.argv):
                print("label-append: error: --verified requires an argument", file=sys.stderr)
                return None, 2
            i += 1
            verified_str = sys.argv[i]
            if verified_str == "true":
                args["verified"] = True
            elif verified_str == "false":
                args["verified"] = False
            else:
                print(f"label-append: error: --verified must be 'true' or 'false', got '{verified_str}'", file=sys.stderr)
                return None, 2

        elif arg == "--ask-id":
            if i + 1 >= len(sys.argv):
                print("label-append: error: --ask-id requires an argument", file=sys.stderr)
                return None, 2
            i += 1
            ask_id_str = sys.argv[i]
            # Strict decimal integer literal (C-2a §2.1): optional sign (+ or -) followed by digits
            if not re.match(r"^[+-]?[0-9]+$", ask_id_str):
                print(f"label-append: error: --ask-id must be a decimal integer literal, got '{ask_id_str}'", file=sys.stderr)
                return None, 2
            try:
                args["ask_id"] = int(ask_id_str)
            except ValueError:
                print(f"label-append: error: --ask-id must be an integer, got '{ask_id_str}'", file=sys.stderr)
                return None, 2

        elif arg == "--model-id":
            if i + 1 >= len(sys.argv):
                print("label-append: error: --model-id requires an argument", file=sys.stderr)
                return None, 2
            i += 1
            args["model_id"] = sys.argv[i]

        elif arg == "--model-reason":
            if i + 1 >= len(sys.argv):
                print("label-append: error: --model-reason requires an argument", file=sys.stderr)
                return None, 2
            i += 1
            args["model_reason"] = sys.argv[i]

        elif arg == "--prompt-hash":
            if i + 1 >= len(sys.argv):
                print("label-append: error: --prompt-hash requires an argument", file=sys.stderr)
                return None, 2
            i += 1
            args["prompt_hash"] = sys.argv[i]

        elif arg == "--template-version":
            if i + 1 >= len(sys.argv):
                print("label-append: error: --template-version requires an argument", file=sys.stderr)
                return None, 2
            i += 1
            args["template_version"] = sys.argv[i]

        elif arg == "--dry-run":
            args["dry_run"] = True

        elif arg == "--lock-timeout":
            if i + 1 >= len(sys.argv):
                print("label-append: error: --lock-timeout requires an argument", file=sys.stderr)
                return None, 2
            i += 1
            try:
                args["lock_timeout"] = int(sys.argv[i])
            except ValueError:
                print(f"label-append: error: --lock-timeout must be an integer", file=sys.stderr)
                return None, 2

        else:
            print(f"label-append: error: unknown flag '{arg}'", file=sys.stderr)
            return None, 2

        i += 1

    # Validate required flags
    if args["file"] is None:
        print("label-append: error: --file is required", file=sys.stderr)
        return None, 2
    if args["label"] is None:
        print("label-append: error: --label is required", file=sys.stderr)
        return None, 2
    if args["author"] is None:
        print("label-append: error: --author is required", file=sys.stderr)
        return None, 2
    if args["verified"] is None:
        print("label-append: error: --verified is required", file=sys.stderr)
        return None, 2
    if args["ask_id"] is None:
        print("label-append: error: --ask-id is required", file=sys.stderr)
        return None, 2

    # Validate XOR: exactly one of --model-id or --model-reason
    has_model_id = args["model_id"] is not None
    has_model_reason = args["model_reason"] is not None
    if has_model_id == has_model_reason:  # both or neither
        print("label-append: error: exactly one of --model-id or --model-reason is required", file=sys.stderr)
        return None, 2

    # Validate pairing: both or neither of --prompt-hash and --template-version
    has_hash = args["prompt_hash"] is not None
    has_template = args["template_version"] is not None
    if has_hash != has_template:  # one but not the other
        print("label-append: error: --prompt-hash and --template-version must both be present or both absent", file=sys.stderr)
        return None, 2

    return args, 0


def get_now_rfc3339_ms() -> str:
    """Get current UTC time in RFC 3339 with milliseconds."""
    now = datetime.now(timezone.utc)
    return now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"


def get_max_ts(now_ts: str, last_row_ts: Optional[str]) -> str:
    """Return max of now and last_row_ts (both RFC 3339 ms).

    Equality is allowed — this prevents E_ORDER violations.
    RFC 3339 ms format is fixed-width and sortable lexicographically,
    so we compare strings without parsing (avoids crashes on invalid dates).
    """
    if last_row_ts is None:
        return now_ts

    # Lexicographic comparison on RFC 3339 ms strings is equivalent to time ordering
    # because the format is fixed-width: YYYY-MM-DDTHH:MM:SS.fffZ
    if last_row_ts > now_ts:
        return last_row_ts
    return now_ts


def read_existing_file(file_path: str) -> Tuple[List[Dict[str, Any]], Optional[str]]:
    """Read and parse existing file. Return (rows, last_ts) or (empty, None).

    Returns None as second element if file doesn't exist.
    On parse error, raises ValueError.
    Enforces C-1a §2.1: file must end with LF (no partial last line).
    """
    path = Path(file_path)
    if not path.exists():
        return [], None

    rows = []
    last_ts = None
    try:
        with open(file_path, "rb") as f:
            content = f.read()

        # V1 fix: File must end with LF (C-1a §2.1 requires LF line endings)
        if content and not content.endswith(b"\n"):
            raise ValueError("file does not end with LF")

        # Parse as UTF-8 text
        text_content = content.decode("utf-8")

        # Process lines: split removes the final LF, leaving an empty string at the end if LF was present
        lines = text_content.split("\n")

        # Remove exactly one trailing empty string (from the final LF)
        # If there are more empty strings, they represent blank lines in the file
        if lines and lines[-1] == "":
            lines = lines[:-1]

        # Any remaining empty lines are invalid (C-1a forbids blank lines)
        for i, line in enumerate(lines):
            if not line:
                raise ValueError(f"blank line at position {i+1}")
            row = json.loads(line)
            if not isinstance(row, dict):
                raise ValueError("not a JSON object")
            rows.append(row)
            if "ts" in row:
                last_ts = row["ts"]
    except (json.JSONDecodeError, ValueError) as e:
        raise ValueError(f"parse error: {e}")
    except UnicodeDecodeError as e:
        raise ValueError(f"encoding error: {e}")

    return rows, last_ts


def validate_row_data(row: Dict[str, Any]) -> List[ValidationError]:
    """Validate a composed row against all C-1a rules. Return list of errors."""
    errors = []

    # Check all 10 keys present and no extra keys
    expected_keys = {
        "label",
        "author_nick",
        "verified",
        "model_id",
        "model_id_reason",
        "prompt_hash",
        "template_version",
        "ts",
        "ask_id",
        "probe_score",
    }
    actual_keys = set(row.keys())

    for missing_key in expected_keys - actual_keys:
        errors.append(ValidationError("E_KEY_MISSING", missing_key, f"missing"))

    for extra_key in actual_keys - expected_keys:
        errors.append(ValidationError("E_KEY_UNKNOWN", extra_key, f"unknown key"))

    # Stop early if keys are wrong; the rest assumes all 10 keys
    if errors:
        return errors

    # Validate each field

    # 1. label: enum
    if row["label"] not in LABEL_ENUM:
        errors.append(
            ValidationError("E_VALUE", "label", f"must be one of {sorted(LABEL_ENUM)}, got '{row['label']}'")
        )

    # 2. author_nick: ERE
    if not isinstance(row["author_nick"], str):
        errors.append(ValidationError("E_TYPE", "author_nick", f"must be string, got {type(row['author_nick']).__name__}"))
    elif not AUTHOR_NICK_RE.match(row["author_nick"]):
        errors.append(ValidationError("E_VALUE", "author_nick", f"format error"))

    # 3. verified: boolean
    if not isinstance(row["verified"], bool):
        errors.append(
            ValidationError("E_TYPE", "verified", f"must be boolean, got {type(row['verified']).__name__}")
        )

    # 4-5. model_id and model_id_reason: XOR relationship
    model_id = row["model_id"]
    model_id_reason = row["model_id_reason"]

    if model_id is not None and model_id_reason is not None:
        errors.append(ValidationError("E_REASON", "-", "model_id and model_id_reason: XOR rule violated (both non-null)"))
    elif model_id is None and model_id_reason is None:
        errors.append(ValidationError("E_REASON", "-", "model_id and model_id_reason: XOR rule violated (both null)"))
    else:
        # model_id non-null: check format
        if model_id is not None:
            if not isinstance(model_id, str):
                errors.append(ValidationError("E_TYPE", "model_id", f"must be string, got {type(model_id).__name__}"))
            elif not MODEL_ID_RE.match(model_id):
                errors.append(ValidationError("E_VALUE", "model_id", f"format error"))
        # model_id_reason non-null: check enum
        if model_id_reason is not None:
            if not isinstance(model_id_reason, str):
                errors.append(
                    ValidationError("E_TYPE", "model_id_reason", f"must be string, got {type(model_id_reason).__name__}")
                )
            elif model_id_reason not in MODEL_REASON_ENUM:
                errors.append(
                    ValidationError(
                        "E_VALUE",
                        "model_id_reason",
                        f"must be one of {sorted(MODEL_REASON_ENUM)}, got '{model_id_reason}'",
                    )
                )

    # 6-7. prompt_hash and template_version: pairing rule
    prompt_hash = row["prompt_hash"]
    template_version = row["template_version"]

    if (prompt_hash is None) != (template_version is None):
        errors.append(ValidationError("E_HASHPAIR", "-", "prompt_hash and template_version must both be null or both non-null"))
    else:
        if prompt_hash is not None:
            if not isinstance(prompt_hash, str):
                errors.append(
                    ValidationError("E_TYPE", "prompt_hash", f"must be string, got {type(prompt_hash).__name__}")
                )
            elif not PROMPT_HASH_RE.match(prompt_hash):
                errors.append(ValidationError("E_VALUE", "prompt_hash", f"must be 64 lowercase hex chars"))

        if template_version is not None:
            if not isinstance(template_version, str):
                errors.append(
                    ValidationError("E_TYPE", "template_version", f"must be string, got {type(template_version).__name__}")
                )
            elif not TEMPLATE_VERSION_RE.match(template_version):
                errors.append(ValidationError("E_VALUE", "template_version", f"format error (vN where N is 1-4 digits)"))

    # 8. ts: RFC 3339 ms format
    if not isinstance(row["ts"], str):
        errors.append(ValidationError("E_TYPE", "ts", f"must be string, got {type(row['ts']).__name__}"))
    elif not TS_RE.match(row["ts"]):
        errors.append(ValidationError("E_VALUE", "ts", f"invalid RFC 3339 ms format"))

    # 9. ask_id: integer >= 0
    if not isinstance(row["ask_id"], int):
        errors.append(ValidationError("E_TYPE", "ask_id", f"must be integer, got {type(row['ask_id']).__name__}"))
    elif row["ask_id"] < 0:
        errors.append(ValidationError("E_VALUE", "ask_id", f"must be >= 0, got {row['ask_id']}"))

    # 10. probe_score: must be null
    if row["probe_score"] is not None:
        errors.append(ValidationError("E_PROBE", "probe_score", f"must be null, got {row['probe_score']}"))

    return errors


def compose_row(args: Dict[str, Any], ts: str) -> Dict[str, Any]:
    """Compose the full row in canonical key order (C-1a §2.1)."""
    return {
        "label": args["label"],
        "author_nick": args["author"],
        "verified": args["verified"],
        "model_id": args["model_id"],
        "model_id_reason": args["model_reason"],
        "prompt_hash": args["prompt_hash"],
        "template_version": args["template_version"],
        "ts": ts,
        "ask_id": args["ask_id"],
        "probe_score": None,
    }


def check_duplicate(row: Dict[str, Any], existing_rows: List[Dict[str, Any]]) -> Optional[ValidationError]:
    """Check for duplicate (ask_id, author_nick) pair."""
    ask_id = row["ask_id"]
    author = row["author_nick"]

    for existing in existing_rows:
        if existing.get("ask_id") == ask_id and existing.get("author_nick") == author:
            return ValidationError("E_DUP", "-", f"({ask_id}, {author}) already exists")

    return None


def acquire_lock(lock_path: str, timeout_seconds: int) -> Tuple[Optional[int], int]:
    """Acquire exclusive lock on lock_path with timeout.

    Returns (file_descriptor, exit_code).
    On success: (fd, 0)
    On timeout: (None, 2)
    On other error: (None, 2)
    """
    try:
        fd = os.open(lock_path, os.O_CREAT | os.O_WRONLY, 0o666)
    except OSError as e:
        print(f"label-append: error: cannot open lock file: {e}", file=sys.stderr)
        return None, 2

    start_time = time.time()
    while True:
        try:
            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            return fd, 0
        except (OSError, IOError):
            elapsed = time.time() - start_time
            if elapsed >= timeout_seconds:
                os.close(fd)
                print(f"label-append: error: lock timeout", file=sys.stderr)
                return None, 2
            time.sleep(0.01)


def release_lock(fd: int) -> None:
    """Release exclusive lock."""
    try:
        fcntl.flock(fd, fcntl.LOCK_UN)
    except OSError:
        pass
    try:
        os.close(fd)
    except OSError:
        pass


def main() -> int:
    """Main gate implementation (C-2a §2.2)."""

    # Step 1: Parse flags
    args, rc = parse_args()
    if rc != 0:
        return rc

    file_path = args["file"]

    # Step 2: Acquire exclusive lock on PATH.lock
    lock_path = file_path + ".lock"
    lock_fd, rc = acquire_lock(lock_path, args["lock_timeout"])
    if rc != 0:
        return rc

    try:
        # Step 3: Read and parse existing file
        try:
            existing_rows, last_ts = read_existing_file(file_path)
        except ValueError as e:
            print(f"label-append: error: corrupt file: {e}", file=sys.stderr)
            return 2

        # Step 4: Stamp ts and probe_score
        now_ts = get_now_rfc3339_ms()
        final_ts = get_max_ts(now_ts, last_ts)

        # Step 5: Compose the full 10-key row in canonical order
        row = compose_row(args, final_ts)

        # Step 6: Validate the row and check for duplicates
        validation_errors = validate_row_data(row)
        dup_error = check_duplicate(row, existing_rows)
        if dup_error:
            validation_errors.append(dup_error)

        if validation_errors:
            # Reject: print errors and exit
            for error in validation_errors:
                print(error.format_reject(), file=sys.stderr)
            return 1

        # Step 7 & 8: Append under lock (or print if dry-run)
        row_json = json.dumps(row, separators=(",", ":"))

        if args["dry_run"]:
            # Dry-run: print the row and exit (file unchanged)
            print(row_json)
            return 0

        # Append to file
        try:
            with open(file_path, "a") as f:
                f.write(row_json + "\n")
                f.flush()
                os.fsync(f.fileno())
        except OSError as e:
            print(f"label-append: error: cannot write to file: {e}", file=sys.stderr)
            return 2

        # Step 8: Print the appended row to stdout
        print(row_json)
        return 0

    finally:
        # Release lock
        release_lock(lock_fd)


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