"""配布用の参照実装（`deploy/color_model.py`）のテスト.

仕様（実装サマリーの設計を仕様として扱う）:

- NumPy と標準ライブラリだけに依存する（本パッケージの他モジュール・sklearn・torch・pydantic・
  Pillow 等を import しない．TIASjudge にこのファイルだけをコピーして使うため）
- `downscale_nearest` は Pillow の `Image.resize(..., NEAREST)` による縮小と画素単位で完全一致する
- `extract_features` は学習側の `extract_color_features(downscale_nearest(...))` と完全一致する
- `predict_proba` は学習側の sklearn パイプラインの `predict_proba` と差 1e-12 以内
- JSON に書いて読み直しても予測は変わらない
- 形状・dtype の不正，舌領域が 0 画素，未対応の版・種類・キー欠落・形状不一致は ValueError
"""

from __future__ import annotations

import ast
import json
from pathlib import Path

import numpy as np
import pytest
from PIL import Image

from tias_judge_model.data.classes import CLASS_DISPLAY_NAMES, CLASS_SLUGS
from tias_judge_model.data.color_features import (
    NUM_COLOR_FEATURES,
    color_feature_names,
    extract_color_features,
)
from tias_judge_model.data.color_features import downscale_nearest as train_downscale
from tias_judge_model.deploy import color_model as deploy
from tias_judge_model.schema import ExperimentConfig
from tias_judge_model.training.color_cross_validation import fit_classifier, make_classifier
from tias_judge_model.training.color_export import (
    ColorExportResult,
    CSelection,
    build_color_model_document,
)

DEPLOY_PATH = Path(deploy.__file__)
ALLOWED_TOP_LEVEL_IMPORTS = {
    "__future__",
    "json",
    "os",
    "collections",
    "dataclasses",
    "pathlib",
    "typing",
    "numpy",
    "math",
}
MAX_SIDE = 48
SEED = 7


# --- 依存の制約 ---


def _imported_modules(path: Path) -> set[str]:
    tree = ast.parse(path.read_text(encoding="utf-8"))
    modules: set[str] = set()
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            modules.update(alias.name for alias in node.names)
        elif isinstance(node, ast.ImportFrom):
            assert node.level == 0, "相対 import をしてはならない（単一ファイルで使うため）"
            assert node.module is not None
            modules.add(node.module)
    return modules


def test_deploy_module_imports_only_numpy_and_stdlib() -> None:
    modules = _imported_modules(DEPLOY_PATH)
    top = {m.split(".")[0] for m in modules}
    forbidden = {"tias_judge_model", "sklearn", "torch", "pydantic", "PIL", "cv2", "scipy"}
    assert not (top & forbidden), top & forbidden
    assert top <= ALLOWED_TOP_LEVEL_IMPORTS, top - ALLOWED_TOP_LEVEL_IMPORTS


def test_deploy_module_has_no_dynamic_imports() -> None:
    src = DEPLOY_PATH.read_text(encoding="utf-8")
    assert "importlib" not in src
    assert "__import__" not in src


# --- テスト用のモデル ---


def _config(max_side: int | None = MAX_SIDE, class_weighting: str = "none") -> ExperimentConfig:
    return ExperimentConfig.model_validate(
        {
            "data": {"root": "unused", "severities": ["strong", "normal"]},
            "split": {"seed": SEED, "n_folds": 5},
            "train": {"class_weighting": class_weighting},
            "color": {
                "classifier": "logistic",
                "max_side": max_side,
                "logistic": {"c_values": [0.5, 2.0], "max_iter": 2000},
            },
        }
    )


def _random_tongue(rng: np.random.Generator, height: int, width: int) -> np.ndarray:
    """楕円の舌領域（ランダムな赤み）＋背景の黒の RGB 画像."""
    yy, xx = np.mgrid[0:height, 0:width]
    inside = ((yy - height / 2) / (height * 0.45)) ** 2 + ((xx - width / 2) / (width * 0.45)) ** 2
    mask = inside <= 1.0
    base = rng.integers(60, 230, size=3)
    img = np.clip(base + rng.normal(0, 25, size=(height, width, 3)), 1, 255).astype(np.uint8)
    img[~mask] = 0
    return img


