#!/usr/bin/env python3
"""Run real curl requests against fixture.py in four configurations; no external network targets."""
import argparse
import datetime
import hashlib
import json
from pathlib import Path
import platform
import select
import shlex
import subprocess
import sys
import time
import uuid

VERSION = "1.0.0"
ROOT = Path(__file__).resolve().parent
TOKENS = {"alice": "demo-alice-token", "bob": "demo-bob-token", None: None}
CONFIGS = [("sequential", "none"), ("uuid", "none"), ("sequential", "owner"), ("uuid", "owner")]


def now():
    return datetime.datetime.now(datetime.timezone.utc).isoformat()


def digest(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def curl(base, who, method, path, body, raw_path):
    command = ["curl", "--noproxy", "*", "--silent", "--show-error", "--include",
               "--http1.1", "--connect-timeout", "1", "--max-time", "3", "--request", method]
    if TOKENS[who]:
        command += ["--header", "Authorization: Bearer " + TOKENS[who]]
    if body is not None:
        command += ["--header", "Content-Type: application/json", "--data", json.dumps(body, sort_keys=True)]
    command.append(base + path)
    response = subprocess.run(command, capture_output=True, text=True, timeout=5)
    raw_path.write_text(response.stdout)
    status = int(response.stdout.splitlines()[0].split()[1])
    payload = json.loads(response.stdout.split("\n\n", 1)[1])
    return command, response, status, payload


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--output", type=Path, default=Path("rerun"), help="new evidence directory")
    args = parser.parse_args()
    args.output.mkdir(parents=True, exist_ok=False)
    evidence = {
        "schema_version": 1, "fixture_version": VERSION, "started_at": now(),
        "method": "Real curl subprocesses against four locally spawned fixture configurations",
        "scope": {"hosts": ["127.0.0.1"], "methods": ["GET", "PUT", "DELETE"],
                  "paths": ["/invoices", "/invoices/<id>"],
                  "data": "Three synthetic invoices and two synthetic users; tokens are public demo values"},
        "versions": {"python": platform.python_version(), "os": platform.system(),
                     "architecture": platform.machine(),
                     "curl": subprocess.check_output(["curl", "--version"], text=True).splitlines()[0]},
        "sha256": {name: digest(ROOT / name) for name in ("fixture.py", "verify.py")},
        "reproduction_command": "python3 verify.py --output " + shlex.quote(str(args.output)),
        "agent_execution": {"executed": False, "note": "HTTP verification was scripted; no model or agent ran"},
        "cases": [], "failures": [],
    }
    commands = ["#!/bin/sh", "# Recorded curl commands; rerun verify.py to start fresh fixture servers.",
                "# These ephemeral ports refer to the recorded run and are no longer listening."]
    try:
        for ids, authz in CONFIGS:
            label = ids + "-" + authz
            vulnerable = authz == "none"
            server_command = [sys.executable, "-u", str(ROOT / "fixture.py"), "--ids", ids,
                              "--authz", authz, "--port", "0"]
            process = subprocess.Popen(server_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
            try:
                ready, _, _ = select.select([process.stdout], [], [], 5)
                if not ready:
                    raise RuntimeError("Fixture did not start within five seconds")
                startup = json.loads(process.stdout.readline())
                base = startup["base_url"]
                if not base.startswith("http://127.0.0.1:"):
                    raise RuntimeError("Fixture returned a non-loopback address")
                commands.append("# Server: python3 fixture.py --ids " + ids + " --authz " + authz +
                                " --port " + base.rsplit(":", 1)[1])
                ctx = {}

                def run(name, who, method, path, body, secure, demo, check=None, note=None):
                    """secure = policy outcome; demo = outcome this configuration should show.
                    Both are (status, predicate-on-payload)."""
                    started, tick = now(), time.monotonic()
                    raw_name = label + "-" + name + ".http"
                    command, response, status, payload = curl(base, who, method, path, body, args.output / raw_name)
                    commands.append(shlex.join(command))
                    expected_status, expected_check = demo
                    secure_status, secure_check = secure
                    passed = (response.returncode == 0 and status == expected_status
                              and (expected_check is None or expected_check(payload)))
                    holds = status == secure_status and (secure_check is None or secure_check(payload))
                    evidence["cases"].append({
                        "id": label + "/" + name, "config": {"ids": ids, "authz": authz},
                        "started_at": started, "elapsed_ms": round((time.monotonic() - tick) * 1000, 3),
                        "server": startup, "command": command, "curl_exit_code": response.returncode,
                        "stderr": response.stderr, "identity": who, "method": method, "path": path,
                        "secure_expected_status": secure_status, "demonstration_expected_status": expected_status,
                        "observed_status": status, "observed_body": payload,
                        "matches_demonstration": passed, "authorization_boundary_holds": holds,
                        "note": note, "response_file": raw_name, "response_sha256": digest(args.output / raw_name),
                    })
                    if not passed:
                        evidence["failures"].append(label + "/" + name)
                    return payload

                ok200 = lambda p: True
                # Listing and ID discovery
                alice_list = run("alice-lists", "alice", "GET", "/invoices", None,
                                 (200, lambda p: len(p["invoices"]) == 2),
                                 (200, lambda p: len(p["invoices"]) == (3 if vulnerable else 2)),
                                 note="Alice's own list")
                bob_list = run("bob-lists", "bob", "GET", "/invoices", None,
                               (200, lambda p: len(p["invoices"]) == 1),
                               (200, lambda p: len(p["invoices"]) == (3 if vulnerable else 1)),
                               note="Policy: Bob sees only his invoice")
                leaked = [i["id"] for i in bob_list["invoices"] if i["owner"] == "alice"]
                alice_ids = [i["id"] for i in alice_list["invoices"] if i["owner"] == "alice"]
                bob_id = [i["id"] for i in bob_list["invoices"] if i["owner"] == "bob"][0]
                source = "bob-list" if leaked else "alice-list"
                a1, a2 = (leaked or alice_ids)[:2]
                ctx["id_source"] = source
                evidence.setdefault("id_sources", {})[label] = {
                    "bob_list_contains_alice_ids": len(leaked), "target_ids_taken_from": source,
                    "alice_invoice_ids": alice_ids}
                # Positive controls and unauthenticated control
                run("alice-reads-own", "alice", "GET", "/invoices/" + a1, None,
                    (200, lambda p: p["owner"] == "alice"), (200, lambda p: p["owner"] == "alice"))
                run("bob-reads-own", "bob", "GET", "/invoices/" + bob_id, None,
                    (200, lambda p: p["owner"] == "bob"), (200, lambda p: p["owner"] == "bob"))
                run("unauthenticated", None, "GET", "/invoices/" + a1, None,
                    (401, None), (401, None))
                # Enumeration by guessing numeric IDs (sequential: 1001 and 1002 are Alice's, 1004 is unused)
                for guess, secure_seq, demo_seq in (("1001", 403, 200), ("1002", 403, 200), ("1004", 404, 404)):
                    secure_status = secure_seq if ids == "sequential" else 404
                    demo_status = (demo_seq if vulnerable else secure_seq) if ids == "sequential" else 404
                    run("bob-guesses-" + guess, "bob", "GET", "/invoices/" + guess, None,
                        (secure_status, None), (demo_status, None), note="Numeric guess, not taken from any response")
                # Cross-account read, update, delete
                run("bob-reads-alice", "bob", "GET", "/invoices/" + a1, None,
                    (403, None), (200 if vulnerable else 403, (lambda p: p["owner"] == "alice") if vulnerable else None))
                tamper = {"amount_cents": 1, "status": "paid"}
                run("bob-updates-alice", "bob", "PUT", "/invoices/" + a1, tamper,
                    (403, None), (200 if vulnerable else 403, (lambda p: p["amount_cents"] == 1) if vulnerable else None))
                run("alice-reads-after-update", "alice", "GET", "/invoices/" + a1, None,
                    (200, lambda p: p["amount_cents"] == 12500 and p["status"] == "open"),
                    (200, (lambda p: p["amount_cents"] == 1 and p["status"] == "paid") if vulnerable
                     else (lambda p: p["amount_cents"] == 12500 and p["status"] == "open")))
                run("bob-deletes-alice", "bob", "DELETE", "/invoices/" + a2, None,
                    (403, None), (200 if vulnerable else 403, (lambda p: p["deleted"] == a2) if vulnerable else None))
                run("alice-reads-after-delete", "alice", "GET", "/invoices/" + a2, None,
                    (200, lambda p: p["id"] == a2), (404 if vulnerable else 200, None))
                # Positive controls: owners still change and remove their own invoices
                run("alice-updates-own", "alice", "PUT", "/invoices/" + a1, {"amount_cents": 12500, "status": "paid"},
                    (200, lambda p: p["status"] == "paid"), (200, lambda p: p["status"] == "paid" and p["amount_cents"] == 12500))
                run("bob-deletes-own", "bob", "DELETE", "/invoices/" + bob_id, None,
                    (200, lambda p: p["deleted"] == bob_id), (200, lambda p: p["deleted"] == bob_id))
                run("bob-reads-own-after-delete", "bob", "GET", "/invoices/" + bob_id, None,
                    (404, None), (404, None))
            finally:
                process.terminate()
                try:
                    process.wait(timeout=3)
                except subprocess.TimeoutExpired:
                    process.kill()
                    process.wait()
    except Exception as exc:
        evidence["failures"].append(type(exc).__name__ + ": " + str(exc))
    for label, info in evidence.get("id_sources", {}).items():
        if label.startswith("uuid"):
            try:
                for value in info["alice_invoice_ids"]:
                    uuid.UUID(value)
                info["alice_ids_are_uuids"] = True
            except ValueError:
                info["alice_ids_are_uuids"] = False
                evidence["failures"].append(label + ": ids are not UUIDs")
    evidence["finished_at"] = now()
    evidence["boundary_failures"] = [c["id"] for c in evidence["cases"] if not c["authorization_boundary_holds"]]
    evidence["all_demonstration_checks_passed"] = not evidence["failures"] and len(evidence["cases"]) == 4 * 16
    (args.output / "results.json").write_text(json.dumps(evidence, indent=2) + "\n")
    (args.output / "commands.sh").write_text("\n".join(commands) + "\n")
    print(json.dumps({"checks": len(evidence["cases"]), "passed": evidence["all_demonstration_checks_passed"],
                      "failures": evidence["failures"], "policy_violations_observed": len(evidence["boundary_failures"]),
                      "evidence": str(args.output / "results.json")}))
    return 0 if evidence["all_demonstration_checks_passed"] else 1


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