#!/usr/bin/env python3
"""Rank Hetzner Server Auction servers by value.

Defaults reflect the requested policy:
- ECC-only
- iNIC bonus enabled
- GPU bonus enabled
- RAM contributes to CPU and storage rankings, not just overall ranking

The script fetches Hetzner's public auction feed, derives CPU thread counts from
known CPU models, computes value metrics, prints the top matches, and writes CSV
and JSON exports under TMPDIR by default.
"""

from __future__ import annotations

import argparse
import contextlib
import csv
import io
import json
import math
import os
import re
import ssl
import sys
import urllib.request
from pathlib import Path
from typing import Any

PUBLIC_AUCTION_EUR = "https://www.hetzner.com/_resources/app/data/app/live_data_sb_EUR.json"
GPU_HINTS = ("GPU", "NVIDIA", "RTX", "TESLA", "A100", "A6000", "H100", "L40", "L4", "V100")

CPU_SPECS: dict[str, dict[str, int]] = {
    "AMD EPYC 7401P": {"cores": 24, "threads": 48},
    "AMD EPYC 7502P": {"cores": 32, "threads": 64},
    "AMD Ryzen 5 3600": {"cores": 6, "threads": 12},
    "AMD Ryzen 7 1700X": {"cores": 8, "threads": 16},
    "AMD Ryzen 7 3700X": {"cores": 8, "threads": 16},
    "AMD Ryzen 7 7700": {"cores": 8, "threads": 16},
    "AMD Ryzen 7 PRO 1700X": {"cores": 8, "threads": 16},
    "AMD Ryzen 9 3900": {"cores": 12, "threads": 24},
    "AMD Ryzen 9 5950X": {"cores": 16, "threads": 32},
    "AMD Ryzen Threadripper 2950X": {"cores": 16, "threads": 32},
    "Intel Core i5-12500": {"cores": 6, "threads": 12},
    "Intel Core i7-6700": {"cores": 4, "threads": 8},
    "Intel Core i7-7700": {"cores": 4, "threads": 8},
    "Intel Core i7-8700": {"cores": 6, "threads": 12},
    "Intel Core i9-12900K": {"cores": 16, "threads": 24},
    "Intel Core i9-13900": {"cores": 24, "threads": 32},
    "Intel Core i9-9900K": {"cores": 8, "threads": 16},
    "Intel XEON E-2176G": {"cores": 6, "threads": 12},
    "Intel XEON E-2276G": {"cores": 6, "threads": 12},
    "Intel Xeon E3-1270V3": {"cores": 4, "threads": 8},
    "Intel Xeon E3-1271V3": {"cores": 4, "threads": 8},
    "Intel Xeon E3-1275V6": {"cores": 4, "threads": 8},
    "Intel Xeon E3-1275v5": {"cores": 4, "threads": 8},
    "Intel Xeon E5-1650V3": {"cores": 6, "threads": 12},
    "Intel Xeon Gold 5412U": {"cores": 24, "threads": 48},
    "Intel Xeon W-2145": {"cores": 8, "threads": 16},
    "Intel Xeon W-2245": {"cores": 8, "threads": 16},
    "Intel Xeon W-2295": {"cores": 18, "threads": 36},
}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--url", default=PUBLIC_AUCTION_EUR, help="Auction feed URL")
    parser.add_argument("--top", type=int, default=5, help="Rows to print per ranking")
    parser.add_argument(
        "--include-non-ecc",
        action="store_true",
        help="Include non-ECC servers instead of filtering to ECC only",
    )
    parser.add_argument(
        "--only-ecc",
        action="store_true",
        help="Explicitly require ECC servers only",
    )
    parser.add_argument(
        "--budget-max",
        type=float,
        help="Maximum monthly price in EUR",
    )
    parser.add_argument(
        "--min-ram-gb",
        type=int,
        default=0,
        help="Minimum RAM in GB",
    )
    parser.add_argument(
        "--max-ram-gb",
        type=int,
        help="Maximum RAM in GB",
    )
    parser.add_argument(
        "--min-storage-gb",
        type=int,
        default=0,
        help="Minimum total storage in GB",
    )
    parser.add_argument(
        "--max-storage-gb",
        type=int,
        help="Maximum total storage in GB",
    )
    parser.add_argument(
        "--ssd-only",
        action="store_true",
        help="Require at least one SSD/NVMe drive and exclude any HDD-backed servers",
    )
    parser.add_argument(
        "--datacenter",
        help="Case-insensitive datacenter filter, e.g. HEL1 or FSN1-DC18",
    )
    parser.add_argument(
        "--exclude-datacenter",
        help="Case-insensitive datacenter exclusion filter, e.g. NBG1 or FSN1-DC18",
    )
    parser.add_argument(
        "--cpu-regex",
        help="Case-insensitive regex to match CPU model names",
    )
    parser.add_argument(
        "--exclude-cpu-regex",
        help="Case-insensitive regex to exclude CPU model names",
    )
    parser.add_argument(
        "--only-gpu",
        action="store_true",
        help="Only include GPU-tagged servers",
    )
    parser.add_argument(
        "--inic-bonus",
        type=float,
        default=0.10,
        help="Additive iNIC bonus as a fraction, e.g. 0.10 = +10%%",
    )
    parser.add_argument(
        "--gpu-bonus",
        type=float,
        default=0.15,
        help="Additive GPU bonus as a fraction, e.g. 0.15 = +15%%",
    )
    parser.add_argument(
        "--cpu-rank-compute-weight",
        type=float,
        default=0.75,
        help="Weight of compute in the CPU ranking; RAM gets the remainder",
    )
    parser.add_argument(
        "--storage-rank-storage-weight",
        type=float,
        default=0.75,
        help="Weight of storage in the storage ranking; RAM gets the remainder",
    )
    parser.add_argument(
        "--overall-weights",
        default="0.40,0.30,0.30",
        help="Overall weights as compute,ram,storage",
    )
    parser.add_argument(
        "--format",
        choices=("text", "markdown"),
        default="text",
        help="Console output format",
    )
    parser.add_argument(
        "--out-dir",
        default=str(Path(os.environ.get("TMPDIR", "/tmp")) / "hetzner-auction-value"),
        help="Directory for CSV/JSON exports",
    )
    parser.add_argument(
        "--csv-out",
        help="Write CSV to this exact path instead of <out-dir>/hetzner_auction_value_rankings.csv",
    )
    parser.add_argument(
        "--json-out",
        help="Write JSON to this exact path instead of <out-dir>/hetzner_auction_value_rankings.json",
    )
    parser.add_argument(
        "--no-write",
        action="store_true",
        help="Do not write CSV/JSON exports; print only",
    )
    parser.add_argument(
        "--strict-cpu-map",
        action="store_true",
        help="Exit if an unknown CPU model appears in the feed",
    )
    parser.add_argument(
        "--sort-by",
        choices=("raw-cpu", "raw-ram", "raw-storage", "cpu-rank", "storage-rank", "overall"),
        default="overall",
        help="Primary sort order for exported rows and the custom top section",
    )
    parser.add_argument(
        "--price-per-thread-cap",
        type=float,
        help="Maximum monthly EUR per CPU thread",
    )
    parser.add_argument(
        "--save-markdown",
        help="Write a Markdown report to this exact path",
    )
    return parser.parse_args()