@pytest.fixture(scope="module")
def trained() -> dict:
    rng = np.random.default_rng(SEED)
    images = [
        _random_tongue(rng, int(rng.integers(30, 90)), int(rng.integers(30, 90))) for _ in range(40)
    ]
    config = _config()
    features = np.stack([extract_color_features(train_downscale(img, MAX_SIDE)) for img in images])
    labels = np.arange(len(images)) % 5
    assert config.color is not None
    pipeline = make_classifier(config.color, {"C": 1.0}, SEED)
    fit_classifier(pipeline, features, labels, None)
    result = ColorExportResult(
        model=pipeline,
        selection=CSelection(
            candidates=[
                {"C": 0.5, "fold_macro_f1": [0.1], "mean_macro_f1": 0.1},
                {"C": 1.0, "fold_macro_f1": [0.2], "mean_macro_f1": 0.2},
            ],
            selected_index=1,
            fold_counts=[{"train": 32, "held_out": 8}],
        ),
        class_counts=(8, 8, 8, 8, 8),
        class_weights=None,
        seed=SEED,
    )
    doc = build_color_model_document(config, result, run_id="RUN", git_commit="abc1234")
    return {"doc": doc, "pipeline": pipeline, "images": images, "rng": rng}


# --- downscale_nearest の一致 ---


@pytest.mark.parametrize(
    ("height", "width", "max_side"),
    [
        (100, 60, 48),
        (60, 100, 48),
        (1000, 999, 512),
        (513, 512, 512),
        (37, 1001, 50),
        (1001, 37, 50),
        (7, 3, 2),
        (3, 7, 1),
        (997, 991, 131),
        (1500, 2000, 512),
        (255, 255, 254),
    ],
)
def test_downscale_matches_pillow_nearest(height: int, width: int, max_side: int) -> None:
    rng = np.random.default_rng(height * 31 + width)
    img = rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8)
    got = deploy.downscale_nearest(img, max_side)
    scale = max_side / max(height, width)
    size = (max(1, round(width * scale)), max(1, round(height * scale)))
    expected = np.asarray(Image.fromarray(img).resize(size, Image.Resampling.NEAREST))
    assert got.shape == expected.shape
    assert max(got.shape[:2]) <= max_side
    np.testing.assert_array_equal(got, expected)
    np.testing.assert_array_equal(got, train_downscale(img, max_side))


def test_downscale_returns_input_when_small_enough() -> None:
    img = np.arange(4 * 5 * 3, dtype=np.uint8).reshape(4, 5, 3)
    np.testing.assert_array_equal(deploy.downscale_nearest(img, 5), img)
    np.testing.assert_array_equal(deploy.downscale_nearest(img, 100), img)


def test_downscale_rejects_non_positive_max_side() -> None:
    img = np.zeros((4, 4, 3), dtype=np.uint8)
    with pytest.raises(ValueError):
        deploy.downscale_nearest(img, 0)


def test_downscale_matches_pillow_over_many_sizes() -> None:
    rng = np.random.default_rng(123)
    for _ in range(200):
        height, width = int(rng.integers(2, 700)), int(rng.integers(2, 700))
        max_side = int(rng.integers(1, max(height, width) + 1))
        img = rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8)
        np.testing.assert_array_equal(
            deploy.downscale_nearest(img, max_side),
            train_downscale(img, max_side),
            err_msg=f"{height}x{width} -> {max_side}",
        )


# --- 特徴量の一致 ---


def test_extract_features_matches_training_side(trained: dict) -> None:
    model = deploy.load_color_model(trained["doc"])
    rng = np.random.default_rng(99)
    for size in [(20, 30), (48, 48), (120, 70), (70, 300), (1, 1)]:
        img = _random_tongue(rng, *size) if min(size) > 1 else np.full((1, 1, 3), 9, np.uint8)
        got = deploy.extract_features(img, model)
        expected = extract_color_features(train_downscale(img, MAX_SIDE))
        np.testing.assert_array_equal(got, expected)
        assert got.dtype == np.float64
        assert got.shape == (NUM_COLOR_FEATURES,)


