#!/usr/bin/env python3
import argparse, hashlib, json, subprocess, sys, time
import urllib.error, urllib.request
from datetime import datetime, timezone
from pathlib import Path

REPO = Path("/home/shire3d/ARMOR")
if str(REPO) not in sys.path:
    sys.path.insert(0, str(REPO))
PRACTICE = REPO / "data/academy/design_mastery/runtime/practice_state.json"
AUTO = REPO / "data/academy/design_mastery/runtime/autopilot_state.json"
CONTRACT = REPO / "data/academy/design_mastery/contracts/parameter-architecture-r3-compact-v3"
EVIDENCE = Path("/SHiREVault/SHiREAcademy/Autopilot")
MODEL_URL = "http://127.0.0.1:11434/api/chat"

def load(path):
    return json.loads(path.read_text(encoding="utf-8"))

def atomic(path, value):
    path.parent.mkdir(parents=True, exist_ok=True)
    temp = path.with_suffix(path.suffix + ".tmp")
    temp.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n",
                    encoding="utf-8")
    temp.replace(path)

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

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

def network_ok():
    out = subprocess.check_output(
        ["findmnt", "-T", "/SHiREVault", "-n", "-o", "FSTYPE"],
        text=True,
    ).strip().splitlines()
    return bool(out) and out[-1] in {"cifs", "nfs", "nfs4", "fuse.sshfs"}

def worker_running():
    result = subprocess.run(
        ["pgrep", "-af", "academy.*worker|practice.*worker|full.*queue"],
        text=True, capture_output=True,
    )
    return result.returncode == 0

def self_check():
    manifest = load(CONTRACT / "manifest.json")
    schema = load(CONTRACT / "schema.json")
    practice = load(PRACTICE)
    autopilot = load(AUTO)
    record = practice["skills"][manifest["skill_id"]]
    return {
        "ok": True,
        "contract": manifest["contract_id"],
        "model": manifest["model"],
        "counted_attempt": False,
        "required_score": manifest["required_score_percent"],
        "qualifications_required": manifest["qualifications_required"],
        "current_counted_attempts": record["attempts"],
        "current_successful_rounds": record["successful_attempts"],
        "knowledge_certified": record["knowledge_certified"],
        "forge_allowed": record["forge_allowed"],
        "autopilot_enabled": autopilot["enabled"],
        "counted_execution_enabled": autopilot["counted_execution_enabled"],
    }

