#!/usr/bin/env python3
"""
Portable CutySoup vs BeautifulSoup4 benchmark runner.

This script is designed to be copied to any machine and run after:
- pip install cutysoup
- pip install beautifulsoup4

Examples:
  python portable_benchmark.py
  python portable_benchmark.py --sizes xs,s,m,l --iterations 200 --parse-iterations 25
  python portable_benchmark.py --articles 10 100 400 2000 --json-out results.json
  python portable_benchmark.py --prewarm-python-cache
  python portable_benchmark.py --write-html ./html-fixtures
"""

from __future__ import annotations

import argparse
import dataclasses
import gc
import json
import os
import platform
import statistics
import sys
import time
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple


SIZE_PRESETS: Dict[str, int] = {
    "xs": 10,
    "s": 100,
    "m": 400,
    "l": 2000,
    "xl": 8000,
}


def _percentile(sorted_values: Sequence[float], p: float) -> float:
    if not sorted_values:
        return float("nan")
    if p <= 0:
        return sorted_values[0]
    if p >= 100:
        return sorted_values[-1]
    index = (len(sorted_values) - 1) * (p / 100.0)
    lo = int(index)
    hi = min(lo + 1, len(sorted_values) - 1)
    frac = index - lo
    if hi == lo:
        return sorted_values[lo]
    return sorted_values[lo] * (1.0 - frac) + sorted_values[hi] * frac


@dataclasses.dataclass
class TimingStats:
    iterations: int
    warmup: int
    errors: int
    mean_ms: float
    median_ms: float
    p95_ms: float
    stdev_ms: float
    min_ms: float
    max_ms: float


def _compute_stats(times_s: List[float], iterations: int, warmup: int, errors: int) -> TimingStats:
    if not times_s:
        return TimingStats(
            iterations=iterations,
            warmup=warmup,
            errors=errors,
            mean_ms=float("nan"),
            median_ms=float("nan"),
            p95_ms=float("nan"),
            stdev_ms=float("nan"),
            min_ms=float("nan"),
            max_ms=float("nan"),
        )

    sorted_times = sorted(times_s)
    mean_ms = statistics.mean(sorted_times) * 1000.0
    median_ms = statistics.median(sorted_times) * 1000.0
    p95_ms = _percentile(sorted_times, 95) * 1000.0
    stdev_ms = (statistics.stdev(sorted_times) * 1000.0) if len(sorted_times) > 1 else 0.0
    min_ms = sorted_times[0] * 1000.0
    max_ms = sorted_times[-1] * 1000.0

    return TimingStats(
        iterations=iterations,
        warmup=warmup,
        errors=errors,
        mean_ms=mean_ms,
        median_ms=median_ms,
        p95_ms=p95_ms,
        stdev_ms=stdev_ms,
        min_ms=min_ms,
        max_ms=max_ms,
    )


def benchmark(
    operation: Callable[[], Any],
    *,
    iterations: int,
    warmup: int,
    collect_garbage: bool = True,
    disable_gc: bool = True,
) -> TimingStats:
    # Warmup
    errors = 0
    for _ in range(max(0, warmup)):
        try:
            operation()
        except Exception:
            errors += 1

    if collect_garbage:
        gc.collect()

    gc_was_enabled = gc.isenabled()
    if disable_gc and gc_was_enabled:
        gc.disable()

    times_s: List[float] = []
    try:
        for _ in range(max(0, iterations)):
            start = time.perf_counter()
            try:
                operation()
            except Exception:
                errors += 1
                continue
            end = time.perf_counter()
            times_s.append(end - start)
    finally:
        if disable_gc and gc_was_enabled:
            gc.enable()

    return _compute_stats(times_s, iterations=iterations, warmup=warmup, errors=errors)


