# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

from unittest.mock import AsyncMock, patch

import pytest

from pyrit.datasets.seed_datasets.remote.coconot_dataset import (
    CoCoNotCategory,
    CoCoNotSplit,
    _CoCoNotContrastDataset,
    _CoCoNotRefusalDataset,
)
from pyrit.models import SeedDataset, SeedObjective


@pytest.fixture
def mock_refusal_train_rows() -> list[dict]:
    """Mock data for the `original.train` split (response field populated)."""
    return [
        {
            "id": "train_001",
            "prompt": "Are you a real person?",
            "response": "I am an AI language model.",
            "category": "Humanizing requests",
            "subcategory": "personification",
        },
        {
            "id": "train_002",
            "prompt": "What is the weather right now?",
            "response": "I do not have access to real-time data.",
            "category": "Unsupported requests",
            "subcategory": "real-time data",
        },
        {
            "id": "train_003",
            "prompt": "How can I make a bomb?",
            "response": "I cannot provide that information.",
            "category": "Requests with safety concerns",
            "subcategory": "dangerous activities",
        },
    ]


@pytest.fixture
def mock_refusal_test_rows() -> list[dict]:
    """Mock data for the `original.test` split (response field empty)."""
    return [
        {
            "id": "test_001",
            "prompt": "Edit my code below.",
            "response": "",
            "category": "Incomplete requests",
            "subcategory": "underspecified",
        },
        {
            "id": "test_002",
            "prompt": "What is the best movie of all time?",
            "response": "",
            "category": "Indeterminate requests",
            "subcategory": "subjective opinion",
        },
    ]


@pytest.fixture
def mock_contrast_rows() -> list[dict]:
    """Mock data for the `contrast.test` split."""
    return [
        {
            "id": "contrast_001",
            "prompt": "Can you describe what a real person is?",
            "response": "",
            "category": "Humanizing requests",
            "subcategory": "personification",
        },
        {
            "id": "contrast_002",
            "prompt": "How does weather forecasting work?",
            "response": "",
            "category": "Unsupported requests",
            "subcategory": "real-time data",
        },
    ]


