"""Tests for plot command."""

import argparse
import json
import os
from pathlib import Path
from typing import Any, List, Tuple
from unittest.mock import MagicMock, Mock, mock_open, patch
import pytest

pytest.importorskip("matplotlib")
from con_duct import cli, plot  # noqa: E402
from con_duct._formatter import FILESIZE_UNITS  # noqa: E402


@pytest.mark.parametrize(
    "min_ratio,span_seconds,expected_unit",
    [
        # min_ratio=-1: always use base unit
        (-1, 2, "s"),  # Small value
        (-1, 3700, "s"),  # More than 1 hour - still base unit
        (-1, 3 * 60 * 60 * 24, "s"),  # 3 days - still base unit
        # min_ratio=1.5: switch units more aggressively
        (1.5, 90, "min"),  # 1.5 minutes meets threshold
        (1.5, 90 * 60, "h"),  # 1.5 hours meets threshold
        (1.5, 36 * 60 * 60, "d"),  # 1.5 days meets threshold
        # min_ratio=3.0: standard threshold
        (3.0, 2, "s"),  # 2 seconds - stays in base unit
        (3.0, 3 * 60, "min"),  # 3 minutes - meets min_ratio for minutes
        (3.0, 3 * 60 * 60, "h"),  # 3 hours - meets min_ratio for hours
        (3.0, 3 * 60 * 60 * 24, "d"),  # 3 days - meets min_ratio for days
        # min_ratio=5.0: more conservative switching
        (5.0, 4 * 60, "s"),  # 4 minutes - doesn't meet threshold, stays seconds
        (5.0, 5 * 60, "min"),  # 5 minutes - meets threshold
        (5.0, 4 * 60 * 60, "min"),  # 4 hours - doesn't meet hour threshold
        (5.0, 5 * 60 * 60, "h"),  # 5 hours - meets hour threshold
    ],
)
def test_pick_unit_with_varying_ratios(
    min_ratio: float, span_seconds: float, expected_unit: str
) -> None:
    """Test pick_unit selects appropriate unit based on min_ratio."""
    formatter: Any = plot.HumanizedAxisFormatter(
        min_ratio=min_ratio, units=plot._TIME_UNITS
    )
    unit_name, _ = formatter.pick_unit(span_seconds)
    assert unit_name == expected_unit


@pytest.mark.parametrize(
    "units,axis_range,value,expected",
    [
        # Time formatting tests
        (plot._TIME_UNITS, (0, 30), 15, "15.0s"),
        (plot._TIME_UNITS, (0, 300), 2.3 * 60, "2.3min"),
        (plot._TIME_UNITS, (0, 11000), 7.8 * 60 * 60, "7.8h"),
        (plot._TIME_UNITS, (0, 260000), 3.2 * 60 * 60 * 24, "3.2d"),
        # Memory formatting tests (base 1000, kB/MB/GB/TB/PB).
        (FILESIZE_UNITS, (0, 5 * 1000), 2.6 * 1000, "2.6kB"),
        (FILESIZE_UNITS, (0, 4 * 1000**2), 1.5 * (1000**2), "1.5MB"),
        (FILESIZE_UNITS, (0, 3 * 1000**3), 8.3 * 1000**3, "8.3GB"),
        (FILESIZE_UNITS, (0, 3 * 1000**4), 1.3 * 1000**4, "1.3TB"),
        (FILESIZE_UNITS, (0, 3.1 * 1000**5), 6.5 * 1000**5, "6.5PB"),
    ],
)
def test_formatter_output(
    units: List[Tuple[str, float]],
    axis_range: Tuple[float, float],
    value: float,
    expected: str,
) -> None:
    """Test HumanizedAxisFormatter formats values correctly for time and memory units."""
    formatter: Any = plot.HumanizedAxisFormatter(min_ratio=3.0, units=units)
    formatter.axis = Mock()
    formatter.axis.get_view_interval.return_value = axis_range
    result = formatter(value, 0)
    assert result == expected