def test_extract_features_without_downscale_uses_full_image(trained: dict) -> None:
    model = deploy.load_color_model(trained["doc"])
    img = _random_tongue(np.random.default_rng(5), 200, 150)
    np.testing.assert_array_equal(
        deploy.extract_features(img, model, downscale=False), extract_color_features(img)
    )


def test_extract_features_with_null_max_side_does_not_downscale(trained: dict) -> None:
    doc = json.loads(json.dumps(trained["doc"]))
    doc["features"]["max_side"] = None
    model = deploy.load_color_model(doc)
    img = _random_tongue(np.random.default_rng(6), 200, 150)
    np.testing.assert_array_equal(deploy.extract_features(img, model), extract_color_features(img))


def test_extract_features_covers_all_rgb_values(trained: dict) -> None:
    # 256 階調すべてを含む画像（線形化の LUT の全要素）でも学習側と一致する
    model = deploy.load_color_model(trained["doc"])
    rng = np.random.default_rng(11)
    img = np.stack([rng.permutation(256), rng.permutation(256), np.arange(256)], axis=-1).astype(
        np.uint8
    )[None]  # (1, 256, 3)．(0,0,0) の画素を含みうる
    img = np.repeat(img, 3, axis=0)
    np.testing.assert_array_equal(
        deploy.extract_features(img, model, downscale=False), extract_color_features(img)
    )


@pytest.mark.parametrize(
    "img",
    [
        np.zeros((4, 4), dtype=np.uint8),
        np.zeros((4, 4, 4), dtype=np.uint8),
        np.ones((4, 4, 3), dtype=np.float32),
        np.ones((4, 4, 3), dtype=np.int64),
    ],
    ids=["2d", "4ch", "float32", "int64"],
)
def test_extract_features_rejects_bad_input(trained: dict, img: np.ndarray) -> None:
    model = deploy.load_color_model(trained["doc"])
    with pytest.raises(ValueError):
        deploy.extract_features(img, model)


def test_extract_features_rejects_all_background(trained: dict) -> None:
    model = deploy.load_color_model(trained["doc"])
    with pytest.raises(ValueError):
        deploy.extract_features(np.zeros((10, 10, 3), dtype=np.uint8), model)


# --- 予測確率の一致 ---


def test_predict_proba_matches_sklearn(trained: dict) -> None:
    model = deploy.load_color_model(trained["doc"])
    rng = np.random.default_rng(1234)
    for i in range(30):
        img = _random_tongue(rng, int(rng.integers(10, 150)), int(rng.integers(10, 150)))
        got = deploy.predict_proba(img, model)
        feats = extract_color_features(train_downscale(img, MAX_SIDE))
        expected = trained["pipeline"].predict_proba(feats[None])[0]
        assert got.shape == (5,)
        assert np.max(np.abs(got - expected)) <= 1e-12, i
        assert got.sum() == pytest.approx(1.0, abs=1e-12)


def test_predict_proba_from_features_batch_matches_sklearn(trained: dict) -> None:
    model = deploy.load_color_model(trained["doc"])
    rng = np.random.default_rng(3)
    feats = np.stack([extract_color_features(_random_tongue(rng, 40, 40)) for _ in range(10)])
    got = deploy.predict_proba_from_features(feats, model)
    expected = trained["pipeline"].predict_proba(feats)
    assert got.shape == (10, 5)
    assert np.max(np.abs(got - expected)) <= 1e-12


def test_predict_proba_from_features_is_stable_for_extreme_values(trained: dict) -> None:
    model = deploy.load_color_model(trained["doc"])
    feats = np.full(NUM_COLOR_FEATURES, 1e6)
    probs = deploy.predict_proba_from_features(feats, model)
    assert np.all(np.isfinite(probs))
    assert probs.sum() == pytest.approx(1.0)


