"""Tests for US-016: mining-style node startup CLI + live dashboard."""

from __future__ import annotations

import json
import socket
import sys
from unittest.mock import MagicMock, patch

# A fake node server has no real backend to prove capability with; say so
# explicitly rather than bypassing startup's fail-closed admission.
from meshnet_node.testing import assume_capability


# ---------------------------------------------------------------------------
# model_catalog tests
# ---------------------------------------------------------------------------

def test_curated_models_list_is_non_empty():
    "Curated models list is non empty\n\nTags: general"
    from meshnet_node.model_catalog import CURATED_MODELS
    assert len(CURATED_MODELS) >= 5


def test_model_preset_vram_for_quant():
    "Model preset vram for quant\n\nTags: general"
    from meshnet_node.model_catalog import CURATED_MODELS

    m = next(m for m in CURATED_MODELS if "Llama-3-70B" in m.name)
    assert m.vram_for_quant("nf4") == m.vram_nf4
    assert m.vram_for_quant("int8") == m.vram_int8
    assert m.vram_for_quant("bf16") == m.vram_bf16
    assert m.vram_for_quant("bfloat16") == m.vram_bf16  # alias


def test_model_preset_fits_vram():
    "Model preset fits vram\n\nTags: general"
    from meshnet_node.model_catalog import CURATED_MODELS

    small = next(m for m in CURATED_MODELS if m.vram_nf4 < 10)
    assert small.fits_vram(small.vram_nf4, "nf4")
    assert not small.fits_vram(small.vram_nf4 - 1, "nf4")


def test_recommended_quant_respects_vram():
    "Recommended quant respects vram\n\nTags: general"
    from meshnet_node.model_catalog import CURATED_MODELS

    m = next(m for m in CURATED_MODELS if "Llama-3-70B" in m.name)
    # nf4=18, int8=40, bf16=140
    assert m.recommended_quant(200) == "bf16"
    assert m.recommended_quant(50) == "int8"
    assert m.recommended_quant(20) == "nf4"
    assert m.recommended_quant(5) is None


def test_models_with_insufficient_vram_are_marked(monkeypatch):
    "Models with insufficient vram are marked\n\nTags: general"
    from meshnet_node import wizard as wiz

    # Simulate 6 GB GPU
    gpus = [{"index": 0, "name": "RTX 3060", "vram_gb": 6.0, "backend": "cuda"}]
    monkeypatch.setattr(wiz, "_detect_gpus", lambda: gpus)

    # Phi-3 at NF4 needs 4 GB — should fit; Llama-3-70B at NF4 needs 18 GB — should not
    from meshnet_node.model_catalog import CURATED_MODELS

    phi = next(m for m in CURATED_MODELS if "Phi-3" in m.name)
    llama = next(m for m in CURATED_MODELS if "Llama-3-70B" in m.name)

    assert phi.fits_vram(6.0, "nf4")
    assert not llama.fits_vram(6.0, "nf4")


# ---------------------------------------------------------------------------
# config tests
# ---------------------------------------------------------------------------

def test_load_config_returns_none_when_missing(tmp_path):
    "Load config returns none when missing\n\nTags: general"
    from meshnet_node.config import load_config
    assert load_config(tmp_path / "nonexistent.json") is None


def test_save_and_load_config_roundtrip(tmp_path):
    "Save and load config roundtrip\n\nTags: general"
    from meshnet_node.config import save_config, load_config

    cfg = {"model_hf_repo": "test/model", "quantization": "nf4", "tracker_url": "http://localhost:8080"}
    cfg_path = tmp_path / "config.json"
    save_config(cfg, cfg_path)

    loaded = load_config(cfg_path)
    assert loaded == cfg


def test_save_config_creates_parent_dirs(tmp_path):
    "Save config creates parent dirs\n\nTags: general"
    from meshnet_node.config import save_config, load_config

    nested = tmp_path / "deep" / "nested" / "config.json"
    save_config({"x": 1}, nested)
    assert nested.exists()
    assert load_config(nested) == {"x": 1}