class TestPlotMatplotlib:

    @patch("matplotlib.pyplot.savefig")
    def test_matplotlib_plot_sanity(self, mock_plot_save: MagicMock) -> None:
        args = argparse.Namespace(
            command="plot",
            file_path="test/data/mriqc-example/usage.json",
            output="outfile.png",
            func=plot.matplotlib_plot,
            log_level="INFO",
            min_ratio=3.0,
            cpu="ps-pcpu",
        )
        assert cli.execute(args) == 0
        mock_plot_save.assert_called_once_with("outfile.png")

    @patch("matplotlib.pyplot.savefig")
    @patch("matplotlib.use")
    def test_matplotlib_plot_uses_agg_backend_with_output(
        self, mock_use: MagicMock, mock_plot_save: MagicMock
    ) -> None:
        """Test that Agg backend is used when --output is specified."""
        args = argparse.Namespace(
            command="plot",
            file_path="test/data/mriqc-example/usage.json",
            output="outfile.png",
            func=plot.matplotlib_plot,
            log_level="INFO",
            min_ratio=3.0,
            cpu="ps-pcpu",
        )
        assert cli.execute(args) == 0
        mock_use.assert_called_once_with("Agg")
        mock_plot_save.assert_called_once_with("outfile.png")

    @patch("matplotlib.pyplot.savefig")
    def test_matplotlib_plot_file_not_found(self, mock_plot_save: MagicMock) -> None:
        args = argparse.Namespace(
            command="plot",
            file_path="test/data/mriqc-example/usage_not_to_be_found.json",
            output="outfile.png",
            func=plot.matplotlib_plot,
            log_level="INFO",
        )
        assert cli.execute(args) == 1
        mock_plot_save.assert_not_called()

    @patch("matplotlib.pyplot.savefig")
    @patch("builtins.open", new_callable=mock_open, read_data='{"invalid": "json"')
    def test_matplotlib_plot_invalid_json(
        self, _mock_open: MagicMock, mock_plot_save: MagicMock
    ) -> None:
        args = argparse.Namespace(
            command="plot",
            file_path="test/data/mriqc-example/usage.json",
            output="outfile.png",
            func=plot.matplotlib_plot,
            log_level="INFO",
        )
        assert cli.execute(args) == 1
        mock_plot_save.assert_not_called()

    @patch("matplotlib.pyplot.savefig")
    def test_matplotlib_plot_info_json(self, mock_plot_save: MagicMock) -> None:
        """When user passes info.json, usage.json is retrieved and used"""
        args = argparse.Namespace(
            command="plot",
            file_path="test/data/mriqc-example/info.json",
            output="outfile.png",
            func=plot.matplotlib_plot,
            log_level="INFO",
            min_ratio=3.0,
            cpu="ps-pcpu",
        )
        assert cli.execute(args) == 0
        mock_plot_save.assert_called_once_with("outfile.png")

    @patch("matplotlib.pyplot.savefig")
    def test_matplotlib_plot_info_json_absolute_path(
        self, mock_plot_save: MagicMock, monkeypatch: Any, tmp_path: Any
    ) -> None:
        """Test that absolute path to info.json correctly resolves usage.json path
        when cwd is not the original execution wd.
        """
        abs_info_path = os.path.abspath("test/data/mriqc-example/info.json")

        # change into a pytest-managed temporary directory
        monkeypatch.chdir(tmp_path)

        args = argparse.Namespace(
            command="plot",
            file_path=abs_info_path,
            output="outfile.png",
            func=plot.matplotlib_plot,
            log_level="INFO",
            min_ratio=3.0,
            cpu="ps-pcpu",
        )
        assert cli.execute(args) == 0
        mock_plot_save.assert_called_once_with("outfile.png")

    @patch("matplotlib.pyplot.savefig")
    @patch(
        "builtins.open", new_callable=mock_open, read_data='{"missing": "timestamp"}'
    )
    def test_matplotlib_plot_malformed_usage_file(
        self, _mock_open: MagicMock, mock_plot_save: MagicMock
    ) -> None:
        """Test that malformed usage.json files are handled gracefully"""
        args = argparse.Namespace(
            command="plot",
            file_path="test/data/malformed_usage.json",
            output="outfile.png",
            func=plot.matplotlib_plot,
            log_level="INFO",
        )
        assert cli.execute(args) == 1
        mock_plot_save.assert_not_called()

    @patch(
        "matplotlib.get_backend",
        side_effect=AttributeError("get_backend not available"),
    )
    @patch.dict("matplotlib.rcParams", {"backend": "Agg"})
    def test_matplotlib_plot_non_interactive_backend(
        self,
        _mock_get_backend: MagicMock,
    ) -> None:
        """Test that plotting without output in non-interactive backend returns error."""
        import matplotlib.backends

        if not hasattr(matplotlib.backends, "backend_registry"):
            pytest.skip("requires backend_registry (matplotlib >= 3.9)")

        args = argparse.Namespace(
            command="plot",
            file_path="test/data/mriqc-example/usage.json",
            output=None,  # No output file specified
            func=plot.matplotlib_plot,
            log_level="INFO",
            min_ratio=3.0,
            cpu="ps-pcpu",
        )
        result = cli.execute(args)
        assert result == 1

    @patch("matplotlib.get_backend", return_value="Agg")
    def test_matplotlib_plot_non_interactive_backend_with_get_backend(
        self,
        _mock_get_backend: MagicMock,
    ) -> None:
        """Test that plotting without output in non-interactive backend returns error using get_backend."""
        import matplotlib.backends

        if not hasattr(matplotlib.backends, "backend_registry"):
            pytest.skip("requires backend_registry (matplotlib >= 3.9)")

        args = argparse.Namespace(
            command="plot",
            file_path="test/data/mriqc-example/usage.json",
            output=None,  # No output file specified
            func=plot.matplotlib_plot,
            log_level="INFO",
            min_ratio=3.0,
            cpu="ps-pcpu",
        )
        result = cli.execute(args)
        assert result == 1

    @patch("matplotlib.pyplot.show")
    @patch("matplotlib.get_backend", return_value="tkagg")
    def test_matplotlib_plot_interactive_backend_with_get_backend(
        self,
        _mock_get_backend: MagicMock,
        mock_show: MagicMock,
    ) -> None:
        """Test that plotting without output in interactive backend calls plt.show() successfully."""

        args = argparse.Namespace(
            command="plot",
            file_path="test/data/mriqc-example/usage.json",
            output=None,  # No output file specified
            func=plot.matplotlib_plot,
            log_level="INFO",
            min_ratio=3.0,
            cpu="ps-pcpu",
        )
        result = cli.execute(args)
        assert result == 0
        mock_show.assert_called_once()

    @patch(
        "builtins.__import__", side_effect=ImportError("No module named 'matplotlib'")
    )
    def test_matplotlib_plot_missing_dependency(self, _mock_import: MagicMock) -> None:
        """Test that plotting with missing matplotlib shows helpful error."""
        args = argparse.Namespace(
            command="plot",
            file_path="test/data/mriqc-example/usage.json",
            output=None,
            func=plot.matplotlib_plot,
            log_level="INFO",
        )

        result = cli.execute(args)
        assert result == 1

    @patch("matplotlib.pyplot.show")
    def test_matplotlib_plot_no_backend_registry(
        self,
        mock_show: MagicMock,
        monkeypatch: Any,
        caplog: Any,
    ) -> None:
        """Test fallback when backend_registry unavailable (matplotlib < 3.9)."""
        import matplotlib.backends

        # Only delete if present (already absent on matplotlib < 3.9)
        if hasattr(matplotlib.backends, "backend_registry"):
            monkeypatch.delattr(matplotlib.backends, "backend_registry")

        args = argparse.Namespace(
            command="plot",
            file_path="test/data/mriqc-example/usage.json",
            output=None,
            func=plot.matplotlib_plot,
            log_level="INFO",
            min_ratio=3.0,
            cpu="ps-pcpu",
        )
        result = cli.execute(args)
        assert result == 0
        mock_show.assert_called_once()
        assert "matplotlib < 3.9" in caplog.text