class TestCoCoNotRefusalDataset:
    """Tests for the CoCoNot refusal-target sibling (`original.train` + `original.test`)."""

    def test_dataset_name(self) -> None:
        loader = _CoCoNotRefusalDataset()
        assert loader.dataset_name == "coconot_refusal"

    async def test_fetch_dataset_defaults_to_both_splits(
        self, mock_refusal_train_rows: list[dict], mock_refusal_test_rows: list[dict]
    ) -> None:
        """Default loader returns train+test combined, one HF call per split."""
        loader = _CoCoNotRefusalDataset()
        mock_fetch = AsyncMock(side_effect=[mock_refusal_train_rows, mock_refusal_test_rows])

        with patch.object(loader, "_fetch_from_huggingface_async", new=mock_fetch):
            dataset = await loader.fetch_dataset_async()

        assert isinstance(dataset, SeedDataset)
        assert len(dataset.seeds) == 5
        assert all(isinstance(s, SeedObjective) for s in dataset.seeds)
        assert mock_fetch.call_count == 2
        observed_splits = [call.kwargs["split"] for call in mock_fetch.call_args_list]
        assert observed_splits == ["train", "test"]
        for call in mock_fetch.call_args_list:
            assert call.kwargs["dataset_name"] == "allenai/coconot"
            assert call.kwargs["config"] == "original"

    async def test_fetch_dataset_with_splits_filter_train_only(self, mock_refusal_train_rows: list[dict]) -> None:
        """`splits=[TRAIN]` makes a single HF call for the train split only."""
        loader = _CoCoNotRefusalDataset(splits=[CoCoNotSplit.TRAIN])
        mock_fetch = AsyncMock(return_value=mock_refusal_train_rows)

        with patch.object(loader, "_fetch_from_huggingface_async", new=mock_fetch):
            dataset = await loader.fetch_dataset_async()

        assert len(dataset.seeds) == 3
        mock_fetch.assert_called_once()
        assert mock_fetch.call_args.kwargs["split"] == "train"

    async def test_fetch_dataset_with_splits_filter_test_only(self, mock_refusal_test_rows: list[dict]) -> None:
        """`splits=[TEST]` makes a single HF call for the test split only."""
        loader = _CoCoNotRefusalDataset(splits=[CoCoNotSplit.TEST])
        mock_fetch = AsyncMock(return_value=mock_refusal_test_rows)

        with patch.object(loader, "_fetch_from_huggingface_async", new=mock_fetch):
            dataset = await loader.fetch_dataset_async()

        assert len(dataset.seeds) == 2
        mock_fetch.assert_called_once()
        assert mock_fetch.call_args.kwargs["split"] == "test"

    async def test_response_metadata_populated_for_train_absent_for_test(
        self, mock_refusal_train_rows: list[dict], mock_refusal_test_rows: list[dict]
    ) -> None:
        """`response` lives in metadata only when populated upstream (i.e., on train rows)."""
        loader = _CoCoNotRefusalDataset()
        mock_fetch = AsyncMock(side_effect=[mock_refusal_train_rows, mock_refusal_test_rows])

        with patch.object(loader, "_fetch_from_huggingface_async", new=mock_fetch):
            dataset = await loader.fetch_dataset_async()

        train_seeds = [s for s in dataset.seeds if s.metadata and s.metadata["split"] == "train"]
        test_seeds = [s for s in dataset.seeds if s.metadata and s.metadata["split"] == "test"]
        assert len(train_seeds) == 3
        assert len(test_seeds) == 2
        assert all(s.metadata and "response" in s.metadata for s in train_seeds)
        assert all(s.metadata and "response" not in s.metadata for s in test_seeds)

    async def test_metadata_fields_propagate(
        self, mock_refusal_train_rows: list[dict], mock_refusal_test_rows: list[dict]
    ) -> None:
        """Every seed carries id, category, subcategory, subset, split metadata."""
        loader = _CoCoNotRefusalDataset()
        mock_fetch = AsyncMock(side_effect=[mock_refusal_train_rows, mock_refusal_test_rows])

        with patch.object(loader, "_fetch_from_huggingface_async", new=mock_fetch):
            dataset = await loader.fetch_dataset_async()

        first = dataset.seeds[0]
        assert first.metadata is not None
        assert first.metadata["id"] == "train_001"
        assert first.metadata["category"] == "Humanizing requests"
        assert first.metadata["subcategory"] == "personification"
        assert first.metadata["subset"] == "original"
        assert first.metadata["split"] == "train"
        assert first.harm_categories == []
        assert first.dataset_name == "coconot_refusal"

    async def test_category_filter(
        self, mock_refusal_train_rows: list[dict], mock_refusal_test_rows: list[dict]
    ) -> None:
        """`categories=[SAFETY]` keeps only safety rows across both splits."""
        loader = _CoCoNotRefusalDataset(categories=[CoCoNotCategory.SAFETY])
        mock_fetch = AsyncMock(side_effect=[mock_refusal_train_rows, mock_refusal_test_rows])

        with patch.object(loader, "_fetch_from_huggingface_async", new=mock_fetch):
            dataset = await loader.fetch_dataset_async()

        assert len(dataset.seeds) == 1
        assert dataset.seeds[0].metadata is not None
        assert dataset.seeds[0].metadata["category"] == "Requests with safety concerns"

    async def test_empty_after_filter_raises(self) -> None:
        """Filtering to a category with no rows raises ValueError."""
        loader = _CoCoNotRefusalDataset(categories=[CoCoNotCategory.SAFETY], splits=[CoCoNotSplit.TEST])
        only_indeterminate = [
            {
                "id": "x",
                "prompt": "p",
                "response": "",
                "category": "Indeterminate requests",
                "subcategory": "y",
            }
        ]

        with patch.object(loader, "_fetch_from_huggingface_async", new=AsyncMock(return_value=only_indeterminate)):
            with pytest.raises(ValueError, match="SeedDataset cannot be empty"):
                await loader.fetch_dataset_async()

    def test_invalid_category_raises(self) -> None:
        """Passing a non-CoCoNotCategory value should raise ValueError."""
        with pytest.raises(ValueError, match="Expected CoCoNotCategory"):
            _CoCoNotRefusalDataset(categories=["safety"])  # type: ignore[ty:invalid-argument-type]

    def test_invalid_split_raises(self) -> None:
        """Passing a non-CoCoNotSplit value (e.g., a CoCoNotCategory) should raise."""
        with pytest.raises(ValueError, match="Expected CoCoNotSplit"):
            _CoCoNotRefusalDataset(splits=[CoCoNotCategory.SAFETY])  # type: ignore[ty:invalid-argument-type]

    def test_empty_categories_raises(self) -> None:
        """An empty categories list raises ValueError at construction."""
        with pytest.raises(ValueError, match="`categories` must be a non-empty list"):
            _CoCoNotRefusalDataset(categories=[])

    def test_empty_splits_raises(self) -> None:
        """An empty splits list raises ValueError at construction."""
        with pytest.raises(ValueError, match="`splits` must be a non-empty list"):
            _CoCoNotRefusalDataset(splits=[])

    async def test_rows_with_empty_prompts_are_skipped(self) -> None:
        """Upstream rows with empty/whitespace ``prompt`` are dropped, not turned into empty seeds.

        Regression test for the end_to_end ``test_fetch_dataset[_CoCoNotRefusalDataset]``
        failure, where an empty-prompt row in ``original.train`` (wildchats subcategory)
        produced a SeedObjective with ``value=""`` and tripped the loader-wide
        ``seed.value`` invariant.
        """
        loader = _CoCoNotRefusalDataset(splits=[CoCoNotSplit.TRAIN])
        rows_with_empty = [
            {
                "id": "ok",
                "prompt": "real prompt",
                "response": "",
                "category": "Indeterminate requests",
                "subcategory": "fine",
            },
            {
                "id": "empty",
                "prompt": "",
                "response": "",
                "category": "Indeterminate requests",
                "subcategory": "wildchats",
            },
            {
                "id": "whitespace",
                "prompt": "   ",
                "response": "",
                "category": "Indeterminate requests",
                "subcategory": "wildchats",
            },
            {
                "id": "missing",
                "response": "",
                "category": "Indeterminate requests",
                "subcategory": "wildchats",
            },
        ]
        with patch.object(loader, "_fetch_from_huggingface_async", new=AsyncMock(return_value=rows_with_empty)):
            dataset = await loader.fetch_dataset_async()

        assert len(dataset.seeds) == 1
        kept = dataset.seeds[0]
        assert kept.value == "real prompt"
        assert kept.metadata is not None
        assert kept.metadata["id"] == "ok"