def test_merge_cli_overrides_applies_non_none_values():
    "Merge cli overrides applies non none values\n\nTags: general"
    from meshnet_node.config import merge_cli_overrides

    base = {"tracker_url": "http://a:8080", "quantization": "nf4", "port": 7000}
    result = merge_cli_overrides(base, tracker_url="http://b:9090", port=None)
    assert result["tracker_url"] == "http://b:9090"
    assert result["port"] == 7000  # None override ignored
    assert result["quantization"] == "nf4"  # unchanged


# ---------------------------------------------------------------------------
# wizard tests
# ---------------------------------------------------------------------------

def test_print_models_table_runs_without_error(capsys, monkeypatch):
    "Print models table runs without error\n\nTags: general"
    from meshnet_node import wizard as wiz

    monkeypatch.setattr(wiz, "_detect_gpus", lambda: [{"index": 0, "name": "GPU", "vram_gb": 24.0, "backend": "cuda"}])
    wiz.print_models_table()
    out = capsys.readouterr().out
    assert "Llama" in out or "Qwen" in out or "Phi" in out


def test_wizard_writes_config_on_happy_path(tmp_path, monkeypatch):
    "Wizard writes config on happy path\n\nTags: general"
    from meshnet_node import wizard as wiz

    # Fake GPU
    gpus = [{"index": 0, "name": "RTX 4090", "vram_gb": 24.0, "backend": "cuda"}]
    monkeypatch.setattr(wiz, "_detect_gpus", lambda: gpus)
    # Tracker not reachable (stub)
    monkeypatch.setattr(wiz, "_ping_tracker", lambda url: False)

    # Simulate user selecting model 1 (Qwen2.5-0.5B), quant 1 (nf4), default dir, default tracker, default wallet
    inputs = iter([
        "1",          # pick Qwen2.5-0.5B-Instruct (index 1 in CURATED_MODELS)
        "1",          # quant NF4
        str(tmp_path / "models"),  # download dir
        "http://localhost:8080",   # tracker
        str(tmp_path / "wallet.json"),  # wallet
    ])
    monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))

    cfg = wiz.run_wizard(config_path_override=tmp_path / "config.json")
    assert cfg["model_hf_repo"] == "Qwen/Qwen2.5-0.5B-Instruct"
    assert cfg["quantization"] == "nf4"
    assert "download_dir" in cfg
    assert cfg["tracker_url"] == "http://localhost:8080"


def test_wizard_raises_keyboard_interrupt_on_ctrl_c(monkeypatch):
    "Wizard raises keyboard interrupt on ctrl c\n\nTags: general"
    from meshnet_node import wizard as wiz

    gpus = [{"index": 0, "name": "RTX 4090", "vram_gb": 24.0, "backend": "cuda"}]
    monkeypatch.setattr(wiz, "_detect_gpus", lambda: gpus)

    call_count = [0]

    def fake_input(prompt=""):
        call_count[0] += 1
        if call_count[0] == 1:
            raise KeyboardInterrupt

    monkeypatch.setattr("builtins.input", fake_input)

    import pytest
    with pytest.raises(KeyboardInterrupt):
        wiz.run_wizard()


# ---------------------------------------------------------------------------
# dashboard tests
# ---------------------------------------------------------------------------

def test_is_interactive_tty_false_when_not_tty(monkeypatch):
    "Is interactive tty false when not tty\n\nTags: general"
    from meshnet_node import dashboard as dash

    monkeypatch.setattr(sys.stdout, "isatty", lambda: False)
    assert not dash.is_interactive_tty()