def fetch_json(url: str) -> dict[str, Any]:
    with urllib.request.urlopen(url, context=ssl.create_default_context(), timeout=30) as response:
        payload = json.loads(response.read().decode())
    if not isinstance(payload, dict) or not isinstance(payload.get("server"), list):
        raise SystemExit(f"Unexpected feed shape from {url}")
    return payload


def disk_breakdown(server: dict[str, Any]) -> dict[str, int]:
    disk = server.get("serverDiskData") or {}
    if not isinstance(disk, dict):
        return {"nvme_gb": 0, "sata_gb": 0, "hdd_gb": 0, "ssd_gb": 0, "storage_gb": 0}

    nvme_gb = sum(int(v) for v in (disk.get("nvme") or []) if v is not None)
    sata_gb = sum(int(v) for v in (disk.get("sata") or []) if v is not None)
    hdd_gb = sum(int(v) for v in (disk.get("hdd") or []) if v is not None)
    ssd_gb = nvme_gb + sata_gb
    return {
        "nvme_gb": nvme_gb,
        "sata_gb": sata_gb,
        "hdd_gb": hdd_gb,
        "ssd_gb": ssd_gb,
        "storage_gb": ssd_gb + hdd_gb,
    }


def total_storage_gb(server: dict[str, Any]) -> int:
    return disk_breakdown(server)["storage_gb"]


