#!/usr/bin/env python3
# For authorized testing only
"""AFL++ fuzzing campaign management and crash triage agent."""

import json
import argparse
import os
import subprocess
from datetime import datetime


def instrument_target(source_path, output_path, compiler="afl-clang-fast",
                      sanitizer=None):
    """Compile target with AFL++ instrumentation."""
    cmd = [compiler, "-o", output_path, source_path]
    if sanitizer == "asan":
        cmd.insert(1, "-fsanitize=address")
    elif sanitizer == "ubsan":
        cmd.insert(1, "-fsanitize=undefined")
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    return {
        "compiler": compiler,
        "source": source_path,
        "output": output_path,
        "sanitizer": sanitizer,
        "success": result.returncode == 0,
        "stderr": result.stderr[:500] if result.stderr else "",
    }


def minimize_corpus(afl_cmin_path, target_binary, input_dir, output_dir):
    """Minimize seed corpus using afl-cmin."""
    cmd = [afl_cmin_path or "afl-cmin", "-i", input_dir, "-o", output_dir,
           "--", target_binary]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
    before = len(os.listdir(input_dir)) if os.path.isdir(input_dir) else 0
    after = len(os.listdir(output_dir)) if os.path.isdir(output_dir) else 0
    return {
        "before": before,
        "after": after,
        "reduction_pct": round((1 - after / max(before, 1)) * 100, 1),
        "success": result.returncode == 0,
    }


def parse_fuzzer_stats(output_dir):
    """Parse afl-fuzz fuzzer_stats file for campaign metrics."""
    stats_path = os.path.join(output_dir, "default", "fuzzer_stats")
    if not os.path.exists(stats_path):
        stats_path = os.path.join(output_dir, "fuzzer_stats")
    if not os.path.exists(stats_path):
        return {"error": f"fuzzer_stats not found in {output_dir}"}

    stats = {}
    with open(stats_path, "r") as f:
        for line in f:
            if ":" in line:
                key, val = line.split(":", 1)
                stats[key.strip()] = val.strip()

    return {
        "start_time": stats.get("start_time", ""),
        "last_update": stats.get("last_update", ""),
        "execs_done": int(stats.get("execs_done", 0)),
        "execs_per_sec": float(stats.get("execs_per_sec", 0)),
        "paths_total": int(stats.get("corpus_count", stats.get("paths_total", 0))),
        "paths_found": int(stats.get("paths_found", 0)),
        "unique_crashes": int(stats.get("saved_crashes", stats.get("unique_crashes", 0))),
        "unique_hangs": int(stats.get("saved_hangs", stats.get("unique_hangs", 0))),
        "stability": stats.get("stability", ""),
        "bitmap_cvg": stats.get("bitmap_cvg", ""),
        "command_line": stats.get("command_line", ""),
    }


def triage_crashes(output_dir):
    """Enumerate and classify crash files from AFL++ output."""
    crash_dirs = [
        os.path.join(output_dir, "default", "crashes"),
        os.path.join(output_dir, "crashes"),
    ]
    crash_dir = None
    for d in crash_dirs:
        if os.path.isdir(d):
            crash_dir = d
            break
    if not crash_dir:
        return {"crashes": [], "total": 0}

    crashes = []
    for filename in sorted(os.listdir(crash_dir)):
        if filename.startswith("README") or filename == ".state":
            continue
        filepath = os.path.join(crash_dir, filename)
        size = os.path.getsize(filepath)
        sig_parts = filename.split(",")
        signal = ""
        for part in sig_parts:
            if part.startswith("sig:"):
                signal = part.split(":")[1]
        crashes.append({
            "filename": filename,
            "size_bytes": size,
            "signal": signal,
            "path": filepath,
        })
    return {"crashes": crashes, "total": len(crashes)}


def minimize_crash(afl_tmin_path, target_binary, crash_file, output_file):
    """Minimize a crash test case with afl-tmin."""
    cmd = [afl_tmin_path or "afl-tmin", "-i", crash_file, "-o", output_file,
           "--", target_binary]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    orig_size = os.path.getsize(crash_file) if os.path.exists(crash_file) else 0
    min_size = os.path.getsize(output_file) if os.path.exists(output_file) else 0
    return {
        "original_size": orig_size,
        "minimized_size": min_size,
        "reduction_pct": round((1 - min_size / max(orig_size, 1)) * 100, 1),
        "success": result.returncode == 0,
    }


def run_whatsup(output_dir):
    """Run afl-whatsup to get multi-instance campaign summary."""
    cmd = ["afl-whatsup", "-s", output_dir]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
    return {"output": result.stdout[:2000] if result.stdout else result.stderr[:500]}


def run_audit(args):
    """Execute AFL++ fuzzing campaign audit."""
    print(f"\n{'='*60}")
    print(f"  AFL++ FUZZING CAMPAIGN AUDIT")
    print(f"  Generated: {datetime.utcnow().isoformat()} UTC")
    print(f"{'='*60}\n")

    report = {}

    if args.output_dir:
        stats = parse_fuzzer_stats(args.output_dir)
        report["fuzzer_stats"] = stats
        print(f"--- FUZZER STATS ---")
        print(f"  Executions: {stats.get('execs_done', 0):,}")
        print(f"  Exec/sec: {stats.get('execs_per_sec', 0)}")
        print(f"  Paths: {stats.get('paths_total', 0)}")
        print(f"  Crashes: {stats.get('unique_crashes', 0)}")
        print(f"  Hangs: {stats.get('unique_hangs', 0)}")
        print(f"  Stability: {stats.get('stability', '')}")
        print(f"  Coverage: {stats.get('bitmap_cvg', '')}")

        crash_data = triage_crashes(args.output_dir)
        report["crash_triage"] = crash_data
        print(f"\n--- CRASH TRIAGE ({crash_data['total']} crashes) ---")
        for c in crash_data["crashes"][:20]:
            print(f"  {c['filename']} ({c['size_bytes']}B) signal={c['signal']}")

    if args.instrument_src and args.instrument_out:
        inst = instrument_target(args.instrument_src, args.instrument_out,
                                  sanitizer=args.sanitizer)
        report["instrumentation"] = inst
        print(f"\n--- INSTRUMENTATION ---")
        print(f"  {'SUCCESS' if inst['success'] else 'FAILED'}: {inst['source']}")

    return report


def main():
    parser = argparse.ArgumentParser(description="AFL++ Fuzzing Campaign Agent")
    parser.add_argument("--output-dir", help="AFL++ output directory to analyze")
    parser.add_argument("--instrument-src", help="Source file to instrument")
    parser.add_argument("--instrument-out", help="Output path for instrumented binary")
    parser.add_argument("--sanitizer", choices=["asan", "ubsan"],
                        help="Address or undefined behavior sanitizer")
    parser.add_argument("--output", help="Save report to JSON file")
    args = parser.parse_args()

    report = run_audit(args)
    if args.output:
        with open(args.output, "w") as f:
            json.dump(report, f, indent=2, default=str)
        print(f"\n[+] Report saved to {args.output}")


if __name__ == "__main__":
    main()
