#!/usr/bin/env python3
"""Run the real AcTrail fanotify permission-enforcement E2E."""

import argparse
import json
import os
import select
import signal
import subprocess
import sys
import tempfile
import time
from pathlib import Path


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("--bin-dir", default=os.environ.get("ACTRAIL_BIN_DIR", "target/release"))
    parser.add_argument("--daemon-ready-timeout-sec", type=float, default=10.0)
    parser.add_argument("--agent-timeout-sec", type=float, default=10.0)
    parser.add_argument("--drain-attempts", type=int, default=20)
    parser.add_argument("--drain-sleep-sec", type=float, default=0.2)
    parser.add_argument("--otel-output", help="export and validate OTEL JSON before cleanup")
    return parser.parse_args()


def require_root() -> None:
    if os.geteuid() != 0:
        raise RuntimeError("fanotify permission events require root/CAP_SYS_ADMIN")


def require_binary(bin_dir: Path, name: str) -> Path:
    path = bin_dir / name
    if not path.exists():
        raise RuntimeError(f"missing binary {path}; build with cargo build --release")
    return path


def write_text(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8")


def operator_config(tmp: Path, rules_path: Path) -> str:
    return f"""# Generated by docs/examples/04.fanotify-enforcement-e2e/run_e2e.py
[control]
socket_path = "{tmp / 'actrail.sock'}"
socket_mode_octal = "660"
pending_connection_max = 256
pid_file = "{tmp / 'actraild.pid'}"
log_path = "{tmp / 'actraild.log'}"
diagnostic_log_level = "info"

[storage]
backend = "sqlite"

[storage.sqlite]
path = "{tmp / 'actrail.sqlite'}"
busy_timeout_ms = 5000

[web]
listen_addr = "127.0.0.1:18080"
request_read_timeout_ms = "1000"

[export.snapshot]
graph_schema_version = "manual-v1"
allow_active_trace_snapshot = true
directory = "{tmp / 'export'}"
payload_bytes_enabled = false
payload_text_enabled = false

[capture]
profile_name = "fanotify-enforcement-e2e"
capabilities = ["proc-lifecycle", "enforcement-file-permission-fanotify"]

[ebpf]
enabled = true
memlock_rlimit = "inherit"
tracked_process_max_entries = 4096
pending_operation_max_entries = 4096
suppressed_fd_max_entries = 4096
suppressed_fd_index_slots_per_process = 64
event_ring_buffer_max_bytes = 1048576
file_path_capture_enabled = false
file_path_max_bytes = 255

[payload.tls]
enabled = false
capture_backend = "tls-sync"
source = "auto"
resolver = "auto"
library = "auto"
library_path = "auto"
binary_path = "disabled"
pattern_path = "disabled"
max_segment_bytes = 4095
max_operation_bytes = 4194304
ring_buffer_bytes = 1048576
pending_operation_max_entries = 4096
retention_max_bytes_per_trace = 10485760
redaction_policy = "authorization-header"
sync_runtime_library_path = "auto"
sync_event_socket_path = "/tmp/actrail-fanotify-e2e-tls-sync.sock"
sync_socket_mode_octal = "660"
sync_match_limit = 8
seccomp_syscalls = ["write", "writev", "sendto", "sendmsg"]

[payload.stdio]
enabled = false
capture_stdin = false
capture_stdout = true
capture_stderr = true
stdin_storage_mode = "full"
stdout_storage_mode = "drop"
stderr_storage_mode = "metadata-only"
max_segment_bytes = 4095
ring_buffer_bytes = 1048576
pending_operation_max_entries = 4096
stream_state_max_entries = 4096
retention_max_bytes_per_trace = 10485760
redaction_policy = "authorization-header"

[payload.socket]
enabled = false
capture_backend = "bpf-copy-seccomp-fallback"
max_segment_bytes = 4095
max_operation_bytes = 4194304
ring_buffer_bytes = 2097152
pending_operation_max_entries = 4096
stream_state_max_entries = 4096
retention_max_bytes_per_trace = 10485760
redaction_policy = "authorization-header"
http_sniff_max_bytes = 8192
seccomp_syscalls = ["write", "sendto"]

[seccomp_notify]
enabled = false
reserved_listener_fd = 253

[process_seccomp]
enabled = false
syscalls = ["execve", "execveat", "fork", "vfork", "clone", "clone3"]
max_args = 64
max_arg_bytes = 4096
pending_max_entries = 4096

[agent_invocation]
enabled = false
commands = ["opencode", ".opencode", "claude"]

[application]
enabled = false
http1_enabled = false
http2_enabled = false

[application.http]
capture_host = false
sse_enabled = false
sse_data_policy = "disabled"
sse_max_buffer_bytes = 4194304
sse_max_data_bytes = 4096

[application.http2]
max_frame_bytes = 16384
max_connection_buffer_bytes = 1048576
emit_data_preview = false
max_data_preview_bytes = 4096

[resource_metrics]
enabled = false
interval_ms = 1000
include_children = true
include_system = true
cpu_alert_percent_millis = "disabled"
memory_alert_rss_kb = "disabled"

[provider]
rules_enabled = false
rules_path = "/etc/actrail/provider-rules.conf"
unknown_provider_label = "unknown"

[enforcement]
enabled = true
backend = "fanotify"
scope = "trace"
rules_path = "{rules_path}"
default_decision = "allow"
mark_strategy = "parent-directories"
audit_enabled = true
event_buffer_bytes = 65536

[supervision]
startup_wait_ms = 5000
shutdown_wait_ms = 5000
poll_interval_ms = 100
"""


def wait_for_daemon(process: subprocess.Popen[str], timeout_sec: float) -> None:
    deadline = time.monotonic() + timeout_sec
    while time.monotonic() < deadline:
        line = read_line_until(process, process.stdout, deadline)
        if line:
            print(line, end="")
            if "daemon listening" in line:
                return
        if process.poll() is not None:
            stderr = process.stderr.read()
            raise RuntimeError(f"actraild exited early: {stderr}")
    raise RuntimeError("actraild did not report readiness")


def read_agent_pid(process: subprocess.Popen[str], timeout_sec: float) -> int:
    deadline = time.monotonic() + timeout_sec
    while time.monotonic() < deadline:
        line = read_line_until(process, process.stdout, deadline)
        if line.startswith("agent_pid="):
            print(line, end="")
            return int(line.split("=", 1)[1])
        if line:
            print(line, end="")
        if process.poll() is not None:
            raise RuntimeError("agent exited before reporting pid")
    raise RuntimeError("agent did not report pid")


def run_checked(command: list[str]) -> str:
    result = subprocess.run(command, text=True, capture_output=True, check=False)
    if result.returncode != 0:
        raise RuntimeError(
            f"command failed: {' '.join(command)}\nstdout={result.stdout}\nstderr={result.stderr}"
        )
    if result.stdout:
        print(result.stdout, end="")
    if result.stderr:
        print(result.stderr, end="", file=sys.stderr)
    return result.stdout


def wait_for_agent_output(process: subprocess.Popen[str], timeout_sec: float) -> str:
    deadline = time.monotonic() + timeout_sec
    lines: list[str] = []
    while time.monotonic() < deadline:
        line = read_line_until(process, process.stdout, deadline)
        if line:
            print(line, end="")
            lines.append(line)
            if "pre_exec_redirection=" in line:
                break
        if process.poll() is not None:
            break
    exit_code = process.wait(timeout=max(deadline - time.monotonic(), 0.1))
    output = "".join(lines)
    if exit_code != 0:
        raise RuntimeError(f"agent failed with exit={exit_code}: {output}")
    return output


def read_line_until(
    process: subprocess.Popen[str],
    stream,
    deadline: float,
) -> str:
    if stream is None:
        raise RuntimeError("process stream is not captured")
    remaining = deadline - time.monotonic()
    if remaining <= 0:
        return ""
    readable, _, _ = select.select([stream], [], [], remaining)
    if readable:
        return stream.readline()
    if process.poll() is not None:
        return stream.readline()
    return ""


def wait_for_enforcement_events(
    actrailctl: Path,
    actrailviewer: Path,
    config_path: Path,
    attempts: int,
    sleep_sec: float,
) -> str:
    for _ in range(attempts):
        run_checked([str(actrailctl), "--config", str(config_path), "list-traces"])
        output = run_checked(
            [str(actrailviewer), "events", "--config", str(config_path), "--trace-id", "1"]
        )
        if all(
            value in output
            for value in [
                "Enforcement",
                "allow-file",
                "deny-file",
                "deny-pre-exec-redirection",
            ]
        ):
            return output
        time.sleep(sleep_sec)
    raise RuntimeError("actrailviewer did not show expected enforcement events")


def export_and_validate_otel(actrailviewer: Path, config_path: Path, output_path: Path) -> None:
    output_path.parent.mkdir(parents=True, exist_ok=True)
    if output_path.exists():
        output_path.unlink()
    run_checked(
        [
            str(actrailviewer),
            "export-otel",
            "--config",
            str(config_path),
            "--trace-id",
            "1",
            "--output",
            str(output_path),
        ]
    )
    document = json.loads(output_path.read_text(encoding="utf-8"))
    found = {"allow": False, "deny": False, "pre_exec_deny": False}
    for span in otel_spans(document):
        attributes = otel_attributes(span)
        if attributes.get("actrail.action.kind") != "enforcement.decision":
            continue
        decision = attributes.get("enforcement.decision")
        status = attributes.get("actrail.action.status")
        result = attributes.get("enforcement.result")
        rule_id = attributes.get("enforcement.rule_id")
        code = span.get("status", {}).get("code")
        if decision == "allow" and status == "success" and result == "allowed":
            found["allow"] = rule_id == "allow-file" and code == "STATUS_CODE_OK"
        if decision == "deny" and status == "error" and result == "denied":
            if rule_id == "deny-file":
                found["deny"] = code == "STATUS_CODE_ERROR"
            if rule_id == "deny-pre-exec-redirection":
                found["pre_exec_deny"] = code == "STATUS_CODE_ERROR"
    if not all(found.values()):
        raise RuntimeError(f"OTEL export missed enforcement decision spans: {found}")
    print(f"otel_output={output_path}")
    print("otel_enforcement_spans=allow,deny")
    print("otel_pre_exec_redirection_span=deny")


def otel_spans(document: dict) -> list[dict]:
    spans: list[dict] = []
    for resource_span in document.get("resourceSpans", []):
        for scope_span in resource_span.get("scopeSpans", []):
            spans.extend(scope_span.get("spans", []))
    return spans


def otel_attributes(span: dict) -> dict[str, str]:
    output: dict[str, str] = {}
    for attribute in span.get("attributes", []):
        value = attribute.get("value", {})
        if "stringValue" in value:
            output[attribute.get("key", "")] = value["stringValue"]
        elif "intValue" in value:
            output[attribute.get("key", "")] = str(value["intValue"])
    return output


def stop_process(process: subprocess.Popen[str]) -> None:
    if process.poll() is not None:
        return
    process.send_signal(signal.SIGTERM)
    try:
        process.wait(timeout=5)
    except subprocess.TimeoutExpired:
        process.kill()
        process.wait()
def main() -> int:
    args = parse_args()
    require_root()
    repo = Path.cwd()
    bin_dir = repo / args.bin_dir
    actraild = require_binary(bin_dir, "actraild")
    actrailctl = require_binary(bin_dir, "actrailctl")
    actrailviewer = require_binary(bin_dir, "actrailviewer")
    agent_script = Path(__file__).resolve().parent / "agent.py"

    with tempfile.TemporaryDirectory(prefix="actrail-fanotify-e2e-") as raw_tmp:
        tmp = Path(raw_tmp)
        allowed = tmp / "targets" / "allowed.txt"
        denied = tmp / "targets" / "denied.txt"
        pre_exec_denied = tmp / "targets" / "pre-exec-denied.txt"
        rules = tmp / "rules.conf"
        config = tmp / "operator.conf"
        write_text(allowed, "allowed\n")
        write_text(denied, "denied\n")
        write_text(pre_exec_denied, "pre-exec denied\n")
        write_text(
            rules,
            f"allow-file allow open {allowed}\n"
            f"deny-file deny open {denied}\n"
            f"deny-pre-exec-redirection deny open {pre_exec_denied}\n",
        )
        write_text(config, operator_config(tmp, rules))

        daemon = subprocess.Popen(
            [str(actraild), "--config", str(config), "run"],
            text=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )
        try:
            wait_for_daemon(daemon, args.daemon_ready_timeout_sec)
            agent = subprocess.Popen(
                [
                    sys.executable,
                    str(agent_script),
                    "--allowed-path",
                    str(allowed),
                    "--denied-path",
                    str(denied),
                    "--pre-exec-denied-path",
                    str(pre_exec_denied),
                ],
                text=True,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
            )
            try:
                pid = read_agent_pid(agent, args.agent_timeout_sec)
                run_checked(
                    [
                        str(actrailctl),
                        "--config",
                        str(config),
                        "track-add",
                        "--pid",
                        str(pid),
                        "--name",
                        "fanotify-enforcement-e2e",
                    ]
                )
                agent.stdin.write("go\n")
                agent.stdin.flush()
                agent_output = wait_for_agent_output(agent, args.agent_timeout_sec)
                if "allowed=ok" not in agent_output:
                    raise RuntimeError("agent did not confirm allowed file access")
                if "denied=permission_denied" not in agent_output:
                    raise RuntimeError("agent did not confirm denied file access")
                if "pre_exec_redirection=permission_denied" not in agent_output:
                    raise RuntimeError(
                        "agent did not confirm fork-before-exec redirection was denied"
                    )
                viewer_output = wait_for_enforcement_events(
                    actrailctl,
                    actrailviewer,
                    config,
                    args.drain_attempts,
                    args.drain_sleep_sec,
                )
                if "decision=allow" not in viewer_output or "decision=deny" not in viewer_output:
                    raise RuntimeError("viewer output missed allow/deny decisions")
                if args.otel_output:
                    export_and_validate_otel(actrailviewer, config, Path(args.otel_output))
            finally:
                stop_process(agent)
        finally:
            stop_process(daemon)

    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except Exception as error:
        print(f"fanotify e2e failed: {error}", file=sys.stderr)
        raise SystemExit(1)