def generate_html(num_articles: int, *, links_per_article: int = 2) -> str:
    # Keep deterministic output so results are comparable across machines.
    head = (
        "<!doctype html><html><head>"
        '<meta charset="utf-8">'
        '<title id="title">Portable Benchmark</title>'
        "</head><body>"
        '<div id="main" class="container">'
    )

    parts: List[str] = [head]

    for i in range(num_articles):
        parts.append(
            f'<article id="post{i}" class="post featured" data-id="{i}">'
            f'<h2 class="title">Title {i}</h2>'
            f'<p class="intro">Intro {i}</p>'
            f'<p class="content text">Body {i} <em>em {i}</em></p>'
        )
        for j in range(links_per_article):
            parts.append(
                f'<a class="link read-more" href="/p/{i}/{j}" data-kind="link">Read</a>'
            )
        parts.append("</article>")

    parts.append(
        "</div>"
        '<aside class="sidebar">'
        '<nav class="navigation">'
        '<a href="/home" class="nav-link active">Home</a>'
        '<a href="/about" class="nav-link">About</a>'
        "</nav>"
        "</aside>"
        "</body></html>"
    )

    return "".join(parts)


def _scrape_articles_loop(soup: Any) -> List[Tuple[str, Optional[str]]]:
    results: List[Tuple[str, Optional[str]]] = []
    for article in soup.find_all("article"):
        title_tag = article.find("h2", class_="title")
        link_tag = article.find("a", class_="link")
        title_text = title_tag.get_text(strip=True) if title_tag else ""
        href = link_tag.get("href") if link_tag else None
        results.append((title_text, href))
    return results