def test_dashboard_plain_fallback_on_keyboard_interrupt(monkeypatch):
    "Plain loop exits cleanly when Ctrl-C is raised.\n\nTags: general"
    from meshnet_node import dashboard as dash

    node = MagicMock()
    node.chat_completion_count = 5

    call_count = [0]

    def fake_sleep(t):
        call_count[0] += 1
        if call_count[0] >= 1:
            raise KeyboardInterrupt

    monkeypatch.setattr(dash.time, "sleep", fake_sleep)
    monkeypatch.setattr(dash, "_gpu_stats", lambda: [])
    monkeypatch.setattr(sys.stdout, "isatty", lambda: False)

    cfg = {"model_name": "test-model", "quantization": "nf4"}
    # Should not raise
    dash.run_dashboard(node, cfg, start_time=dash.time.monotonic())


def test_ema_updates_correctly():
    "Ema updates correctly\n\nTags: general"
    from meshnet_node.dashboard import _EMA

    ema = _EMA(alpha=1.0)  # alpha=1.0 → always takes latest sample
    ema.update(10.0)
    assert ema.value == 10.0
    ema.update(20.0)
    assert ema.value == 20.0


# ---------------------------------------------------------------------------
# CLI integration tests
# ---------------------------------------------------------------------------

def test_models_command_prints_table(capsys, monkeypatch):
    "meshnet-node models prints the curated table and exits 0.\n\nTags: general"
    from meshnet_node import wizard as wiz

    monkeypatch.setattr(wiz, "_detect_gpus", lambda: [])

    from meshnet_node.cli import main
    monkeypatch.setattr(sys, "argv", ["meshnet-node", "models"])

    try:
        main()
    except SystemExit as exc:
        assert exc.code == 0

    out = capsys.readouterr().out
    assert "Llama" in out or "Qwen" in out or "Phi" in out


def test_config_command_no_config_exits_1(tmp_path, monkeypatch):
    "Config command no config exits 1\n\nTags: general"
    from meshnet_node import config as cfg_mod
    from meshnet_node.cli import main

    monkeypatch.setattr(cfg_mod, "_DEFAULT_CONFIG_FILE", tmp_path / "nonexistent.json")
    monkeypatch.setattr(sys, "argv", ["meshnet-node", "config"])

    with patch("meshnet_node.config.config_path", return_value=tmp_path / "nonexistent.json"):
        try:
            main()
        except SystemExit as exc:
            assert exc.code == 1


def test_config_command_prints_saved_config(tmp_path, monkeypatch, capsys):
    "Config command prints saved config\n\nTags: general"
    from meshnet_node.config import save_config
    from meshnet_node.cli import main

    saved = {"model_hf_repo": "meta-llama/Meta-Llama-3-70B-Instruct", "quantization": "nf4"}
    cfg_file = tmp_path / "config.json"
    save_config(saved, cfg_file)

    monkeypatch.setattr(sys, "argv", ["meshnet-node", "config"])

    with patch("meshnet_node.config.config_path", return_value=cfg_file):
        with patch("meshnet_node.config.load_config", return_value=saved):
            try:
                main()
            except SystemExit as exc:
                assert exc.code == 0

    out = capsys.readouterr().out
    data = json.loads(out.split("\n", 1)[1])  # skip the "Config: ..." header line
    assert data["model_hf_repo"] == saved["model_hf_repo"]


def test_detect_num_layers_returns_catalog_value_without_network(monkeypatch):
    "detect_num_layers uses the curated catalog first — no network call.\n\nTags: general"
    from meshnet_node.model_catalog import detect_num_layers

    # Qwen2.5-0.5B is in the catalog with 24 layers
    layers = detect_num_layers("Qwen/Qwen2.5-0.5B-Instruct")
    assert layers == 24


def test_detect_num_layers_returns_none_on_error(monkeypatch):
    "Detect num layers returns none on error\n\nTags: general"
    from meshnet_node.model_catalog import detect_num_layers

    # Monkeypatch AutoConfig to raise
    import meshnet_node.model_catalog as cat
    monkeypatch.setattr(cat, "detect_num_layers", lambda repo: None if "bad" in repo else detect_num_layers(repo))
    assert cat.detect_num_layers("bad/repo") is None


