"""Tool evidence parsing. Absent facts stay null; execution and acceptance are distinct."""

from __future__ import annotations
import json, re
from pathlib import Path


class ResultManager:
    def validate_command(self, workspace, code):
        w = Path(workspace)
        path = w / "plugin-result.json"
        if not path.exists():
            return {
                "valid": False,
                "reason": "missing plugin-result.json; command exit alone is not acceptance",
                "coverage": None,
            }
        result = json.loads(path.read_text())
        result["valid"] = code == 0 and result.get("valid") is True
        return result

    def validate_native(self, workspace, config, code):
        w = Path(workspace)
        root = w / "device-output"
        logpath = root / "executor.log"
        methodpath = root / "method-logcat.log"
        reasons = []
        log = logpath.read_text(errors="replace") if logpath.exists() else ""
        start = root / "native/formal-start"
        end = root / "end-unix"
        duration = (
            int(end.read_text().strip()) - int(start.read_text().strip()) / 1000
            if start.exists() and end.exists()
            else None
        )
        steps = len(re.findall(r"SATA end step \[\d+\]", log))
        actions = len(re.findall(r"EXECUTE action_fp=", log))
        if code != 0:
            reasons.append("native exit code " + str(code))
        if duration is None or duration < config["budget_seconds"] - 5:
            reasons.append("formal budget not met")
        if "// Monkey finished" not in log or not steps:
            reasons.append("native completion/actions evidence missing")
        ack = "PLATFORM_CONFIG_VERIFIED " + config["run_id"]
        if ack not in log:
            reasons.append("native configuration acknowledgement missing")
        coverage = None
        methods = None
        if methodpath.exists() and methodpath.stat().st_size:
            cfg = json.loads((w / "inputs/coverage_config").read_text())
            aliases = cfg.get("MethodLogAliases", {})
            methods = {
                aliases.get(m.group(1).strip(), m.group(1).strip())
                for m in re.finditer(
                    r"METHOD(?:\s*[:=]\s*|\s+)([^\n\r]+)",
                    methodpath.read_text(errors="replace"),
                )
            }
            includes = cfg.get("IncludePrefixes", [])
            excludes = cfg.get("ExcludePrefixes", [])

            def domain(method):
                cls = method.strip("<").split(":", 1)[0]
                return (
                    bool(includes)
                    and any(cls == p or cls.startswith(p + ".") for p in includes)
                    and not any(cls == p or cls.startswith(p + ".") for p in excludes)
                )

            appmethods = {m for m in methods if domain(m)}
            manifestpath = w / "inputs/method_manifest"
            universe = None
            if manifestpath.exists():
                universe = set(
                    json.loads(manifestpath.read_text()).get(
                        "instrumented_method_ids", []
                    )
                )
            if universe is None:
                reasons.append("frozen method universe unavailable")
            elif methods - universe:
                reasons.append("observed methods outside frozen APK universe")
            total = cfg.get("TotalMethod")
            coverage = {
                "app_methods": len(appmethods),
                "total_methods": total,
                "app_ratio": len(appmethods) / total if total else None,
                "apk_methods": len(methods),
                "apk_total": cfg.get("ApkTotalMethod")
                or cfg.get("AllMethodStringsInApk"),
                "tag": cfg.get("Tag"),
                "universe_verified": universe is not None and not (methods - universe),
            }
            (w / "covered-methods.txt").write_text("\n".join(sorted(methods)) + "\n")
        if coverage is None:
            reasons.append("METHOD coverage evidence absent")
        events = []
        for path in root.rglob("jev-events.jsonl"):
            for line in path.read_text(errors="replace").splitlines():
                try:
                    events.append(json.loads(line))
                except ValueError:
                    reasons.append("malformed decision event")
        decision = None
        if config.get("provider_id"):
            sent = sum(e.get("event") == "JEV_REQUEST_SENT" for e in events)
            # Synchronous frozen Native emits SEMANTIC_RESPONSE_CONSUMED while
            # asynchronous Native emits JEV_RESULT_RECEIVED. Count each logical
            # request once; a retry can legitimately produce multiple receipt
            # events for one request id.
            reply_events = {
                e.get("request_id")
                for e in events
                if e.get("event")
                in ("JEV_RESULT_RECEIVED", "SEMANTIC_RESPONSE_CONSUMED")
                and e.get("request_id")
            }
            replies = len(reply_events)
            invalid = [
                e
                for e in events
                if e.get("event") in ("DECISION_UNHEALTHY", "RECEIPT_FAILED")
            ]
            finished = [e for e in events if e.get("event") == "RUN_FINISHED"]
            healthy = bool(
                finished and finished[-1].get("decision_healthy") and not invalid
            )
            decision = {
                "sent": sent,
                "replies": replies,
                "healthy": healthy,
                "status": (
                    "VALID"
                    if healthy and replies
                    else "NO_REVIEW" if healthy and not sent else "INVALID"
                ),
            }
            if not healthy:
                reasons.append("decision chain unhealthy or missing terminal receipt")
            if not replies:
                # A native process can finish with a healthy-looking terminal event
                # while no model request was actually answered. Keep the evidence,
                # but do not accept it as a valid model experiment.
                reasons.append("no model replies; decision result is NO_REVIEW")
        files = list(root.rglob("*"))
        pairwise = [
            str(p.relative_to(root))
            for p in files
            if p.is_file() and "pair" in p.name.lower()
        ]
        probes = [e for e in events if str(e.get("event", "")).startswith("PROBE_")]
        crashes = [
            str(p.relative_to(root))
            for p in files
            if p.is_file() and "crash" in p.name.lower()
        ]
        return {
            "valid": not reasons,
            "reason": "; ".join(dict.fromkeys(reasons)) or None,
            "exit_code": code,
            "duration_seconds": duration,
            "coverage": coverage,
            "decision": decision,
            "native_steps": steps,
            "execution_records": actions,
            "pairwise_files": pairwise or None,
            "probe_event_count": len(probes) if events else None,
            "crash_files": crashes or None,
            "evidence_files": sum(p.is_file() for p in files),
            "evaluation_protocol": config["evaluation_protocol"],
        }