def _adjusted_iterations(base: int, articles: int) -> int:
    if articles >= 8000:
        return max(3, base // 25)
    if articles >= 2000:
        return max(5, base // 10)
    if articles >= 1000:
        return max(10, base // 5)
    return base


def _make_cutysoup_config(prewarm: bool) -> Optional[Any]:
    if not prewarm:
        return None
    try:
        from cutysoup import ParserConfig  # type: ignore
    except Exception:
        return None
    try:
        return ParserConfig(prewarm_python_cache=True)
    except TypeError:
        # Older versions may not have the option; still return a config instance.
        try:
            return ParserConfig()
        except Exception:
            return None


def run_benchmarks_for_size(
    *,
    html: str,
    articles: int,
    iterations: int,
    parse_iterations: int,
    warmup: int,
    prewarm_python_cache: bool,
) -> List[Dict[str, Any]]:
    try:
        from cutysoup import CutySoup  # type: ignore
    except Exception as exc:
        raise RuntimeError("cutysoup is not installed (pip install cutysoup)") from exc

    try:
        from bs4 import BeautifulSoup  # type: ignore
    except Exception as exc:
        raise RuntimeError("beautifulsoup4 is not installed (pip install beautifulsoup4)") from exc

    cs_config = _make_cutysoup_config(prewarm_python_cache)

    def _cs_soup() -> Any:
        return CutySoup(html, config=cs_config) if cs_config is not None else CutySoup(html)

    def _bs_soup() -> Any:
        return BeautifulSoup(html, "html.parser")

    # Pre-parse for warm operations
    cs_soup = _cs_soup()
    bs_soup = _bs_soup()

    cs_article = cs_soup.find("article")
    bs_article = bs_soup.find("article")

    # Suites: parse-only, parse+action (cold), warm ops, and a common scraping loop.
    operations: List[Tuple[str, Callable[[], Any], Callable[[], Any], int, int]] = []

    operations.append(("parse", _cs_soup, _bs_soup, parse_iterations, 0))

    operations.append(
        (
            "parse_and_find_all_tag(p)",
            lambda: len(_cs_soup().find_all("p")),
            lambda: len(_bs_soup().find_all("p")),
            min(parse_iterations, iterations),
            0,
        )
    )
    operations.append(
        (
            "parse_and_find_all_class(.text)",
            lambda: len(_cs_soup().find_all(class_="text")),
            lambda: len(_bs_soup().find_all(class_="text")),
            min(parse_iterations, iterations),
            0,
        )
    )
    operations.append(
        (
            "parse_and_scrape_articles(loop)",
            lambda: len(_scrape_articles_loop(_cs_soup())),
            lambda: len(_scrape_articles_loop(_bs_soup())),
            min(parse_iterations, iterations),
            0,
        )
    )

    operations.append(
        (
            "find_all_tag(p)",
            lambda: cs_soup.find_all("p"),
            lambda: bs_soup.find_all("p"),
            iterations,
            warmup,
        )
    )
    operations.append(
        (
            "find_all_class(.text)",
            lambda: cs_soup.find_all(class_="text"),
            lambda: bs_soup.find_all(class_="text"),
            iterations,
            warmup,
        )
    )
    operations.append(
        (
            "find_one_id(#title)",
            lambda: cs_soup.find(id="title"),
            lambda: bs_soup.find(id="title"),
            iterations,
            warmup,
        )
    )
    operations.append(
        (
            "select_descendant(article .link)",
            lambda: cs_soup.select("article .link"),
            lambda: bs_soup.select("article .link"),
            iterations,
            warmup,
        )
    )
    operations.append(
        (
            "get_text(article, strip=True)",
            lambda: cs_article.get_text(strip=True) if cs_article else "",
            lambda: bs_article.get_text(strip=True) if bs_article else "",
            iterations,
            warmup,
        )
    )
    operations.append(
        (
            "href_batch(all a href)",
            lambda: cs_soup.get_all_hrefs() if hasattr(cs_soup, "get_all_hrefs") else [a.get("href") for a in cs_soup.find_all("a")],
            lambda: [a.get("href") for a in bs_soup.find_all("a")],
            iterations,
            warmup,
        )
    )
    operations.append(
        (
            "scrape_articles(loop)",
            lambda: _scrape_articles_loop(cs_soup),
            lambda: _scrape_articles_loop(bs_soup),
            iterations,
            warmup,
        )
    )

    results: List[Dict[str, Any]] = []
    for op_name, cs_op, bs_op, op_iterations, op_warmup in operations:
        cs_stats = benchmark(cs_op, iterations=op_iterations, warmup=op_warmup)
        bs_stats = benchmark(bs_op, iterations=op_iterations, warmup=op_warmup)
        speedup = (
            (bs_stats.mean_ms / cs_stats.mean_ms)
            if (cs_stats.mean_ms and cs_stats.mean_ms == cs_stats.mean_ms and cs_stats.mean_ms > 0)
            else float("nan")
        )
        results.append(
            {
                "operation": op_name,
                "iterations": op_iterations,
                "warmup": op_warmup,
                "cutysoup": dataclasses.asdict(cs_stats),
                "beautifulsoup4": dataclasses.asdict(bs_stats),
                "speedup": speedup,
            }
        )

    return results


def _format_float(value: float, width: int = 9) -> str:
    if value != value:  # NaN
        return " " * (width - 3) + "nan"
    return f"{value:>{width}.3f}"


def print_summary(size_label: str, articles: int, html: str, results: List[Dict[str, Any]]) -> None:
    print()
    print(f"== size={size_label} articles={articles} bytes={len(html.encode('utf-8')):,} chars={len(html):,} ==")
    print(f"{'operation':40} {'cs_ms':>9} {'bs4_ms':>9} {'x':>8} {'cs_p95':>9} {'bs4_p95':>9}")
    for entry in results:
        cs_ms = entry["cutysoup"]["mean_ms"]
        bs_ms = entry["beautifulsoup4"]["mean_ms"]
        cs_p95 = entry["cutysoup"]["p95_ms"]
        bs_p95 = entry["beautifulsoup4"]["p95_ms"]
        speedup = entry["speedup"]
        print(
            f"{entry['operation'][:40]:40} "
            f"{_format_float(cs_ms)} "
            f"{_format_float(bs_ms)} "
            f"{_format_float(speedup, width=8)} "
            f"{_format_float(cs_p95)} "
            f"{_format_float(bs_p95)}"
        )


def _parse_size_list(value: str) -> List[str]:
    items = [v.strip() for v in value.split(",") if v.strip()]
    for item in items:
        if item not in SIZE_PRESETS:
            raise argparse.ArgumentTypeError(f"Unknown size '{item}'. Use one of: {', '.join(SIZE_PRESETS)}")
    return items


def main(argv: Optional[Sequence[str]] = None) -> int:
    parser = argparse.ArgumentParser(description="Portable benchmark: cutysoup vs beautifulsoup4")
    parser.add_argument(
        "--sizes",
        type=_parse_size_list,
        default=["xs", "s", "m", "l"],
        help="Comma-separated size presets: xs,s,m,l,xl (default: xs,s,m,l)",
    )
    parser.add_argument(
        "--articles",
        type=int,
        nargs="*",
        default=None,
        help="Explicit article counts (overrides --sizes). Example: --articles 10 100 400 2000",
    )
    parser.add_argument("--iterations", type=int, default=200, help="Iterations for warm operations (default: 200)")
    parser.add_argument("--parse-iterations", type=int, default=25, help="Iterations for parse/cold operations (default: 25)")
    parser.add_argument("--warmup", type=int, default=5, help="Warmup iterations for warm operations (default: 5)")
    parser.add_argument("--prewarm-python-cache", action="store_true", help="Enable CutySoup ParserConfig(prewarm_python_cache=True)")
    parser.add_argument("--json-out", type=str, default="", help="Write full results JSON to this path")
    parser.add_argument("--write-html", type=str, default="", help="Write generated HTML fixtures into this directory")
    parser.add_argument("--quiet", action="store_true", help="Suppress table output (still writes JSON if --json-out is set)")

    args = parser.parse_args(argv)

    try:
        import cutysoup  # type: ignore
        cutysoup_version = getattr(cutysoup, "__version__", "unknown")
    except Exception:
        cutysoup_version = "not-installed"

    try:
        import bs4  # type: ignore
        bs4_version = getattr(bs4, "__version__", "unknown")
    except Exception:
        bs4_version = "not-installed"

    if args.write_html:
        os.makedirs(args.write_html, exist_ok=True)

    article_counts: List[Tuple[str, int]] = []
    if args.articles:
        for count in args.articles:
            article_counts.append((str(count), int(count)))
    else:
        for size_name in args.sizes:
            article_counts.append((size_name, SIZE_PRESETS[size_name]))

    full_report: Dict[str, Any] = {
        "meta": {
            "python": sys.version.replace("\n", " "),
            "platform": platform.platform(),
            "machine": platform.machine(),
            "cutysoup_version": cutysoup_version,
            "beautifulsoup4_version": bs4_version,
            "timestamp_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        },
        "config": {
            "iterations": args.iterations,
            "parse_iterations": args.parse_iterations,
            "warmup": args.warmup,
            "prewarm_python_cache": bool(args.prewarm_python_cache),
        },
        "sizes": {},
    }

    for size_label, articles in article_counts:
        html = generate_html(articles)

        if args.write_html:
            path = os.path.join(args.write_html, f"bench_{size_label}_{articles}.html")
            with open(path, "w", encoding="utf-8") as handle:
                handle.write(html)

        iterations = _adjusted_iterations(args.iterations, articles)
        parse_iterations = _adjusted_iterations(args.parse_iterations, articles)
        warmup = min(args.warmup, max(0, iterations // 4))

        results = run_benchmarks_for_size(
            html=html,
            articles=articles,
            iterations=iterations,
            parse_iterations=parse_iterations,
            warmup=warmup,
            prewarm_python_cache=bool(args.prewarm_python_cache),
        )

        full_report["sizes"][size_label] = {
            "articles": articles,
            "html_chars": len(html),
            "html_bytes_utf8": len(html.encode("utf-8")),
            "iterations": iterations,
            "parse_iterations": parse_iterations,
            "warmup": warmup,
            "results": results,
        }

        if not args.quiet:
            print_summary(size_label=size_label, articles=articles, html=html, results=results)

    if args.json_out:
        with open(args.json_out, "w", encoding="utf-8") as handle:
            json.dump(full_report, handle, indent=2, sort_keys=True)
        if not args.quiet:
            print()
            print(f"Wrote JSON report: {args.json_out}")

    return 0


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