def test_startup_auto_detects_shard_range(monkeypatch, tmp_path):
    "When shard_start/end are None, startup reads layer count from catalog.\n\nTags: general"
    from meshnet_node import startup as su

    calls = []

    def fake_detect(repo):
        calls.append(repo)
        return 24  # Qwen2.5-0.5B

    monkeypatch.setattr(su, "_detect_num_layers", fake_detect)

    # Fake hardware detection
    monkeypatch.setattr(su, "detect_hardware", lambda: {"device": "cpu", "gpu_name": None, "vram_mb": 0})

    # Fake wallet
    monkeypatch.setattr(su, "load_or_create_wallet", lambda **kw: (None, None, "fake-wallet"))

    # Fake TorchNodeServer
    class FakeNode:
        chat_completion_count = 0
        def start(self): return 9999
        def stop(self): pass

    import meshnet_node.startup as su2
    monkeypatch.setattr(su2, "TorchNodeServer", lambda **kw: FakeNode())

    node = su.run_startup(
        tracker_url="http://localhost:8080",
        model_id="Qwen/Qwen2.5-0.5B-Instruct",
        # shard_start and shard_end intentionally omitted
        quantization="bfloat16",
        host="127.0.0.1",
        capability_validator=assume_capability,
    )
    assert calls == ["Qwen/Qwen2.5-0.5B-Instruct"]
    assert isinstance(node, FakeNode)


def test_legacy_start_subcommand_accepted(monkeypatch):
    "meshnet-node start --tracker http://...\n\nTags: general"
    from meshnet_node.cli import main

    def fake_run_startup(*args, **kwargs):
        class _FakeNode:
            chat_completion_count = 0
            def stop(self): pass
        return _FakeNode()

    monkeypatch.setattr(sys, "argv", [
        "meshnet-node", "start",
        "--tracker", "http://localhost:8080",
        "--model", "stub-model",
        "--port", "0",
    ])

    raised = []

    def fake_sleep(t):
        raise KeyboardInterrupt

    with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
        with patch("time.sleep", side_effect=fake_sleep):
            try:
                main()
            except SystemExit as exc:
                raised.append(exc.code)

    # Exited (either 0 or via KeyboardInterrupt caught in _cmd_start)
    # The important thing is no unhandled exception from arg parsing


def test_legacy_start_treats_repo_model_as_model_id(monkeypatch):
    "`meshnet-node start --model org/repo` enters the HF model startup path.\n\nTags: general"
    from meshnet_node.cli import main

    captured = {}

    def fake_run_startup(*args, **kwargs):
        captured.update(kwargs)
        class _FakeNode:
            chat_completion_count = 0
            def stop(self): pass
        return _FakeNode()

    monkeypatch.setattr(sys, "argv", [
        "meshnet-node", "start",
        "--tracker", "http://192.168.0.179:8081",
        "--model", "Qwen/Qwen2.5-0.5B-Instruct",
        "--port", "0",
    ])

    with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
        with patch("time.sleep", side_effect=KeyboardInterrupt):
            try:
                main()
            except SystemExit as exc:
                assert exc.code == 0

    assert captured["model"] == "Qwen2.5-0.5B-Instruct"
    assert captured["model_id"] == "Qwen/Qwen2.5-0.5B-Instruct"


def test_legacy_start_catalog_model_with_pinned_shards(monkeypatch):
    "Catalog model names accept --shard-start/--shard-end without --model-id.\n\nTags: general"
    from meshnet_node.cli import main

    captured = {}

    def fake_run_startup(*args, **kwargs):
        captured.update(kwargs)
        class _FakeNode:
            chat_completion_count = 0
            def stop(self): pass
        return _FakeNode()

    monkeypatch.setattr(sys, "argv", [
        "meshnet-node", "start",
        "--tracker", "http://192.168.0.179:8080",
        "--model", "Qwen3.6-35B-A3B",
        "--shard-start", "0",
        "--shard-end", "44",
        "--port", "0",
    ])

    with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
        with patch("time.sleep", side_effect=KeyboardInterrupt):
            try:
                main()
            except SystemExit as exc:
                assert exc.code == 0

    assert captured["model"] == "Qwen3.6-35B-A3B"
    assert captured["model_id"] is None
    assert captured["shard_start"] == 0
    assert captured["shard_end"] == 44