def feature_flags(server: dict[str, Any]) -> tuple[bool, bool]:
    specials = {str(v).upper() for v in (server.get("specials") or [])}
    description = " ".join(str(v) for v in (server.get("description") or [])).upper()
    has_inic = "INIC" in specials or "INIC" in description
    has_gpu = "GPU" in specials or any(hint in description for hint in GPU_HINTS)
    return has_inic, has_gpu


def weighted_geomean(pairs: list[tuple[float, float]]) -> float:
    filtered = [(value, weight) for value, weight in pairs if weight > 0]
    if not filtered:
        return 0.0
    if any(value <= 0 for value, _ in filtered):
        return 0.0
    total_weight = sum(weight for _, weight in filtered)
    if total_weight <= 0:
        return 0.0
    return math.exp(sum((weight / total_weight) * math.log(value) for value, weight in filtered))


def money(value: float) -> str:
    return f"€{value:.0f}"


def fmt_tb(gb: int) -> str:
    return f"{gb / 1000:.2f} TB"


def sort_rows(rows: list[dict[str, Any]], score_key: str) -> list[dict[str, Any]]:
    return sorted(rows, key=lambda row: (row[score_key], -row["price_eur"]), reverse=True)


def markdown_escape(value: Any) -> str:
    return str(value).replace("|", "\\|").replace("\n", " ")


def print_text_section(title: str, score_key: str, ranked: list[dict[str, Any]]) -> None:
    print(f"\n{title}")
    for index, row in enumerate(ranked, start=1):
        tags = []
        if row["ecc"]:
            tags.append("ECC")
        if row["has_inic"]:
            tags.append("iNIC")
        if row["has_gpu"]:
            tags.append("GPU")
        if row["has_ssd"] and not row["has_hdd"]:
            tags.append("SSD-only")
        tag_text = ", ".join(tags) if tags else "-"
        print(
            f"{index}. id={row['id']}  score={row[score_key] * 100:.2f}  price={money(row['price_eur'])}  "
            f"cpu={row['cpu']}  threads={row['threads']}  ram={row['ram_gb']}GB  "
            f"storage={fmt_tb(row['storage_gb'])}  dc={row['datacenter']}  bonus=x{row['bonus_multiplier']:.2f}  tags={tag_text}"
        )
        print(f"   drives: {row['drives']}")


def print_markdown_section(title: str, score_key: str, ranked: list[dict[str, Any]]) -> None:
    print(f"\n## {title}\n")
    print("| Rank | ID | Score | Price | CPU | Threads | RAM | Storage | DC | Bonus | Tags | Drives |")
    print("| --- | ---: | ---: | ---: | --- | ---: | ---: | ---: | --- | ---: | --- | --- |")
    for index, row in enumerate(ranked, start=1):
        tags = []
        if row["ecc"]:
            tags.append("ECC")
        if row["has_inic"]:
            tags.append("iNIC")
        if row["has_gpu"]:
            tags.append("GPU")
        if row["has_ssd"] and not row["has_hdd"]:
            tags.append("SSD-only")
        tag_text = ", ".join(tags) if tags else "-"
        print(
            "| "
            f"{index} | {markdown_escape(row['id'])} | {row[score_key] * 100:.2f} | {money(row['price_eur'])} | "
            f"{markdown_escape(row['cpu'])} | {row['threads']} | {row['ram_gb']} GB | {fmt_tb(row['storage_gb'])} | "
            f"{markdown_escape(row['datacenter'])} | x{row['bonus_multiplier']:.2f} | {markdown_escape(tag_text)} | {markdown_escape(row['drives'])} |"
        )