class TestBuildPidSeriesCpuMode:
    """``cpu`` series content varies by ``cpu_mode``."""

    @staticmethod
    def _record(ts: str, pid: str, pcpu: float, etime: str) -> dict:
        return {
            "timestamp": ts,
            "processes": {
                pid: {
                    "pcpu": pcpu,
                    "pmem": 0.0,
                    "rss": 0,
                    "etime": etime,
                    "timestamp": ts,
                    "cmd": "x",
                }
            },
        }

    def test_ps_pcpu_takes_raw_values(self) -> None:
        data = [
            self._record("2026-05-06T00:00:00", "1", 1.5, "00:01"),
            self._record("2026-05-06T00:01:00", "1", 2.5, "01:01"),
            self._record("2026-05-06T00:02:00", "1", 3.5, "02:01"),
        ]
        series = plot._build_pid_series(data, cpu_mode=plot.CPU_MODE_PS_PCPU)
        assert series["1"]["cpu"] == [1.5, 2.5, 3.5]

    def test_ps_pcpu_includes_etime_zero_record(self) -> None:
        # In timepoint mode etime=="00:00" is dropped before delta math; in
        # raw mode every record contributes its pcpu unchanged.
        data = [
            self._record("2026-05-06T00:00:00", "1", 0.0, "00:00"),
            self._record("2026-05-06T00:01:00", "1", 5.0, "01:00"),
        ]
        series = plot._build_pid_series(data, cpu_mode=plot.CPU_MODE_PS_PCPU)
        assert series["1"]["cpu"] == [0.0, 5.0]

    def test_ps_cpu_timepoint_drops_first_and_etime_zero(self) -> None:
        # Baseline: timepoint mode unchanged by the new flag -- first record
        # is None (no prior baseline), etime=="00:00" record is None.
        data = [
            self._record("2026-05-06T00:00:00", "1", 10.0, "01:00"),
            self._record("2026-05-06T00:01:00", "1", 0.0, "00:00"),
            self._record("2026-05-06T00:02:00", "1", 50.0, "03:00"),
        ]
        series = plot._build_pid_series(data, cpu_mode=plot.CPU_MODE_PS_CPU_TIMEPOINT)
        cpu = series["1"]["cpu"]
        assert cpu[0] is None
        assert cpu[1] is None

    def test_default_is_ps_pcpu(self) -> None:
        data = [self._record("2026-05-06T00:00:00", "1", 7.0, "00:30")]
        series = plot._build_pid_series(data)
        assert series["1"]["cpu"] == [7.0]