@pytest.mark.parametrize("shape", [(NUM_COLOR_FEATURES - 1,), (2, 3, NUM_COLOR_FEATURES), (3,)])
def test_predict_proba_from_features_rejects_bad_shape(trained: dict, shape: tuple) -> None:
    model = deploy.load_color_model(trained["doc"])
    with pytest.raises(ValueError):
        deploy.predict_proba_from_features(np.zeros(shape), model)


def test_json_round_trip_keeps_predictions(trained: dict, tmp_path: Path) -> None:
    path = tmp_path / "color_model.json"
    path.write_text(json.dumps(trained["doc"], ensure_ascii=False, indent=2), encoding="utf-8")
    from_file = deploy.load_color_model(path)
    from_str_path = deploy.load_color_model(str(path))
    from_dict = deploy.load_color_model(trained["doc"])
    rng = np.random.default_rng(77)
    for _ in range(10):
        img = _random_tongue(rng, 64, 50)
        p = deploy.predict_proba(img, from_dict)
        np.testing.assert_array_equal(deploy.predict_proba(img, from_file), p)
        np.testing.assert_array_equal(deploy.predict_proba(img, from_str_path), p)


# --- モデルの読み込み ---


def test_loaded_model_exposes_classes_and_features(trained: dict) -> None:
    model = deploy.load_color_model(trained["doc"])
    assert model.num_features == NUM_COLOR_FEATURES
    assert model.num_classes == 5
    assert model.feature_names == color_feature_names()
    assert [c["slug"] for c in model.classes] == list(CLASS_SLUGS)
    assert [c["display_name"] for c in model.classes] == [
        CLASS_DISPLAY_NAMES[s] for s in CLASS_SLUGS
    ]
    assert model.max_side == MAX_SIDE


def _broken(doc: dict, mutate) -> dict:
    d = json.loads(json.dumps(doc))
    mutate(d)
    return d


@pytest.mark.parametrize(
    "mutate",
    [
        lambda d: d.update(format_version=2),
        lambda d: d.update(format_version="1"),
        lambda d: d.update(model_type="color_hist_gb"),
        lambda d: d.pop("scaler"),
        lambda d: d.pop("classes"),
        lambda d: d["features"].pop("lab"),
        lambda d: d["classifier"].update(coef=d["classifier"]["coef"][:4]),
        lambda d: d["classifier"].update(intercept=d["classifier"]["intercept"][:4]),
        lambda d: d["scaler"].update(mean=d["scaler"]["mean"][:-1]),
        lambda d: d["scaler"].update(scale=[float("nan")] * NUM_COLOR_FEATURES),
        lambda d: d["features"].update(names=d["features"]["names"][:-1]),
        lambda d: d["features"].update(num_features=106),
        lambda d: d["features"].update(max_side=0),
        lambda d: d["features"]["ab_hist"].update(a_range=[50.0, 0.0]),
        lambda d: d["features"]["l_hist"].update(range=[20.0]),
        lambda d: d["features"]["lab"].update(srgb_to_xyz=[[1.0, 0.0], [0.0, 1.0]]),
        lambda d: d.update(classes=list(reversed(d["classes"]))),
    ],
    ids=[
        "future_version",
        "string_version",
        "model_type",
        "no_scaler",
        "no_classes",
        "no_lab",
        "coef_rows",
        "intercept_len",
        "mean_len",
        "nan_scale",
        "names_len",
        "num_features",
        "max_side_0",
        "a_range_reversed",
        "l_range_len",
        "matrix_shape",
        "class_order",
    ],
)
def test_load_rejects_invalid_documents(trained: dict, mutate) -> None:
    with pytest.raises(ValueError):
        deploy.load_color_model(_broken(trained["doc"], mutate))


def test_load_rejects_non_object_json(tmp_path: Path) -> None:
    path = tmp_path / "color_model.json"
    path.write_text("[1, 2, 3]", encoding="utf-8")
    with pytest.raises(ValueError):
        deploy.load_color_model(path)
