# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Integration tests: metrics.csv contains all columns used by plot_metrics().

Runs a minimal PTL training loop (1 epoch, 2 batches each) using mocked model internals so no real dataset or GPU is
required.  After training, reads the CSVLogger output and asserts that every metric column that ``plot_metrics()`` needs
is present and has at least one non-NaN value.

Also verifies that ``train/loss`` is logged at the same scale as ``val/loss`` (i.e. NOT divided by ``grad_accum_steps``
before logging).
"""

from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock, patch

import pandas as pd
import torch

from rfdetr.config import RFDETRBaseConfig, TrainConfig
from rfdetr.training import build_trainer
from rfdetr.training.module_data import RFDETRDataModule
from rfdetr.training.module_model import RFDETRModelModule

from .helpers import _fake_postprocess, _FakeCriterion, _FakeDataset, _make_param_dicts, _TinyModel

# ---------------------------------------------------------------------------
# Helpers local to this module
# ---------------------------------------------------------------------------


def _fit_and_read_csv(mc: RFDETRBaseConfig, tc: TrainConfig, criterion=None) -> pd.DataFrame:
    """Run 1 epoch (2 train + 2 val batches) and return the resulting metrics.csv.

    Examples:
        >>> import contextlib
        >>> import io
        >>> from tempfile import TemporaryDirectory
        >>> with TemporaryDirectory() as d:
        ...     output_dir = Path(d) / 'out'
        ...     with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
        ...         metrics = _fit_and_read_csv(
        ...             RFDETRBaseConfig(pretrain_weights=None, device='cpu', num_classes=3),
        ...             TrainConfig(
        ...                 dataset_dir=str(Path(d) / 'ds'),
        ...                 output_dir=str(output_dir),
        ...                 epochs=1,
        ...                 batch_size=2,
        ...                 tensorboard=False,
        ...             ),
        ...         )
        ...     {'train/loss', 'val/loss'}.issubset(metrics.columns)
        True
    """
    fake_criterion = criterion or _FakeCriterion()
    with (
        patch("rfdetr.training.module_model.build_model_from_config", return_value=_TinyModel()),
        patch(
            "rfdetr.training.module_model.build_criterion_from_config",
            return_value=(fake_criterion, MagicMock(side_effect=_fake_postprocess)),
        ),
        patch("rfdetr.training.module_data.build_dataset", return_value=_FakeDataset(length=20)),
        patch(
            "rfdetr.training.module_model.get_param_dict",
            side_effect=lambda args, model: _make_param_dicts(model),
        ),
    ):
        module = RFDETRModelModule(mc, tc)
        datamodule = RFDETRDataModule(mc, tc)
        trainer = build_trainer(
            tc,
            mc,
            accelerator="cpu",
            max_epochs=1,
            limit_train_batches=2,
            limit_val_batches=2,
            log_every_n_steps=1,
        )
        trainer.fit(module, datamodule=datamodule)

    csv_path = Path(tc.output_dir) / "metrics.csv"
    assert csv_path.exists(), "CSVLogger must write metrics.csv to output_dir"
    return pd.read_csv(csv_path)


# ---------------------------------------------------------------------------
# Expected columns (must exist and have ≥1 non-NaN row after one epoch)
# ---------------------------------------------------------------------------

_REQUIRED_DETECTION = frozenset(
    {
        "train/loss",
        "train/lr",
        "val/loss",
        "val/mAP_50",
        "val/mAP_50_95",
        "val/mAR",
    }
)

_REQUIRED_DETECTION_EMA = _REQUIRED_DETECTION | frozenset(
    {
        "val/ema_mAP_50",
        "val/ema_mAP_50_95",
        "val/ema_mAR",
    }
)


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestDetectionMetricsCSV:
    """metrics.csv contains all columns that plot_metrics() needs for detection."""

    def test_base_metrics_present_without_ema(self, base_model_config, base_train_config):
        """Without EMA all core val/* columns must appear in metrics.csv with non-NaN data."""
        mc = base_model_config()
        tc = base_train_config(use_ema=False, run_test=False)
        df = _fit_and_read_csv(mc, tc)

        missing = _REQUIRED_DETECTION - set(df.columns)
        assert not missing, f"Missing columns in metrics.csv: {sorted(missing)}"

        all_nan = {c for c in _REQUIRED_DETECTION if df[c].isna().all()}
        assert not all_nan, f"Columns with all-NaN values: {sorted(all_nan)}"

    def test_ema_metrics_present_with_ema_enabled(self, base_model_config, base_train_config):
        """With use_ema=True the ema_* aliases must also appear in metrics.csv."""
        mc = base_model_config()
        tc = base_train_config(use_ema=True, run_test=False)
        df = _fit_and_read_csv(mc, tc)

        missing = _REQUIRED_DETECTION_EMA - set(df.columns)
        assert not missing, f"Missing EMA columns in metrics.csv: {sorted(missing)}"

        all_nan = {c for c in _REQUIRED_DETECTION_EMA if df[c].isna().all()}
        assert not all_nan, f"EMA columns with all-NaN values: {sorted(all_nan)}"

    def test_train_loss_is_unscaled(self, base_model_config, base_train_config):
        """Train/loss must be logged at the raw criterion scale, not divided by grad_accum_steps.

        With grad_accum_steps=4 the old code divided the logged value by 4, making train/loss ~4× smaller than val/loss.
        After the fix the logged value equals the raw weighted criterion output so both losses are on the same scale.
        """
        fixed_loss_value = 5.0
        grad_accum_steps = 4

        class _FixedCriterion:
            weight_dict = {"loss_ce": 1.0}

            def num_boxes_for_targets(self, outputs, targets):
                dummy = outputs.get("dummy", torch.zeros(1))
                return torch.ones((), dtype=dummy.dtype, device=dummy.device)

            def __call__(self, outputs, targets, num_boxes=None):
                # Loss is always fixed_loss_value, connected to model params for gradient.
                dummy = outputs.get("dummy", torch.zeros(1))
                denominator = self.num_boxes_for_targets(outputs, targets) if num_boxes is None else num_boxes
                return {"loss_ce": (dummy.mean() * 0 + fixed_loss_value) / denominator}

        mc = base_model_config()
        tc = base_train_config(use_ema=False, run_test=False, grad_accum_steps=grad_accum_steps)
        df = _fit_and_read_csv(mc, tc, criterion=_FixedCriterion())

        logged = df["train/loss"].dropna().mean()
        expected_unscaled = fixed_loss_value
        expected_if_divided = fixed_loss_value / grad_accum_steps

        assert abs(logged - expected_unscaled) < abs(logged - expected_if_divided), (
            f"train/loss={logged:.4f} is closer to the grad-accum-divided value "
            f"({expected_if_divided:.4f}) than the raw criterion output "
            f"({expected_unscaled:.4f}). The division must have been removed."
        )