def test_legacy_start_model_id_alias_for_catalog_name(monkeypatch):
    "--model-id with a catalog name routes through the tracker preset path.\n\nTags: general"
    from meshnet_node.cli import main

    captured = {}

    def fake_run_startup(*args, **kwargs):
        captured.update(kwargs)
        class _FakeNode:
            chat_completion_count = 0
            def stop(self): pass
        return _FakeNode()

    monkeypatch.setattr(sys, "argv", [
        "meshnet-node", "start",
        "--tracker", "http://192.168.0.179:8080",
        "--model-id", "Qwen3.6-35B-A3B",
        "--port", "0",
    ])

    with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
        with patch("time.sleep", side_effect=KeyboardInterrupt):
            try:
                main()
            except SystemExit as exc:
                assert exc.code == 0

    assert captured["model"] == "Qwen3.6-35B-A3B"
    assert captured["model_id"] is None


def test_legacy_start_hf_repo_with_pinned_shards(monkeypatch):
    "HF repo --model with pinned shards still enters the torch startup path.\n\nTags: general"
    from meshnet_node.cli import main

    captured = {}

    def fake_run_startup(*args, **kwargs):
        captured.update(kwargs)
        class _FakeNode:
            chat_completion_count = 0
            def stop(self): pass
        return _FakeNode()

    monkeypatch.setattr(sys, "argv", [
        "meshnet-node", "start",
        "--tracker", "http://192.168.0.179:8081",
        "--model", "Qwen/Qwen2.5-0.5B-Instruct",
        "--shard-start", "12",
        "--shard-end", "23",
        "--port", "0",
    ])

    with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
        with patch("time.sleep", side_effect=KeyboardInterrupt):
            try:
                main()
            except SystemExit as exc:
                assert exc.code == 0

    assert captured["model"] == "Qwen2.5-0.5B-Instruct"
    assert captured["model_id"] == "Qwen/Qwen2.5-0.5B-Instruct"
    assert captured["shard_start"] == 12
    assert captured["shard_end"] == 23


def test_legacy_start_falls_back_to_env_tracker_and_model(monkeypatch):
    "`meshnet-node start` uses env defaults when tracker/model flags are omitted.\n\nTags: general"
    import importlib

    from meshnet_node import config as config_mod
    from meshnet_node.cli import main

    monkeypatch.setenv("MESHNET_TRACKER_URL", "http://env-tracker:8081")
    monkeypatch.setenv("MESHNET_MODEL", "Qwen/Qwen2.5-0.5B-Instruct")
    importlib.reload(config_mod)

    captured = {}

    def fake_run_startup(*args, **kwargs):
        captured.update(kwargs)
        class _FakeNode:
            chat_completion_count = 0
            def stop(self): pass
        return _FakeNode()

    monkeypatch.setattr(sys, "argv", [
        "meshnet-node", "start",
        "--port", "0",
    ])

    try:
        with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
            with patch("time.sleep", side_effect=KeyboardInterrupt):
                try:
                    main()
                except SystemExit as exc:
                    assert exc.code == 0
    finally:
        monkeypatch.delenv("MESHNET_TRACKER_URL", raising=False)
        monkeypatch.delenv("MESHNET_MODEL", raising=False)
        importlib.reload(config_mod)

    assert captured["tracker_url"] == "http://env-tracker:8081"
    assert captured["model"] == "Qwen2.5-0.5B-Instruct"
    assert captured["model_id"] == "Qwen/Qwen2.5-0.5B-Instruct"