def write_exports(
    rows: list[dict[str, Any]],
    summary: dict[str, Any],
    out_dir: Path,
    *,
    csv_out: str | None = None,
    json_out: str | None = None,
) -> tuple[Path, Path]:
    out_dir.mkdir(parents=True, exist_ok=True)
    csv_path = Path(csv_out) if csv_out else out_dir / "hetzner_auction_value_rankings.csv"
    json_path = Path(json_out) if json_out else out_dir / "hetzner_auction_value_rankings.json"
    csv_path.parent.mkdir(parents=True, exist_ok=True)
    json_path.parent.mkdir(parents=True, exist_ok=True)

    fieldnames = [
        "id",
        "cpu",
        "cores",
        "threads",
        "ram_gb",
        "price_eur",
        "storage_gb",
        "storage_tb",
        "nvme_gb",
        "sata_gb",
        "hdd_gb",
        "has_ssd",
        "has_hdd",
        "datacenter",
        "bandwidth_mbit",
        "ecc",
        "has_inic",
        "has_gpu",
        "bonus_multiplier",
        "raw_cpu_value",
        "raw_ram_value",
        "raw_storage_value",
        "score_cpu_rank",
        "score_storage_rank",
        "score_overall_rank",
        "drives",
        "specials",
    ]
    with csv_path.open("w", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=fieldnames)
        writer.writeheader()
        for row in rows:
            writer.writerow({key: row.get(key) for key in fieldnames})

    json_path.write_text(json.dumps(summary, indent=2) + "\n")
    return csv_path, json_path