def run_one():
    if not network_ok():
        raise RuntimeError("SHiREVault network storage is unavailable")
    if worker_running():
        raise RuntimeError("Academy worker or full queue is running")

    before = sha(PRACTICE)
    manifest = load(CONTRACT / "manifest.json")
    schema = load(CONTRACT / "schema.json")
    prompt = (CONTRACT / "prompt.txt").read_text(encoding="utf-8").strip()
    skill_id = manifest["skill_id"]
    run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
    run_dir = EVIDENCE / "parametric_cad" / "parameter-architecture" / run_id
    run_dir.mkdir(parents=True, exist_ok=False)

    system = """
Return exactly one JSON object matching the schema.
p.d is a typed derived parameter: n=name, e=expression, q=the exact names
of user parameters it depends on. p.z is a typed stable reference:
k=origin/baseline/datum/reference_plane and d=its explanation.
The title must name a real harmless household object, not an Academy test.
Boundary values must satisfy minimum <= nominal <= maximum.
Supply every substantive value yourself. Do not claim execution, physical
validation, Forge access, release authority, or Blender use. JSON only.
""".strip()

    request_data = {
        "model": manifest["model"],
        "think": False,
        "stream": False,
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": prompt},
        ],
        "options": {
            "temperature": 0.0,
            "num_predict": 720,
            "num_ctx": 1536,
        },
        "format": schema,
    }
    atomic(run_dir / "request.json", request_data)

    request = urllib.request.Request(
        MODEL_URL,
        data=json.dumps(request_data).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )

    started = time.monotonic()
    try:
        with urllib.request.urlopen(request, timeout=330) as response:
            ollama = json.loads(response.read().decode("utf-8"))
    except (urllib.error.URLError, TimeoutError) as exc:
        record_result(skill_id, run_dir, "infrastructure_failure",
                      False, 0, {"error": str(exc)})
        raise

    elapsed = round(time.monotonic() - started, 2)
    atomic(run_dir / "ollama-response.json", ollama)
    raw = str((ollama.get("message") or {}).get("content") or "").strip()
    (run_dir / "raw-answer.json").write_text(raw + "\n", encoding="utf-8")
    try:
        wire = json.loads(raw)
    except json.JSONDecodeError as exc:
        after = sha(PRACTICE)
        failure = {
            "classification": "infrastructure_failure",
            "skill_id": skill_id,
            "counted_attempt": False,
            "retryable": True,
            "elapsed_seconds": elapsed,
            "done_reason": ollama.get("done_reason"),
            "generated_tokens": ollama.get("eval_count"),
            "error": f"Incomplete structured response: {exc}",
            "practice_state_sha256_before": before,
            "practice_state_sha256_after": after,
            "practice_state_unchanged": before == after,
        }
        atomic(run_dir / "result.json", failure)
        record_result(
            skill_id,
            run_dir,
            "infrastructure_failure",
            False,
            0,
            failure,
        )
        return {**failure, "evidence": str(run_dir)}

    correction_count = 0

    if not (
        float(wire["b"]["n"])
        <= float(wire["b"]["d"])
        <= float(wire["b"]["x"])
    ):
        correction_schema = {
            "type": "object",
            "required": ["n", "d", "x", "i"],
            "properties": {
                "n": {"type": "number"},
                "d": {"type": "number"},
                "x": {"type": "number"},
                "i": {
                    "type": "string",
                    "minLength": 8,
                    "maxLength": 160,
                },
            },
            "additionalProperties": False,
        }

        correction_attempts = []
        corrected_boundary = dict(wire["b"])

        for attempt_number in (1, 2):
            correction_context = {
                "project_title": wire["p"]["t"],
                "user_parameters": wire["p"]["u"],
                "original_boundary": wire["b"],
                "previous_correction": (
                    corrected_boundary
                    if correction_attempts
                    else None
                ),
            }

            correction_payload = {
                "model": manifest["model"],
                "think": False,
                "stream": False,
                "messages": [
                    {
                        "role": "system",
                        "content": (
                            "Repair only the boundary object for this "
                            "Parameter Architecture project. Choose one of "
                            "the supplied numeric user parameters as the "
                            "boundary subject. Return n as its smallest "
                            "valid value, d as its normal nominal value, "
                            "and x as its largest valid value. You must "
                            "satisfy n <= d <= x. Keep values plausible "
                            "for the selected parameter and on its numeric "
                            "scale. The invalid field must be a clear "
                            "plain-language out-of-range case. Do not use "
                            "format placeholders, equations, chemistry "
                            "units, copied unordered values or invented "
                            "technical notation. Return only keys "
                            "n, d, x and i as JSON."
                        ),
                    },
                    {
                        "role": "user",
                        "content": json.dumps(
                            correction_context,
                            sort_keys=True,
                        ),
                    },
                ],
                "options": {
                    "temperature": 0.0,
                    "num_predict": 180,
                    "num_ctx": 768,
                },
                "format": correction_schema,
            }

            atomic(
                run_dir
                / f"boundary-correction-{attempt_number}-request.json",
                correction_payload,
            )

            correction_request = urllib.request.Request(
                MODEL_URL,
                data=json.dumps(correction_payload).encode("utf-8"),
                headers={"Content-Type": "application/json"},
                method="POST",
            )

            correction_started = time.monotonic()
            with urllib.request.urlopen(
                correction_request,
                timeout=150,
            ) as response:
                correction_response = json.loads(
                    response.read().decode("utf-8")
                )

            elapsed += round(
                time.monotonic() - correction_started,
                2,
            )

            atomic(
                run_dir
                / f"boundary-correction-{attempt_number}-response.json",
                correction_response,
            )

            correction_raw = str(
                (correction_response.get("message") or {}).get(
                    "content"
                )
                or ""
            ).strip()

            corrected_boundary = json.loads(correction_raw)

            if set(corrected_boundary) != {"n", "d", "x", "i"}:
                raise RuntimeError(
                    "Boundary self-correction returned invalid keys"
                )

            correction_attempts.append({
                "attempt": attempt_number,
                "boundary": corrected_boundary,
            })

            if (
                float(corrected_boundary["n"])
                <= float(corrected_boundary["d"])
                <= float(corrected_boundary["x"])
            ):
                invalid_text = str(corrected_boundary["i"]).strip()
                forbidden = ("{", "}", "mol", "^", "0:")
                if (
                    len(invalid_text) >= 8
                    and not any(token in invalid_text for token in forbidden)
                ):
                    break

        wire["b"] = corrected_boundary
        correction_count = len(correction_attempts)
        atomic(
            run_dir / "boundary-correction-summary.json",
            {
                "attempts": correction_attempts,
                "final_boundary": corrected_boundary,
                "ordered": (
                    float(corrected_boundary["n"])
                    <= float(corrected_boundary["d"])
                    <= float(corrected_boundary["x"])
                ),
            },
        )

    expected = {
        "root": {"r", "p", "b", "v", "s", "l"},
        "project": {"t", "g", "u", "d", "z", "s", "o", "a"},
        "derived": {"n", "e", "q"},
        "datum": {"k", "d"},
    }
    assert set(wire) == expected["root"]
    assert set(wire["p"]) == expected["project"]
    assert set(wire["p"]["d"]) == expected["derived"]
    assert set(wire["p"]["z"]) == expected["datum"]

    used = set()
    def take(*path):
        value = wire
        for key in path:
            value = value[key]
        used.add(path)
        return value

    def number(value):
        return format(float(value), ".15g")

    inputs = []
    parameter_names = []
    for index in range(len(wire["p"]["u"])):
        name = take("p", "u", index, "n")
        value = take("p", "u", index, "v")
        unit = take("p", "u", index, "u")
        parameter_names.append(str(name))
        inputs.append(f"{name}={number(value)}{unit}")

    criteria = []
    for index in range(len(wire["p"]["a"])):
        criteria.append(
            f"{take('p','a',index,'m')}: "
            f"{number(take('p','a',index,'v'))}"
            f"{take('p','a',index,'u')}"
        )

    dependencies = [
        take("p", "d", "q", index)
        for index in range(len(wire["p"]["d"]["q"]))
    ]
    primary_unit = wire["p"]["u"][0]["u"]

    full = {
        "round": take("r"),
        "transfer_project": {
            "title": take("p", "t"),
            "goal": (
                f"{take('p','g')} Derived parameter "
                f"{take('p','d','n')} uses expression "
                f"{take('p','d','e')} and depends on "
                f"{', '.join(dependencies)}. Stable "
                f"{take('p','z','k')}: {take('p','z','d')}."
            ),
            "inputs": inputs,
            "steps": [take("p", "s", i) for i in range(3)],
            "outputs": [
                take("p", "o", i) for i in range(len(wire["p"]["o"]))
            ],
            "acceptance_criteria": criteria,
        },
        "boundary_cases": {
            "minimum": f"minimum={number(take('b','n'))}{primary_unit}",
            "nominal": f"nominal={number(take('b','d'))}{primary_unit}",
            "maximum": f"maximum={number(take('b','x'))}{primary_unit}",
            "invalid": take("b", "i"),
        },
        "provenance_review": {
            "licence_status": take("v", "l"),
            "decision": take("v", "d"),
            "commercial_use": take("v", "c"),
            "reason": take("v", "r"),
        },
        "safety_gate": {
            "risk_class": take("s", "r"),
            "action": take("s", "a"),
            "forge_execution_allowed": take("s", "f"),
            "reason": take("s", "x"),
        },
        "limitations": [take("l", 0), take("l", 1)],
    }

    def leaves(value, path=()):
        if isinstance(value, dict):
            for key, child in value.items():
                yield from leaves(child, path + (key,))
        elif isinstance(value, list):
            for index, child in enumerate(value):
                yield from leaves(child, path + (index,))
        else:
            yield path

    from services.design_academy_practice_service import practice_service
    bundle = practice_service._lesson_bundle(skill_id)
    assessment = practice_service.evaluate_response(skill_id, bundle, full, 3)

    lower_names = {name.strip().lower() for name in parameter_names}
    lower_dependencies = {str(name).strip().lower() for name in dependencies}
    relevance = {
        "real_project_title": not any(
            term in full["transfer_project"]["title"].lower()
            for term in ("academy", "round", "test", "schema", "compact")
        ),
        "unique_user_parameters": (
            len(lower_names) >= 2 and len(lower_names) == len(parameter_names)
        ),
        "dependency_references_parameters": (
            bool(lower_dependencies)
            and lower_dependencies.issubset(lower_names)
        ),
        "derived_expression_supplied": (
            len(str(wire["p"]["d"]["e"]).strip()) >= 8
        ),
        "typed_stable_datum": (
            wire["p"]["z"]["k"]
            in {"origin", "baseline", "datum", "reference_plane"}
        ),
        "ordered_boundaries": (
            float(wire["b"]["n"])
            <= float(wire["b"]["d"])
            <= float(wire["b"]["x"])
        ),
    }

    trace_complete = used == set(leaves(wire))
    passed = all([
        elapsed < 360,
        ollama.get("done_reason") == "stop",
        assessment["passed"],
        assessment["score_percent"] == 100,
        all(relevance.values()),
        trace_complete,
    ])

    result = {
        "classification": (
            "UNCOUNTED_QUALIFICATION_PASS"
            if passed else "UNCOUNTED_QUALIFICATION_FAIL"
        ),
        "skill_id": skill_id,
        "counted_attempt": False,
        "elapsed_seconds": elapsed,
        "generated_tokens": ollama.get("eval_count"),
        "automatic_corrections": correction_count,
        "assessment": assessment,
        "relevance": relevance,
        "all_model_content_used": trace_complete,
        "expanded_answer": full,
        "practice_state_sha256_before": before,
    }

    after = sha(PRACTICE)
    result["practice_state_sha256_after"] = after
    result["practice_state_unchanged"] = before == after
    if before != after:
        raise RuntimeError("Practice state changed during uncounted qualification")

    atomic(run_dir / "expanded-answer.json", full)
    atomic(run_dir / "result.json", result)
    record_result(skill_id, run_dir, result["classification"],
                  passed, assessment["score_percent"], result)
    return {**result, "evidence": str(run_dir)}