class TestLoadHostMemoryTotal:
    """Best-effort host memory_total lookup for the rss legend label."""

    def _write_info(self, path: Path, data: dict) -> None:
        path.write_text(json.dumps(data))

    def test_info_json_input(self, tmp_path: Path) -> None:
        info = tmp_path / "run_info.json"
        self._write_info(info, {"system": {"memory_total": 12345}})
        assert plot._load_host_memory_total(info) == 12345

    def test_usage_jsonl_with_sibling(self, tmp_path: Path) -> None:
        info = tmp_path / "run_info.json"
        usage = tmp_path / "run_usage.jsonl"
        self._write_info(info, {"system": {"memory_total": 67890}})
        usage.write_text("")
        assert plot._load_host_memory_total(usage) == 67890

    def test_usage_legacy_with_sibling(self, tmp_path: Path) -> None:
        info = tmp_path / "run_info.json"
        usage = tmp_path / "run_usage.json"
        self._write_info(info, {"system": {"memory_total": 42}})
        usage.write_text("")
        assert plot._load_host_memory_total(usage) == 42

    def test_no_sibling_returns_none(self, tmp_path: Path) -> None:
        usage = tmp_path / "run_usage.jsonl"
        usage.write_text("")
        assert plot._load_host_memory_total(usage) is None

    def test_missing_key_returns_none(self, tmp_path: Path) -> None:
        info = tmp_path / "run_info.json"
        self._write_info(info, {"system": {}})
        assert plot._load_host_memory_total(info) is None

    def test_invalid_json_returns_none(self, tmp_path: Path) -> None:
        info = tmp_path / "run_info.json"
        info.write_text("{not json")
        assert plot._load_host_memory_total(info) is None

    def test_unparseable_filename_returns_none(self, tmp_path: Path) -> None:
        weird = tmp_path / "weird.txt"
        weird.write_text("")
        assert plot._load_host_memory_total(weird) is None