def main() -> int:
    args = parse_args()

    if not 0 <= args.cpu_rank_compute_weight <= 1:
        raise SystemExit("--cpu-rank-compute-weight must be between 0 and 1")
    if not 0 <= args.storage_rank_storage_weight <= 1:
        raise SystemExit("--storage-rank-storage-weight must be between 0 and 1")
    if args.only_ecc and args.include_non_ecc:
        raise SystemExit("--only-ecc and --include-non-ecc cannot be used together")
    if args.max_ram_gb is not None and args.max_ram_gb < args.min_ram_gb:
        raise SystemExit("--max-ram-gb must be >= --min-ram-gb")
    if args.max_storage_gb is not None and args.max_storage_gb < args.min_storage_gb:
        raise SystemExit("--max-storage-gb must be >= --min-storage-gb")
    if args.price_per_thread_cap is not None and args.price_per_thread_cap <= 0:
        raise SystemExit("--price-per-thread-cap must be > 0")

    try:
        overall_compute_weight, overall_ram_weight, overall_storage_weight = [
            float(part.strip()) for part in args.overall_weights.split(",")
        ]
    except ValueError as exc:
        raise SystemExit("--overall-weights must look like 0.40,0.30,0.30") from exc

    if min(overall_compute_weight, overall_ram_weight, overall_storage_weight) < 0:
        raise SystemExit("overall weights must be non-negative")
    if overall_compute_weight + overall_ram_weight + overall_storage_weight <= 0:
        raise SystemExit("overall weights must sum to a positive number")

    payload = fetch_json(args.url)
    source_servers = payload["server"]
    ecc_only = args.only_ecc or not args.include_non_ecc
    filtered_servers = [s for s in source_servers if not ecc_only or bool(s.get("is_ecc"))]

    cpu_regex = None
    if args.cpu_regex:
        try:
            cpu_regex = re.compile(args.cpu_regex, re.IGNORECASE)
        except re.error as exc:
            raise SystemExit(f"Invalid --cpu-regex: {exc}") from exc

    exclude_cpu_regex = None
    if args.exclude_cpu_regex:
        try:
            exclude_cpu_regex = re.compile(args.exclude_cpu_regex, re.IGNORECASE)
        except re.error as exc:
            raise SystemExit(f"Invalid --exclude-cpu-regex: {exc}") from exc

    rows: list[dict[str, Any]] = []
    unknown_cpu_models: set[str] = set()

    for server in filtered_servers:
        cpu = str(server.get("cpu") or "")
        spec = CPU_SPECS.get(cpu)
        if spec is None:
            unknown_cpu_models.add(cpu)
            continue

        price_eur = float(server.get("price") or 0)
        if price_eur <= 0:
            continue
        if args.budget_max is not None and price_eur > args.budget_max:
            continue
        datacenter = str(server.get("datacenter") or "")
        if args.datacenter and args.datacenter.lower() not in datacenter.lower():
            continue
        if args.exclude_datacenter and args.exclude_datacenter.lower() in datacenter.lower():
            continue
        if cpu_regex and not cpu_regex.search(cpu):
            continue
        if exclude_cpu_regex and exclude_cpu_regex.search(cpu):
            continue

        ram_gb = int(server.get("ram_size") or 0)
        disks = disk_breakdown(server)
        storage_gb = disks["storage_gb"]
        if ram_gb < args.min_ram_gb:
            continue
        if args.max_ram_gb is not None and ram_gb > args.max_ram_gb:
            continue
        if storage_gb < args.min_storage_gb:
            continue
        if args.max_storage_gb is not None and storage_gb > args.max_storage_gb:
            continue
        if args.ssd_only and (disks["ssd_gb"] <= 0 or disks["hdd_gb"] > 0):
            continue
        if price_eur / spec["threads"] > args.price_per_thread_cap if args.price_per_thread_cap is not None else False:
            continue

        has_inic, has_gpu = feature_flags(server)
        if args.only_gpu and not has_gpu:
            continue
        bonus_multiplier = (1.0 + (args.inic_bonus if has_inic else 0.0)) * (
            1.0 + (args.gpu_bonus if has_gpu else 0.0)
        )

        rows.append(
            {
                "id": server.get("id") or server.get("key"),
                "cpu": cpu,
                "cores": spec["cores"],
                "threads": spec["threads"],
                "ram_gb": ram_gb,
                "price_eur": price_eur,
                "storage_gb": storage_gb,
                "storage_tb": round(storage_gb / 1000, 2),
                "nvme_gb": disks["nvme_gb"],
                "sata_gb": disks["sata_gb"],
                "hdd_gb": disks["hdd_gb"],
                "has_ssd": disks["ssd_gb"] > 0,
                "has_hdd": disks["hdd_gb"] > 0,
                "datacenter": datacenter,
                "bandwidth_mbit": int(server.get("bandwidth") or 0),
                "ecc": bool(server.get("is_ecc")),
                "has_inic": has_inic,
                "has_gpu": has_gpu,
                "bonus_multiplier": round(bonus_multiplier, 4),
                "drives": " + ".join(str(v) for v in (server.get("hdd_arr") or [])),
                "specials": ", ".join(str(v) for v in (server.get("specials") or [])),
                "raw_cpu_value": spec["threads"] / price_eur,
                "raw_ram_value": ram_gb / price_eur,
                "raw_storage_value": storage_gb / price_eur,
            }
        )

    if unknown_cpu_models:
        message = (
            "Unknown CPU models were skipped. Update CPU_SPECS in tools/hetzner_auction_value.py "
            f"to include them:\n- " + "\n- ".join(sorted(unknown_cpu_models))
        )
        if args.strict_cpu_map:
            raise SystemExit(message)
        print(message, file=sys.stderr)

    if not rows:
        raise SystemExit("No servers left after filtering and CPU mapping")

    max_cpu = max(row["raw_cpu_value"] for row in rows)
    max_ram = max(row["raw_ram_value"] for row in rows)
    max_storage = max(row["raw_storage_value"] for row in rows)

    cpu_rank_ram_weight = 1.0 - args.cpu_rank_compute_weight
    storage_rank_ram_weight = 1.0 - args.storage_rank_storage_weight

    for row in rows:
        row["norm_cpu"] = row["raw_cpu_value"] / max_cpu if max_cpu else 0.0
        row["norm_ram"] = row["raw_ram_value"] / max_ram if max_ram else 0.0
        row["norm_storage"] = row["raw_storage_value"] / max_storage if max_storage else 0.0

        row["score_cpu_rank"] = weighted_geomean(
            [
                (row["norm_cpu"], args.cpu_rank_compute_weight),
                (row["norm_ram"], cpu_rank_ram_weight),
            ]
        ) * row["bonus_multiplier"]

        row["score_storage_rank"] = weighted_geomean(
            [
                (row["norm_storage"], args.storage_rank_storage_weight),
                (row["norm_ram"], storage_rank_ram_weight),
            ]
        ) * row["bonus_multiplier"]

        row["score_overall_rank"] = weighted_geomean(
            [
                (row["norm_cpu"], overall_compute_weight),
                (row["norm_ram"], overall_ram_weight),
                (row["norm_storage"], overall_storage_weight),
            ]
        ) * row["bonus_multiplier"]

    sort_key_map = {
        "raw-cpu": "raw_cpu_value",
        "raw-ram": "raw_ram_value",
        "raw-storage": "raw_storage_value",
        "cpu-rank": "score_cpu_rank",
        "storage-rank": "score_storage_rank",
        "overall": "score_overall_rank",
    }
    selected_sort_key = sort_key_map[args.sort_by]

    top_n = max(1, args.top)
    cpu_top = sort_rows(rows, "score_cpu_rank")[:top_n]
    storage_top = sort_rows(rows, "score_storage_rank")[:top_n]
    overall_top = sort_rows(rows, "score_overall_rank")[:top_n]
    custom_top = sort_rows(rows, selected_sort_key)[:top_n]
    export_rows = sort_rows(rows, selected_sort_key)

    def build_markdown_report() -> str:
        buffer = io.StringIO()
        with contextlib.redirect_stdout(buffer):
            print("# Hetzner Server Auction value rankings\n")
            print(f"- Source: `{args.url}`")
            print(f"- Servers in feed: **{len(source_servers)}**")
            print(f"- Servers scored: **{len(rows)}**")
            print(f"- ECC only: **{ecc_only}**")
            print(f"- Budget max: **{money(args.budget_max)}**" if args.budget_max is not None else "- Budget max: **none**")
            print(f"- Minimum RAM: **{args.min_ram_gb} GB**")
            print(f"- Maximum RAM: **{args.max_ram_gb} GB**" if args.max_ram_gb is not None else "- Maximum RAM: **none**")
            print(f"- Minimum storage: **{fmt_tb(args.min_storage_gb)}**")
            print(
                f"- Maximum storage: **{fmt_tb(args.max_storage_gb)}**"
                if args.max_storage_gb is not None
                else "- Maximum storage: **none**"
            )
            print(f"- SSD only: **{args.ssd_only}**")
            print(f"- Datacenter filter: **{args.datacenter or 'none'}**")
            print(f"- Exclude datacenter: **{args.exclude_datacenter or 'none'}**")
            print(f"- CPU regex: **`{args.cpu_regex}`**" if args.cpu_regex else "- CPU regex: **none**")
            print(
                f"- Exclude CPU regex: **`{args.exclude_cpu_regex}`**"
                if args.exclude_cpu_regex
                else "- Exclude CPU regex: **none**"
            )
            print(f"- Only GPU: **{args.only_gpu}**")
            print(
                f"- Price per thread cap: **{args.price_per_thread_cap:.2f} EUR**"
                if args.price_per_thread_cap is not None
                else "- Price per thread cap: **none**"
            )
            print(f"- Sort by: **{args.sort_by}**")
            print(
                f"- Bonuses: **iNIC +{args.inic_bonus * 100:.0f}%**, **GPU +{args.gpu_bonus * 100:.0f}%** "
                "(multiplicative if both)"
            )
            print(
                f"- Weights: CPU rank = compute {args.cpu_rank_compute_weight:.2f} / RAM {cpu_rank_ram_weight:.2f}; "
                f"storage rank = storage {args.storage_rank_storage_weight:.2f} / RAM {storage_rank_ram_weight:.2f}; "
                f"overall = compute {overall_compute_weight:.2f} / RAM {overall_ram_weight:.2f} / storage {overall_storage_weight:.2f}"
            )
            print_markdown_section(f"Top by selected sort ({args.sort_by})", selected_sort_key, custom_top)
            print_markdown_section("Top CPU value (RAM-weighted)", "score_cpu_rank", cpu_top)
            print_markdown_section("Top storage value (RAM-weighted)", "score_storage_rank", storage_top)
            print_markdown_section("Top overall value", "score_overall_rank", overall_top)
        return buffer.getvalue()

    def build_text_report() -> str:
        buffer = io.StringIO()
        with contextlib.redirect_stdout(buffer):
            print("Hetzner Server Auction value rankings")
            print(f"Source: {args.url}")
            print(f"Servers in feed: {len(source_servers)}")
            print(f"Servers scored: {len(rows)}")
            print(f"ECC only: {ecc_only}")
            print(f"Budget max: {money(args.budget_max)}" if args.budget_max is not None else "Budget max: none")
            print(f"Minimum RAM: {args.min_ram_gb} GB")
            print(f"Maximum RAM: {args.max_ram_gb} GB" if args.max_ram_gb is not None else "Maximum RAM: none")
            print(f"Minimum storage: {fmt_tb(args.min_storage_gb)}")
            print(f"Maximum storage: {fmt_tb(args.max_storage_gb)}" if args.max_storage_gb is not None else "Maximum storage: none")
            print(f"SSD only: {args.ssd_only}")
            print(f"Datacenter filter: {args.datacenter or 'none'}")
            print(f"Exclude datacenter: {args.exclude_datacenter or 'none'}")
            print(f"CPU regex: {args.cpu_regex or 'none'}")
            print(f"Exclude CPU regex: {args.exclude_cpu_regex or 'none'}")
            print(f"Only GPU: {args.only_gpu}")
            print(
                f"Price per thread cap: {args.price_per_thread_cap:.2f} EUR"
                if args.price_per_thread_cap is not None
                else "Price per thread cap: none"
            )
            print(f"Sort by: {args.sort_by}")
            print(
                "Bonuses: "
                f"iNIC=+{args.inic_bonus * 100:.0f}% GPU=+{args.gpu_bonus * 100:.0f}% "
                "(multiplicative if both)"
            )
            print(
                "Weights: "
                f"cpu_rank=compute {args.cpu_rank_compute_weight:.2f} / ram {cpu_rank_ram_weight:.2f}; "
                f"storage_rank=storage {args.storage_rank_storage_weight:.2f} / ram {storage_rank_ram_weight:.2f}; "
                f"overall=compute {overall_compute_weight:.2f} / ram {overall_ram_weight:.2f} / storage {overall_storage_weight:.2f}"
            )
            print_text_section(f"Top by selected sort ({args.sort_by})", selected_sort_key, custom_top)
            print_text_section("Top CPU value (RAM-weighted)", "score_cpu_rank", cpu_top)
            print_text_section("Top storage value (RAM-weighted)", "score_storage_rank", storage_top)
            print_text_section("Top overall value", "score_overall_rank", overall_top)
        return buffer.getvalue()

    text_report = build_text_report()
    markdown_report = build_markdown_report() if args.format == "markdown" or args.save_markdown else ""
    print(markdown_report if args.format == "markdown" else text_report, end="")

    if args.save_markdown:
        markdown_path = Path(args.save_markdown)
        markdown_path.parent.mkdir(parents=True, exist_ok=True)
        markdown_path.write_text(markdown_report)

    summary = {
        "source": args.url,
        "filters": {
            "ecc_only": ecc_only,
            "budget_max": args.budget_max,
            "min_ram_gb": args.min_ram_gb,
            "max_ram_gb": args.max_ram_gb,
            "min_storage_gb": args.min_storage_gb,
            "max_storage_gb": args.max_storage_gb,
            "ssd_only": args.ssd_only,
            "datacenter": args.datacenter,
            "exclude_datacenter": args.exclude_datacenter,
            "cpu_regex": args.cpu_regex,
            "exclude_cpu_regex": args.exclude_cpu_regex,
            "only_gpu": args.only_gpu,
            "price_per_thread_cap": args.price_per_thread_cap,
        },
        "bonuses": {
            "inic_bonus": args.inic_bonus,
            "gpu_bonus": args.gpu_bonus,
            "stacking": "multiplicative",
        },
        "weights": {
            "cpu_rank": {"compute": args.cpu_rank_compute_weight, "ram": cpu_rank_ram_weight},
            "storage_rank": {"storage": args.storage_rank_storage_weight, "ram": storage_rank_ram_weight},
            "overall": {
                "compute": overall_compute_weight,
                "ram": overall_ram_weight,
                "storage": overall_storage_weight,
            },
        },
        "counts": {
            "feed_servers": len(source_servers),
            "scored_servers": len(rows),
            "ecc_servers": sum(1 for row in rows if row["ecc"]),
            "inic_servers": sum(1 for row in rows if row["has_inic"]),
            "gpu_servers": sum(1 for row in rows if row["has_gpu"]),
            "ssd_only_servers": sum(1 for row in rows if row["has_ssd"] and not row["has_hdd"]),
        },
        "sort_by": args.sort_by,
        "top": {
            "selected_sort": custom_top,
            "cpu_rank": cpu_top,
            "storage_rank": storage_top,
            "overall_rank": overall_top,
        },
        "rows": export_rows,
    }

    if not args.no_write:
        csv_path, json_path = write_exports(
            export_rows,
            summary,
            Path(args.out_dir),
            csv_out=args.csv_out,
            json_out=args.json_out,
        )
        print(f"\nCSV: {csv_path}")
        print(f"JSON: {json_path}")
    if args.save_markdown:
        print(f"Markdown: {Path(args.save_markdown)}")

    return 0


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