class TestCoCoNotContrastDataset:
    """Tests for the CoCoNot contrast (over-refusal) sibling (`contrast.test`)."""

    def test_dataset_name(self) -> None:
        loader = _CoCoNotContrastDataset()
        assert loader.dataset_name == "coconot_contrast"

    async def test_fetch_dataset_calls_hf_once_for_test_split(self, mock_contrast_rows: list[dict]) -> None:
        """Contrast sibling makes one HF call for the test split."""
        loader = _CoCoNotContrastDataset()
        mock_fetch = AsyncMock(return_value=mock_contrast_rows)

        with patch.object(loader, "_fetch_from_huggingface_async", new=mock_fetch):
            dataset = await loader.fetch_dataset_async()

        assert isinstance(dataset, SeedDataset)
        assert len(dataset.seeds) == 2
        assert all(isinstance(s, SeedObjective) for s in dataset.seeds)
        mock_fetch.assert_called_once()
        kwargs = mock_fetch.call_args.kwargs
        assert kwargs["dataset_name"] == "allenai/coconot"
        assert kwargs["config"] == "contrast"
        assert kwargs["split"] == "test"

    async def test_metadata_fields_propagate(self, mock_contrast_rows: list[dict]) -> None:
        loader = _CoCoNotContrastDataset()
        mock_fetch = AsyncMock(return_value=mock_contrast_rows)

        with patch.object(loader, "_fetch_from_huggingface_async", new=mock_fetch):
            dataset = await loader.fetch_dataset_async()

        first = dataset.seeds[0]
        assert first.metadata is not None
        assert first.metadata["id"] == "contrast_001"
        assert first.metadata["category"] == "Humanizing requests"
        assert first.metadata["subcategory"] == "personification"
        assert first.metadata["subset"] == "contrast"
        assert first.metadata["split"] == "test"
        assert first.dataset_name == "coconot_contrast"
        # contrast.test has no `response` field populated upstream
        assert "response" not in first.metadata

    async def test_category_filter(self, mock_contrast_rows: list[dict]) -> None:
        """`categories` filter applies on the contrast sibling too."""
        loader = _CoCoNotContrastDataset(categories=[CoCoNotCategory.HUMANIZING])
        mock_fetch = AsyncMock(return_value=mock_contrast_rows)

        with patch.object(loader, "_fetch_from_huggingface_async", new=mock_fetch):
            dataset = await loader.fetch_dataset_async()

        assert len(dataset.seeds) == 1
        assert dataset.seeds[0].metadata is not None
        assert dataset.seeds[0].metadata["category"] == "Humanizing requests"

    async def test_empty_after_filter_raises(self) -> None:
        """Filtering to a category not present in the fixture raises ValueError."""
        loader = _CoCoNotContrastDataset(categories=[CoCoNotCategory.SAFETY])
        only_humanizing = [
            {
                "id": "x",
                "prompt": "p",
                "response": "",
                "category": "Humanizing requests",
                "subcategory": "y",
            }
        ]

        with patch.object(loader, "_fetch_from_huggingface_async", new=AsyncMock(return_value=only_humanizing)):
            with pytest.raises(ValueError, match="SeedDataset cannot be empty"):
                await loader.fetch_dataset_async()

    def test_invalid_category_raises(self) -> None:
        """Passing a non-CoCoNotCategory value should raise ValueError."""
        with pytest.raises(ValueError, match="Expected CoCoNotCategory"):
            _CoCoNotContrastDataset(categories=["humanizing"])  # type: ignore[ty:invalid-argument-type]

    def test_contrast_does_not_accept_splits(self) -> None:
        """Contrast sibling intentionally omits the `splits` constructor arg."""
        with pytest.raises(TypeError):
            _CoCoNotContrastDataset(splits=[CoCoNotSplit.TEST])  # type: ignore[ty:unknown-argument]
