#!/usr/bin/env python3
# Copyright: 2026, Maximiliano Curia <maxy@debian.org>
#
# License: ISC
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
#  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
#  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
#  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
#  OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
#  PERFORMANCE OF THIS SOFTWARE.

"""Per-stage benchmark for the decopy file-processing pipeline.

Reads a corpus once, then times each stage of decopy._process_content
independently (read+decode, parse_holders, clean_comments, find_licenses) and
reports seconds, ms/file and the share of total per-file CPU. Also runs a
thread-scaling check on find_licenses, which exposes whether the `regex`
backend lets the thread pool parallelise the dominant cost (today it does not:
regex holds the GIL).

This is the inner-loop tool for the performance work; pair it with the
golden-output regression (tests/golden_output_test.py) so speedups are proven
to preserve behaviour.

Run from the repository root. The corpus defaults to the in-repo fixtures so it
works with no external checkout; point it at a large tree for the real picture:

    python3 scripts/bench_pipeline.py
    python3 scripts/bench_pipeline.py ~/debian/python/ruff/crates --ext .rs,.py
    python3 scripts/bench_pipeline.py ~/debian/python/ruff --limit 4000 --json
"""

from __future__ import annotations

import argparse
import io
import json
import os
import subprocess
import sys
import threading
import time
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from decopy.fstree.parsers import generic_input  # noqa: E402
from decopy.licensing.holders import parse_holders  # noqa: E402
from decopy.licensing.licenses import clean_comments, find_licenses  # noqa: E402


def collect_files(root: Path, exts: set[str], limit: int) -> list[Path]:
    """Regular, non-symlink files under root, optionally filtered by suffix."""
    files: list[Path] = []
    for path in sorted(root.rglob("*")):
        if not path.is_file() or path.is_symlink():
            continue
        if exts and path.suffix not in exts:
            continue
        files.append(path)
        if limit and len(files) >= limit:
            break
    return files


def read_corpus(files: list[Path]) -> tuple[list[str], float, int]:
    """Read+decode every file the way decopy does; return texts, secs, bytes."""
    texts: list[str] = []
    total_bytes = 0
    start = time.perf_counter()
    for path in files:
        try:
            raw = path.read_bytes()
        except OSError:
            continue
        total_bytes += len(raw)
        reader = io.BufferedReader(io.BytesIO(raw))  # type: ignore[arg-type]
        texts.append("".join(generic_input(reader)))  # type: ignore[arg-type]  # ty: ignore[invalid-argument-type]
    return texts, time.perf_counter() - start, total_bytes


def time_stage(label: str, fn, items: list) -> tuple[str, float]:  # noqa: ANN001
    start = time.perf_counter()
    for item in items:
        fn(item)
    return label, time.perf_counter() - start


def measure_import_cost() -> float:
    """Time a fresh import of decopy.licensing.res (compiles all license patterns)."""
    code = (
        "import time; t=time.perf_counter(); "
        "import decopy.licensing.res; print(time.perf_counter()-t)"
    )
    env = {**os.environ, "PYTHONPATH": str(ROOT)}
    try:
        out = subprocess.run(  # noqa: S603
            [sys.executable, "-c", code],
            check=True,
            capture_output=True,
            text=True,
            env=env,
        )
        return float(out.stdout.strip())
    except (subprocess.CalledProcessError, ValueError):
        return float("nan")


def thread_scaling(cleaned: list[str], thread_counts: list[int]) -> list[dict]:
    """Run find_licenses over the corpus on N threads; report wall time."""

    def worker(chunk: list[str]) -> None:
        for text in chunk:
            list(find_licenses(text))

    results = []
    base = None
    for n in thread_counts:
        chunks = [cleaned[i::n] for i in range(n)]
        start = time.perf_counter()
        threads = [threading.Thread(target=worker, args=(c,)) for c in chunks]
        for thread in threads:
            thread.start()
        for thread in threads:
            thread.join()
        elapsed = time.perf_counter() - start
        if base is None:
            base = elapsed
        results.append({"threads": n, "seconds": elapsed, "speedup": base / elapsed})
    return results


def run(args: argparse.Namespace) -> dict:
    exts = {e if e.startswith(".") else f".{e}" for e in args.ext.split(",") if e}
    files = collect_files(Path(args.corpus), exts, args.limit)
    if not files:
        print(f"No files under {args.corpus}", file=sys.stderr)
        raise SystemExit(2)

    texts, read_secs, total_bytes = read_corpus(files)
    n = len(texts)
    cleaned = [clean_comments(t) for t in texts]

    stages = [
        ("read+decode", read_secs),
        time_stage("parse_holders", parse_holders, texts),
        time_stage("clean_comments", clean_comments, texts),
        time_stage("find_licenses", lambda t: list(find_licenses(t)), cleaned),
    ]
    process_total = sum(s for label, s in stages if label != "read+decode")

    max_threads = min(8, (os.cpu_count() or 1))
    thread_counts = [c for c in (1, 2, 4, 8) if c <= max_threads] or [1]

    return {
        "corpus": str(args.corpus),
        "files": n,
        "megabytes": round(total_bytes / 1e6, 2),
        "import_compile_seconds": round(measure_import_cost(), 3),
        "stages": [
            {
                "stage": label,
                "seconds": round(secs, 3),
                "ms_per_file": round(1000 * secs / n, 3),
                "share_pct": (
                    round(100 * secs / process_total, 1)
                    if label != "read+decode" and process_total
                    else None
                ),
            }
            for label, secs in stages
        ],
        "process_total_seconds": round(process_total, 3),
        "thread_scaling_find_licenses": thread_scaling(cleaned, thread_counts),
        "cpu_count": os.cpu_count(),
    }


def print_report(report: dict) -> None:
    print(f"corpus: {report['corpus']}")
    print(
        f"files: {report['files']}  size: {report['megabytes']} MB  "
        f"cpus: {report['cpu_count']}  "
        f"import+compile: {report['import_compile_seconds']}s (one-time)"
    )
    print(f"\n{'stage':<16}{'seconds':>10}{'ms/file':>10}{'share':>8}")
    for stage in report["stages"]:
        share = "" if stage["share_pct"] is None else f"{stage['share_pct']}%"
        print(
            f"{stage['stage']:<16}{stage['seconds']:>10}"
            f"{stage['ms_per_file']:>10}{share:>8}"
        )
    print(f"{'(process total)':<16}{report['process_total_seconds']:>10}")
    print("\nfind_licenses thread scaling (regex holds the GIL -> expect ~1x):")
    for row in report["thread_scaling_find_licenses"]:
        print(
            f"  {row['threads']:>2} threads: {row['seconds']:.3f}s  "
            f"speedup {row['speedup']:.2f}x"
        )


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "corpus",
        nargs="?",
        default=str(ROOT / "tests" / "testdata"),
        help="directory to scan (default: in-repo fixtures)",
    )
    parser.add_argument(
        "--ext",
        default="",
        help="comma-separated suffix filter, e.g. .rs,.py (default: all files)",
    )
    parser.add_argument(
        "--limit", type=int, default=0, help="cap number of files (0 = no cap)"
    )
    parser.add_argument("--json", action="store_true", help="emit the report as JSON")
    args = parser.parse_args()

    report = run(args)
    if args.json:
        print(json.dumps(report, indent=2))
    else:
        print_report(report)
    return 0


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