def record_result(skill_id, run_dir, classification, passed, score, detail):
    state = load(AUTO)
    skill = state.setdefault("skills", {}).setdefault(skill_id, {
        "qualification_runs": [],
        "successful_qualifications": 0,
        "consecutive_qualification_failures": 0,
        "consecutive_infrastructure_failures": 0,
    })
    skill["qualification_runs"].append({
        "recorded_at": now(),
        "classification": classification,
        "passed": bool(passed),
        "score_percent": int(score),
        "evidence": str(run_dir),
    })
    if classification == "infrastructure_failure":
        skill["consecutive_infrastructure_failures"] += 1
    elif passed:
        skill["successful_qualifications"] += 1
        skill["consecutive_qualification_failures"] = 0
        skill["consecutive_infrastructure_failures"] = 0
    else:
        skill["consecutive_qualification_failures"] += 1

    required = int(state["qualifications_required"])
    successful = int(skill["successful_qualifications"])
    state["current_plan"]["next_action"] = (
        "ready_for_counted_certification"
        if successful >= required
        else f"uncounted_qualification_{successful + 1}"
    )
    state["updated_at"] = now()
    atomic(AUTO, state)

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("command", choices=("self-check", "run-one"))
    args = parser.parse_args()
    payload = self_check() if args.command == "self-check" else run_one()
    print(json.dumps(payload, indent=2, sort_keys=True))
    return 0 if payload.get("classification") in (
        None,
        "UNCOUNTED_QUALIFICATION_PASS",
    ) else 1

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