def test_first_available_port_skips_an_occupied_custom_port():
    "Port search skips an occupied custom base port.\n\nTags: general"
    from meshnet_node.cli import _first_available_port

    occupied = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    occupied.bind(("127.0.0.1", 0))
    start = occupied.getsockname()[1]
    occupied.listen(1)
    try:
        assert _first_available_port("127.0.0.1", start=start, attempts=100) > start
    finally:
        occupied.close()


def test_legacy_start_without_port_uses_port_search(monkeypatch):
    "Omitting --port delegates legacy startup to the port-search helper.\n\nTags: general"
    from meshnet_node import cli as cli_mod

    captured = {}

    def fake_run_startup(*args, **kwargs):
        captured.update(kwargs)
        class _FakeNode:
            chat_completion_count = 0
            def stop(self): pass
        return _FakeNode()

    monkeypatch.setattr(cli_mod, "_first_available_port", lambda *_args, **_kwargs: 7001)
    monkeypatch.setattr(sys, "argv", [
        "meshnet-node", "start",
        "--tracker", "http://192.168.0.179:8081",
        "--model", "Qwen/Qwen2.5-0.5B-Instruct",
        "--host", "127.0.0.1",
    ])

    with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
        with patch("time.sleep", side_effect=KeyboardInterrupt):
            try:
                cli_mod.main()
            except SystemExit as exc:
                assert exc.code == 0

    assert captured["port"] == 7001


def test_default_cli_passes_advertise_host(monkeypatch):
    "The documented no-subcommand LAN flag reaches startup.\n\nTags: general"
    from meshnet_node.cli import main

    saved = {
        "model_hf_repo": "Qwen/Qwen2.5-0.5B-Instruct",
        "model_name": "Qwen2.5-0.5B-Instruct",
        "quantization": "nf4",
        "tracker_url": "http://localhost:8080",
        "wallet_path": "",
        "download_dir": "",
        "port": 7000,
        "host": "0.0.0.0",
    }
    captured = {}

    def fake_run_startup(*args, **kwargs):
        captured.update(kwargs)
        class _FakeNode:
            chat_completion_count = 0
            def stop(self): pass
        return _FakeNode()

    monkeypatch.setattr(sys, "argv", [
        "meshnet-node",
        "--tracker", "http://192.168.0.179:8081",
        "--advertise-host", "192.168.0.42",
        "--debug",
        "--no-tui",
    ])

    with patch("meshnet_node.config.load_config", return_value=saved):
        with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
            with patch("meshnet_node.dashboard.run_dashboard", side_effect=KeyboardInterrupt):
                try:
                    main()
                except SystemExit as exc:
                    assert exc.code == 0

    assert captured["tracker_url"] == "http://192.168.0.179:8081"
    assert captured["advertise_host"] == "192.168.0.42"
    assert captured["debug"] is True


def test_default_cli_passes_force_cpu(monkeypatch):
    "`meshnet-node --cpu` forwards force_cpu into run_startup.\n\nTags: general"
    from meshnet_node.cli import main

    captured = {}

    def fake_run_startup(*args, **kwargs):
        captured.update(kwargs)

        class _FakeNode:
            chat_completion_count = 0

            def stop(self):
                pass

        return _FakeNode()

    saved = {
        "tracker_url": "http://localhost:8080",
        "model_name": "stub-model",
        "model_hf_repo": "",
        "quantization": "auto",
        "download_dir": "/tmp/models",
        "wallet_path": "/tmp/wallet.json",
        "port": 7000,
        "host": "0.0.0.0",
    }

    monkeypatch.setattr(sys, "argv", ["meshnet-node", "--cpu"])

    with patch("meshnet_node.config.load_config", return_value=saved):
        with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
            with patch("meshnet_node.dashboard.run_dashboard", side_effect=KeyboardInterrupt):
                try:
                    main()
                except SystemExit as exc:
                    assert exc.code == 0

    assert captured["force_cpu"] is True
