"""Comprehensive unit tests for datarecipe.comparator module.

Covers DatasetMetrics, ComparisonReport (to_markdown, to_table),
DatasetComparator (compare, compare_by_ids, _build_report,
_analyze_strengths_weaknesses, _determine_best_for, _generate_recommendations).
"""

import unittest
from unittest.mock import patch

from datarecipe.comparator import ComparisonReport, DatasetComparator, DatasetMetrics
from datarecipe.cost_calculator import CostBreakdown, CostEstimate
from datarecipe.quality_metrics import (
    ComplexityMetrics,
    ConsistencyMetrics,
    DiversityMetrics,
    QualityReport,
)
from datarecipe.schema import Recipe, Reproducibility

# ==================== Helper factories ====================


def _make_cost(api=100, human=50, compute=30, total=180):
    """Build a CostBreakdown with given expected values."""
    return CostBreakdown(
        api_cost=CostEstimate(low=api * 0.8, high=api * 1.5, expected=api),
        human_annotation_cost=CostEstimate(low=human * 0.7, high=human * 1.5, expected=human),
        compute_cost=CostEstimate(low=compute * 0.8, high=compute * 1.3, expected=compute),
        total=CostEstimate(low=total * 0.7, high=total * 1.5, expected=total),
        assumptions=["test assumption"],
    )


def _make_quality(overall=75, diversity_utr=0.2, diversity_sd=0.5,
                  consistency_fc=0.9, complexity_vr=0.3):
    """Build a QualityReport with given scores."""
    return QualityReport(
        diversity=DiversityMetrics(
            unique_token_ratio=diversity_utr,
            vocabulary_size=1000,
            semantic_diversity=diversity_sd,
        ),
        consistency=ConsistencyMetrics(
            format_consistency=consistency_fc,
            structure_score=0.8,
            field_completeness=0.95,
            length_variance=0.5,
        ),
        complexity=ComplexityMetrics(
            avg_length=200,
            avg_tokens=50,
            vocabulary_richness=complexity_vr,
            avg_sentence_length=15,
            readability_score=60,
        ),
        overall_score=overall,
        sample_size=100,
    )


def _make_recipe(name="ds", source_id=None, num_examples=10000,
                 repro_score=None, synthetic_ratio=None, teacher_models=None):
    """Build a Recipe with common defaults."""
    repro = Reproducibility(score=repro_score) if repro_score is not None else None
    return Recipe(
        name=name,
        source_id=source_id or name,
        num_examples=num_examples,
        reproducibility=repro,
        synthetic_ratio=synthetic_ratio,
        teacher_models=teacher_models or [],
    )


def _make_metric(dataset_id="ds-a", recipe=None, cost=None, quality=None):
    """Build a DatasetMetrics."""
    if recipe is None:
        recipe = _make_recipe(name=dataset_id)
    return DatasetMetrics(
        dataset_id=dataset_id,
        recipe=recipe,
        cost=cost,
        quality=quality,
    )


# ==================== DatasetMetrics tests ====================


class TestDatasetMetrics(unittest.TestCase):
    """Test DatasetMetrics dataclass."""

    def test_basic_creation(self):
        recipe = _make_recipe()
        m = DatasetMetrics(dataset_id="test", recipe=recipe)
        self.assertEqual(m.dataset_id, "test")
        self.assertIsNone(m.cost)
        self.assertIsNone(m.quality)

    def test_creation_with_all_fields(self):
        recipe = _make_recipe()
        cost = _make_cost()
        quality = _make_quality()
        m = DatasetMetrics(dataset_id="full", recipe=recipe, cost=cost, quality=quality)
        self.assertEqual(m.dataset_id, "full")
        self.assertIsNotNone(m.cost)
        self.assertIsNotNone(m.quality)


# ==================== ComparisonReport.to_markdown tests ====================


class TestComparisonReportToMarkdown(unittest.TestCase):
    """Test ComparisonReport.to_markdown() output."""

    def _report_with_metrics(self, metrics, **kwargs):
        datasets = [m.dataset_id for m in metrics]
        return ComparisonReport(datasets=datasets, metrics=metrics, **kwargs)

    def test_empty_report(self):
        report = ComparisonReport(datasets=[], metrics=[])
        md = report.to_markdown()
        self.assertIn("# Dataset Comparison Report", md)
        self.assertIn("## Overview", md)
        self.assertIn("> Generated by DataRecipe", md)

    def test_overview_table_with_full_data(self):
        recipe = _make_recipe(name="alpha", num_examples=5000, repro_score=8)
        cost = _make_cost(total=200)
        quality = _make_quality(overall=85)
        m = _make_metric("alpha", recipe=recipe, cost=cost, quality=quality)
        report = self._report_with_metrics([m])
        md = report.to_markdown()
        self.assertIn("5,000", md)
        self.assertIn("$200", md)
        self.assertIn("85/100", md)
        self.assertIn("8/10", md)

    def test_overview_table_na_values(self):
        recipe = Recipe(name="bare")
        m = DatasetMetrics(dataset_id="bare", recipe=recipe)
        report = self._report_with_metrics([m])
        md = report.to_markdown()
        # num_examples is None, cost is None, quality is None, repro is None
        self.assertEqual(md.count("N/A"), 4)

    def test_cost_comparison_section(self):
        report = ComparisonReport(
            datasets=["a"],
            cost_comparison={"a": {"api": 100, "human": 50, "compute": 30, "total": 180}},
        )
        md = report.to_markdown()
        self.assertIn("## Cost Comparison", md)
        self.assertIn("$100", md)
        self.assertIn("$50", md)
        self.assertIn("$30", md)
        self.assertIn("$180", md)

    def test_no_cost_comparison_section_when_empty(self):
        report = ComparisonReport(datasets=["a"])
        md = report.to_markdown()
        self.assertNotIn("## Cost Comparison", md)

    def test_quality_comparison_section(self):
        report = ComparisonReport(
            datasets=["a"],
            quality_comparison={
                "a": {"diversity": 0.75, "consistency": 0.90, "complexity": 0.40, "overall": 80}
            },
        )
        md = report.to_markdown()
        self.assertIn("## Quality Comparison", md)
        self.assertIn("0.75", md)
        self.assertIn("0.90", md)
        self.assertIn("0.40", md)
        self.assertIn("80", md)

    def test_no_quality_comparison_when_empty(self):
        report = ComparisonReport(datasets=["a"])
        md = report.to_markdown()
        self.assertNotIn("## Quality Comparison", md)

    def test_strengths_and_weaknesses_section(self):
        report = ComparisonReport(
            datasets=["a"],
            strengths={"a": ["Fast", "Cheap"]},
            weaknesses={"a": ["Small"]},
        )
        md = report.to_markdown()
        self.assertIn("### a", md)
        self.assertIn("**Strengths:**", md)
        self.assertIn("- Fast", md)
        self.assertIn("- Cheap", md)
        self.assertIn("**Weaknesses:**", md)
        self.assertIn("- Small", md)

    def test_no_strengths_weaknesses_when_empty(self):
        report = ComparisonReport(datasets=["a"], strengths={}, weaknesses={})
        md = report.to_markdown()
        self.assertIn("### a", md)
        self.assertNotIn("**Strengths:**", md)
        self.assertNotIn("**Weaknesses:**", md)

    def test_best_for_section(self):
        report = ComparisonReport(
            datasets=["a"],
            best_for={"Largest scale": "a", "Lowest cost": "a"},
        )
        md = report.to_markdown()
        self.assertIn("## Best For", md)
        self.assertIn("Largest scale", md)

    def test_no_best_for_when_empty(self):
        report = ComparisonReport(datasets=["a"])
        md = report.to_markdown()
        self.assertNotIn("## Best For", md)

    def test_recommendations_section(self):
        report = ComparisonReport(
            datasets=["a"],
            recommendations=["Use dataset A", "Consider dataset B"],
        )
        md = report.to_markdown()
        self.assertIn("## Recommendations", md)
        self.assertIn("1. Use dataset A", md)
        self.assertIn("2. Consider dataset B", md)

    def test_no_recommendations_when_empty(self):
        report = ComparisonReport(datasets=["a"])
        md = report.to_markdown()
        self.assertNotIn("## Recommendations", md)

    def test_footer(self):
        report = ComparisonReport(datasets=[])
        md = report.to_markdown()
        self.assertIn("---", md)
        self.assertIn("> Generated by DataRecipe", md)


# ==================== ComparisonReport.to_table tests ====================


class TestComparisonReportToTable(unittest.TestCase):
    """Test ComparisonReport.to_table() ASCII table output."""

    def test_basic_table_structure(self):
        recipe = _make_recipe(name="ds1", num_examples=5000, repro_score=7,
                              synthetic_ratio=0.8)
        cost = _make_cost(total=100)
        quality = _make_quality(overall=70)
        m = _make_metric("ds1", recipe=recipe, cost=cost, quality=quality)
        report = ComparisonReport(datasets=["ds1"], metrics=[m])
        table = report.to_table()
        self.assertIn("Metric", table)
        self.assertIn("ds1", table)
        self.assertIn("Size", table)
        self.assertIn("Est. Cost", table)
        self.assertIn("Quality", table)
        self.assertIn("Reproducibility", table)
        self.assertIn("Synthetic %", table)
        self.assertIn("5,000", table)
        self.assertIn("$100", table)
        self.assertIn("70/100", table)
        self.assertIn("7/10", table)
        self.assertIn("80%", table)

    def test_table_na_values(self):
        recipe = Recipe(name="bare")
        m = DatasetMetrics(dataset_id="bare", recipe=recipe)
        report = ComparisonReport(datasets=["bare"], metrics=[m])
        table = report.to_table()
        self.assertIn("N/A", table)

    def test_table_multiple_datasets(self):
        r1 = _make_recipe(name="ds1", num_examples=1000)
        r2 = _make_recipe(name="ds2", num_examples=2000)
        m1 = _make_metric("ds1", recipe=r1)
        m2 = _make_metric("ds2", recipe=r2)
        report = ComparisonReport(datasets=["ds1", "ds2"], metrics=[m1, m2])
        table = report.to_table()
        self.assertIn("ds1", table)
        self.assertIn("ds2", table)
        self.assertIn("1,000", table)
        self.assertIn("2,000", table)

    def test_table_best_for_section(self):
        recipe = _make_recipe(name="ds1")
        m = _make_metric("ds1", recipe=recipe)
        report = ComparisonReport(
            datasets=["ds1"],
            metrics=[m],
            best_for={"Largest scale": "ds1"},
        )
        table = report.to_table()
        self.assertIn("Recommendations:", table)
        self.assertIn("Largest scale: ds1", table)

    def test_table_no_best_for(self):
        recipe = _make_recipe(name="ds1")
        m = _make_metric("ds1", recipe=recipe)
        report = ComparisonReport(datasets=["ds1"], metrics=[m])
        table = report.to_table()
        self.assertNotIn("Recommendations:", table)


# ==================== DatasetComparator.__init__ tests ====================


class TestDatasetComparatorInit(unittest.TestCase):
    """Test DatasetComparator initialization."""

    @patch("datarecipe.comparator.DatasetAnalyzer")
    @patch("datarecipe.comparator.CostCalculator")
    @patch("datarecipe.comparator.QualityAnalyzer")
    def test_default_init(self, mock_qa, mock_cc, mock_da):
        comp = DatasetComparator()
        self.assertTrue(comp.include_cost)
        self.assertFalse(comp.include_quality)
        self.assertEqual(comp.quality_sample_size, 500)

    @patch("datarecipe.comparator.DatasetAnalyzer")
    @patch("datarecipe.comparator.CostCalculator")
    @patch("datarecipe.comparator.QualityAnalyzer")
    def test_custom_init(self, mock_qa, mock_cc, mock_da):
        comp = DatasetComparator(
            include_cost=False, include_quality=True, quality_sample_size=200
        )
        self.assertFalse(comp.include_cost)
        self.assertTrue(comp.include_quality)
        self.assertEqual(comp.quality_sample_size, 200)


# ==================== DatasetComparator.compare tests ====================


class TestDatasetComparatorCompare(unittest.TestCase):
    """Test DatasetComparator.compare() method."""

    def _make_comparator(self, include_cost=True, include_quality=False):
        with patch("datarecipe.comparator.DatasetAnalyzer"), \
             patch("datarecipe.comparator.CostCalculator") as mock_cc, \
             patch("datarecipe.comparator.QualityAnalyzer") as mock_qa:
            comp = DatasetComparator(
                include_cost=include_cost, include_quality=include_quality
            )
            comp.cost_calculator = mock_cc.return_value
            comp.quality_analyzer = mock_qa.return_value
        return comp

    def test_compare_empty_list(self):
        comp = self._make_comparator(include_cost=False)
        report = comp.compare([])
        self.assertIsInstance(report, ComparisonReport)
        self.assertEqual(report.datasets, [])
        self.assertEqual(report.metrics, [])

    def test_compare_uses_source_id(self):
        comp = self._make_comparator(include_cost=False)
        recipe = _make_recipe(name="name", source_id="org/dataset")
        report = comp.compare([recipe])
        self.assertEqual(report.datasets, ["org/dataset"])

    def test_compare_falls_back_to_name(self):
        comp = self._make_comparator(include_cost=False)
        recipe = Recipe(name="fallback-name")
        report = comp.compare([recipe])
        self.assertEqual(report.datasets, ["fallback-name"])

    def test_compare_with_cost(self):
        comp = self._make_comparator(include_cost=True)
        cost = _make_cost()
        comp.cost_calculator.estimate_from_recipe.return_value = cost
        recipe = _make_recipe(name="ds")
        report = comp.compare([recipe])
        self.assertEqual(report.metrics[0].cost, cost)

    def test_compare_cost_error_handled(self):
        comp = self._make_comparator(include_cost=True)
        comp.cost_calculator.estimate_from_recipe.side_effect = ValueError("fail")
        recipe = _make_recipe(name="ds")
        report = comp.compare([recipe])
        self.assertIsNone(report.metrics[0].cost)

    def test_compare_cost_type_error_handled(self):
        comp = self._make_comparator(include_cost=True)
        comp.cost_calculator.estimate_from_recipe.side_effect = TypeError("fail")
        recipe = _make_recipe(name="ds")
        report = comp.compare([recipe])
        self.assertIsNone(report.metrics[0].cost)

    def test_compare_cost_key_error_handled(self):
        comp = self._make_comparator(include_cost=True)
        comp.cost_calculator.estimate_from_recipe.side_effect = KeyError("fail")
        recipe = _make_recipe(name="ds")
        report = comp.compare([recipe])
        self.assertIsNone(report.metrics[0].cost)

    def test_compare_cost_attribute_error_handled(self):
        comp = self._make_comparator(include_cost=True)
        comp.cost_calculator.estimate_from_recipe.side_effect = AttributeError("fail")
        recipe = _make_recipe(name="ds")
        report = comp.compare([recipe])
        self.assertIsNone(report.metrics[0].cost)

    def test_compare_no_cost_when_disabled(self):
        comp = self._make_comparator(include_cost=False)
        recipe = _make_recipe(name="ds")
        report = comp.compare([recipe])
        self.assertIsNone(report.metrics[0].cost)
        comp.cost_calculator.estimate_from_recipe.assert_not_called()

    def test_compare_with_quality(self):
        comp = self._make_comparator(include_cost=False, include_quality=True)
        quality = _make_quality()
        comp.quality_analyzer.analyze_from_huggingface.return_value = quality
        recipe = _make_recipe(name="ds", source_id="org/ds")
        report = comp.compare([recipe])
        self.assertEqual(report.metrics[0].quality, quality)
        comp.quality_analyzer.analyze_from_huggingface.assert_called_once_with(
            "org/ds", sample_size=500
        )

    def test_compare_quality_skipped_without_source_id(self):
        comp = self._make_comparator(include_cost=False, include_quality=True)
        recipe = Recipe(name="no-source")
        report = comp.compare([recipe])
        self.assertIsNone(report.metrics[0].quality)

    def test_compare_quality_error_handled(self):
        comp = self._make_comparator(include_cost=False, include_quality=True)
        comp.quality_analyzer.analyze_from_huggingface.side_effect = ImportError("no pkg")
        recipe = _make_recipe(name="ds", source_id="org/ds")
        report = comp.compare([recipe])
        self.assertIsNone(report.metrics[0].quality)

    def test_compare_quality_os_error_handled(self):
        comp = self._make_comparator(include_cost=False, include_quality=True)
        comp.quality_analyzer.analyze_from_huggingface.side_effect = OSError("net fail")
        recipe = _make_recipe(name="ds", source_id="org/ds")
        report = comp.compare([recipe])
        self.assertIsNone(report.metrics[0].quality)

    def test_compare_quality_value_error_handled(self):
        comp = self._make_comparator(include_cost=False, include_quality=True)
        comp.quality_analyzer.analyze_from_huggingface.side_effect = ValueError("bad")
        recipe = _make_recipe(name="ds", source_id="org/ds")
        report = comp.compare([recipe])
        self.assertIsNone(report.metrics[0].quality)

    def test_compare_quality_attribute_error_handled(self):
        comp = self._make_comparator(include_cost=False, include_quality=True)
        comp.quality_analyzer.analyze_from_huggingface.side_effect = AttributeError("bad")
        recipe = _make_recipe(name="ds", source_id="org/ds")
        report = comp.compare([recipe])
        self.assertIsNone(report.metrics[0].quality)


# ==================== DatasetComparator.compare_by_ids tests ====================


class TestDatasetComparatorCompareByIds(unittest.TestCase):
    """Test DatasetComparator.compare_by_ids() method."""

    def _make_comparator(self):
        with patch("datarecipe.comparator.DatasetAnalyzer") as mock_da, \
             patch("datarecipe.comparator.CostCalculator"), \
             patch("datarecipe.comparator.QualityAnalyzer"):
            comp = DatasetComparator(include_cost=False, include_quality=False)
            comp.analyzer = mock_da.return_value
        return comp

    def test_compare_by_ids_success(self):
        comp = self._make_comparator()
        recipe = _make_recipe(name="test-ds", source_id="org/test-ds")
        comp.analyzer.analyze.return_value = recipe
        report = comp.compare_by_ids(["org/test-ds"])
        comp.analyzer.analyze.assert_called_once_with("org/test-ds")
        self.assertEqual(len(report.metrics), 1)

    def test_compare_by_ids_failure_creates_minimal_recipe(self):
        comp = self._make_comparator()
        comp.analyzer.analyze.side_effect = OSError("network error")
        report = comp.compare_by_ids(["org/fail-ds"])
        self.assertEqual(len(report.metrics), 1)
        self.assertEqual(report.metrics[0].dataset_id, "org/fail-ds")

    def test_compare_by_ids_value_error(self):
        comp = self._make_comparator()
        comp.analyzer.analyze.side_effect = ValueError("bad")
        report = comp.compare_by_ids(["org/fail-ds"])
        self.assertEqual(len(report.metrics), 1)

    def test_compare_by_ids_key_error(self):
        comp = self._make_comparator()
        comp.analyzer.analyze.side_effect = KeyError("bad")
        report = comp.compare_by_ids(["org/fail-ds"])
        self.assertEqual(len(report.metrics), 1)

    def test_compare_by_ids_attribute_error(self):
        comp = self._make_comparator()
        comp.analyzer.analyze.side_effect = AttributeError("bad")
        report = comp.compare_by_ids(["org/fail-ds"])
        self.assertEqual(len(report.metrics), 1)

    def test_compare_by_ids_mixed(self):
        comp = self._make_comparator()
        good_recipe = _make_recipe(name="good", source_id="org/good")
        comp.analyzer.analyze.side_effect = [good_recipe, ValueError("fail")]
        report = comp.compare_by_ids(["org/good", "org/bad"])
        self.assertEqual(len(report.metrics), 2)


# ==================== _build_report tests ====================


class TestBuildReport(unittest.TestCase):
    """Test DatasetComparator._build_report()."""

    def _make_comparator(self):
        with patch("datarecipe.comparator.DatasetAnalyzer"), \
             patch("datarecipe.comparator.CostCalculator"), \
             patch("datarecipe.comparator.QualityAnalyzer"):
            return DatasetComparator(include_cost=False)

    def test_build_report_cost_comparison(self):
        comp = self._make_comparator()
        cost = _make_cost(api=100, human=50, compute=30, total=180)
        m = _make_metric("ds-a", cost=cost)
        report = comp._build_report([m])
        self.assertIn("ds-a", report.cost_comparison)
        self.assertEqual(report.cost_comparison["ds-a"]["api"], 100)
        self.assertEqual(report.cost_comparison["ds-a"]["human"], 50)
        self.assertEqual(report.cost_comparison["ds-a"]["compute"], 30)
        self.assertEqual(report.cost_comparison["ds-a"]["total"], 180)

    def test_build_report_no_cost(self):
        comp = self._make_comparator()
        m = _make_metric("ds-a")
        report = comp._build_report([m])
        self.assertEqual(report.cost_comparison, {})

    def test_build_report_quality_comparison(self):
        comp = self._make_comparator()
        quality = _make_quality(overall=80, diversity_utr=0.2, diversity_sd=0.5,
                                consistency_fc=0.9, complexity_vr=0.3)
        m = _make_metric("ds-a", quality=quality)
        report = comp._build_report([m])
        self.assertIn("ds-a", report.quality_comparison)
        self.assertEqual(report.quality_comparison["ds-a"]["diversity"], 0.2)
        self.assertEqual(report.quality_comparison["ds-a"]["consistency"], 0.9)
        self.assertEqual(report.quality_comparison["ds-a"]["complexity"], 0.3)
        self.assertEqual(report.quality_comparison["ds-a"]["overall"], 80)

    def test_build_report_no_quality(self):
        comp = self._make_comparator()
        m = _make_metric("ds-a")
        report = comp._build_report([m])
        self.assertEqual(report.quality_comparison, {})

    def test_build_report_datasets_list(self):
        comp = self._make_comparator()
        m1 = _make_metric("ds-a")
        m2 = _make_metric("ds-b")
        report = comp._build_report([m1, m2])
        self.assertEqual(report.datasets, ["ds-a", "ds-b"])


# ==================== _analyze_strengths_weaknesses tests ====================


class TestAnalyzeStrengthsWeaknesses(unittest.TestCase):
    """Test DatasetComparator._analyze_strengths_weaknesses()."""

    def _make_comparator(self):
        with patch("datarecipe.comparator.DatasetAnalyzer"), \
             patch("datarecipe.comparator.CostCalculator"), \
             patch("datarecipe.comparator.QualityAnalyzer"):
            return DatasetComparator(include_cost=False)

    def test_lowest_cost_strength(self):
        comp = self._make_comparator()
        m1 = _make_metric("cheap", cost=_make_cost(total=50))
        m2 = _make_metric("expensive", cost=_make_cost(total=500))
        strengths, _ = comp._analyze_strengths_weaknesses([m1, m2])
        self.assertIn("Lowest estimated production cost", strengths["cheap"])

    def test_higher_than_average_cost_weakness(self):
        comp = self._make_comparator()
        m1 = _make_metric("cheap", cost=_make_cost(total=100))
        m2 = _make_metric("expensive", cost=_make_cost(total=500))
        _, weaknesses = comp._analyze_strengths_weaknesses([m1, m2])
        # avg is 300, expensive(500) > 300*1.5=450 -> weakness
        self.assertIn("Higher than average production cost", weaknesses["expensive"])

    def test_cost_not_higher_than_average_no_weakness(self):
        comp = self._make_comparator()
        m1 = _make_metric("a", cost=_make_cost(total=100))
        m2 = _make_metric("b", cost=_make_cost(total=120))
        _, weaknesses = comp._analyze_strengths_weaknesses([m1, m2])
        # avg=110, b(120) < 110*1.5=165 -> no weakness
        self.assertNotIn("Higher than average production cost", weaknesses.get("b", []))

    def test_largest_dataset_strength(self):
        comp = self._make_comparator()
        r1 = _make_recipe(name="big", num_examples=50000)
        r2 = _make_recipe(name="small", num_examples=5000)
        m1 = _make_metric("big", recipe=r1)
        m2 = _make_metric("small", recipe=r2)
        strengths, _ = comp._analyze_strengths_weaknesses([m1, m2])
        self.assertIn("Largest dataset size", strengths["big"])

    def test_small_dataset_weakness(self):
        comp = self._make_comparator()
        r = _make_recipe(name="tiny", num_examples=500)
        m = _make_metric("tiny", recipe=r)
        _, weaknesses = comp._analyze_strengths_weaknesses([m])
        self.assertIn("Small dataset size", weaknesses["tiny"])

    def test_highest_reproducibility_strength(self):
        comp = self._make_comparator()
        r1 = _make_recipe(name="repro", repro_score=9)
        r2 = _make_recipe(name="low", repro_score=3)
        m1 = _make_metric("repro", recipe=r1)
        m2 = _make_metric("low", recipe=r2)
        strengths, _ = comp._analyze_strengths_weaknesses([m1, m2])
        self.assertIn("Highest reproducibility score", strengths["repro"])

    def test_excellent_reproducibility_strength(self):
        comp = self._make_comparator()
        r = _make_recipe(name="excellent", repro_score=9)
        m = _make_metric("excellent", recipe=r)
        strengths, _ = comp._analyze_strengths_weaknesses([m])
        self.assertIn("Excellent documentation and reproducibility", strengths["excellent"])

    def test_limited_reproducibility_weakness(self):
        comp = self._make_comparator()
        r = _make_recipe(name="limited", repro_score=3)
        m = _make_metric("limited", recipe=r)
        _, weaknesses = comp._analyze_strengths_weaknesses([m])
        self.assertIn("Limited reproducibility information", weaknesses["limited"])

    def test_highest_quality_strength(self):
        comp = self._make_comparator()
        m1 = _make_metric("best", quality=_make_quality(overall=90, diversity_sd=0.7))
        m2 = _make_metric("worse", quality=_make_quality(overall=60, diversity_sd=0.5))
        strengths, _ = comp._analyze_strengths_weaknesses([m1, m2])
        self.assertIn("Highest quality score", strengths["best"])

    def test_high_overall_quality_strength(self):
        comp = self._make_comparator()
        m = _make_metric("hq", quality=_make_quality(overall=85, diversity_sd=0.7))
        strengths, _ = comp._analyze_strengths_weaknesses([m])
        self.assertIn("High overall quality", strengths["hq"])

    def test_below_average_quality_weakness(self):
        comp = self._make_comparator()
        m = _make_metric("lq", quality=_make_quality(overall=40, diversity_sd=0.5))
        _, weaknesses = comp._analyze_strengths_weaknesses([m])
        self.assertIn("Below average quality metrics", weaknesses["lq"])

    def test_high_content_diversity_strength(self):
        comp = self._make_comparator()
        m = _make_metric("diverse", quality=_make_quality(overall=70, diversity_sd=0.7))
        strengths, _ = comp._analyze_strengths_weaknesses([m])
        self.assertIn("High content diversity", strengths["diverse"])

    def test_low_content_diversity_weakness(self):
        comp = self._make_comparator()
        m = _make_metric("uniform", quality=_make_quality(overall=70, diversity_sd=0.2))
        _, weaknesses = comp._analyze_strengths_weaknesses([m])
        self.assertIn("Low content diversity", weaknesses["uniform"])

    def test_premium_models_strength(self):
        comp = self._make_comparator()
        r = _make_recipe(name="premium", teacher_models=["gpt-4"])
        m = _make_metric("premium", recipe=r)
        strengths, _ = comp._analyze_strengths_weaknesses([m])
        self.assertIn("Generated with premium AI models", strengths["premium"])

    def test_claude_opus_premium_model(self):
        comp = self._make_comparator()
        r = _make_recipe(name="claude", teacher_models=["claude-3-opus"])
        m = _make_metric("claude", recipe=r)
        strengths, _ = comp._analyze_strengths_weaknesses([m])
        self.assertIn("Generated with premium AI models", strengths["claude"])

    def test_non_premium_models_no_strength(self):
        comp = self._make_comparator()
        r = _make_recipe(name="basic", teacher_models=["llama-7b"])
        m = _make_metric("basic", recipe=r)
        strengths, _ = comp._analyze_strengths_weaknesses([m])
        self.assertNotIn("Generated with premium AI models", strengths.get("basic", []))

    def test_highly_synthetic_strength_and_weakness(self):
        comp = self._make_comparator()
        r = _make_recipe(name="synth", synthetic_ratio=0.95)
        m = _make_metric("synth", recipe=r)
        strengths, weaknesses = comp._analyze_strengths_weaknesses([m])
        self.assertIn("Highly scalable (mostly synthetic)", strengths["synth"])
        self.assertIn("May have synthetic data artifacts", weaknesses["synth"])

    def test_low_synthetic_ratio_strength_and_weakness(self):
        comp = self._make_comparator()
        r = _make_recipe(name="human", synthetic_ratio=0.05)
        m = _make_metric("human", recipe=r)
        strengths, weaknesses = comp._analyze_strengths_weaknesses([m])
        self.assertIn("High-quality human annotations", strengths["human"])
        self.assertIn("Difficult to scale", weaknesses["human"])

    def test_no_cost_no_quality_no_repro(self):
        comp = self._make_comparator()
        r = Recipe(name="bare")
        m = DatasetMetrics(dataset_id="bare", recipe=r)
        strengths, weaknesses = comp._analyze_strengths_weaknesses([m])
        self.assertEqual(strengths["bare"], [])
        self.assertEqual(weaknesses["bare"], [])


# ==================== _determine_best_for tests ====================


class TestDetermineBestFor(unittest.TestCase):
    """Test DatasetComparator._determine_best_for()."""

    def _make_comparator(self):
        with patch("datarecipe.comparator.DatasetAnalyzer"), \
             patch("datarecipe.comparator.CostCalculator"), \
             patch("datarecipe.comparator.QualityAnalyzer"):
            return DatasetComparator(include_cost=False)

    def test_empty_metrics(self):
        comp = self._make_comparator()
        best_for = comp._determine_best_for([])
        self.assertEqual(best_for, {})

    def test_best_value(self):
        comp = self._make_comparator()
        # ds-a: quality=90 / (cost=100/1000) = 900
        # ds-b: quality=80 / (cost=200/1000) = 400
        m1 = _make_metric("ds-a", cost=_make_cost(total=100),
                          quality=_make_quality(overall=90))
        m2 = _make_metric("ds-b", cost=_make_cost(total=200),
                          quality=_make_quality(overall=80))
        best_for = comp._determine_best_for([m1, m2])
        self.assertEqual(best_for["Best value (quality/cost)"], "ds-a")

    def test_largest_scale(self):
        comp = self._make_comparator()
        r1 = _make_recipe(name="big", num_examples=50000)
        r2 = _make_recipe(name="small", num_examples=5000)
        m1 = _make_metric("big", recipe=r1)
        m2 = _make_metric("small", recipe=r2)
        best_for = comp._determine_best_for([m1, m2])
        self.assertEqual(best_for["Largest scale"], "big")

    def test_most_reproducible(self):
        comp = self._make_comparator()
        r1 = _make_recipe(name="repro", repro_score=9)
        r2 = _make_recipe(name="low", repro_score=3)
        m1 = _make_metric("repro", recipe=r1)
        m2 = _make_metric("low", recipe=r2)
        best_for = comp._determine_best_for([m1, m2])
        self.assertEqual(best_for["Most reproducible"], "repro")

    def test_highest_quality(self):
        comp = self._make_comparator()
        m1 = _make_metric("hq", quality=_make_quality(overall=95))
        m2 = _make_metric("lq", quality=_make_quality(overall=50))
        best_for = comp._determine_best_for([m1, m2])
        self.assertEqual(best_for["Highest quality"], "hq")

    def test_lowest_cost(self):
        comp = self._make_comparator()
        m1 = _make_metric("cheap", cost=_make_cost(total=50))
        m2 = _make_metric("pricey", cost=_make_cost(total=500))
        best_for = comp._determine_best_for([m1, m2])
        self.assertEqual(best_for["Lowest cost"], "cheap")

    def test_quick_prototyping(self):
        comp = self._make_comparator()
        r1 = _make_recipe(name="small", num_examples=5000)
        r2 = _make_recipe(name="large", num_examples=50000)
        m1 = _make_metric("small", recipe=r1, quality=_make_quality(overall=80))
        m2 = _make_metric("large", recipe=r2, quality=_make_quality(overall=90))
        best_for = comp._determine_best_for([m1, m2])
        # Only small qualifies (<10000), so it wins
        self.assertEqual(best_for["Quick prototyping"], "small")

    def test_quick_prototyping_no_quality(self):
        comp = self._make_comparator()
        r1 = _make_recipe(name="small1", num_examples=5000)
        r2 = _make_recipe(name="small2", num_examples=3000)
        m1 = _make_metric("small1", recipe=r1)
        m2 = _make_metric("small2", recipe=r2)
        best_for = comp._determine_best_for([m1, m2])
        # Both qualify, both have quality=0 (no quality object -> 0 fallback)
        self.assertIn("Quick prototyping", best_for)

    def test_no_sizes_no_largest_scale(self):
        comp = self._make_comparator()
        r = Recipe(name="bare")
        m = DatasetMetrics(dataset_id="bare", recipe=r)
        best_for = comp._determine_best_for([m])
        self.assertNotIn("Largest scale", best_for)

    def test_no_quality_no_highest_quality(self):
        comp = self._make_comparator()
        m = _make_metric("noq")
        best_for = comp._determine_best_for([m])
        self.assertNotIn("Highest quality", best_for)

    def test_no_cost_no_lowest_cost_or_best_value(self):
        comp = self._make_comparator()
        m = _make_metric("noc")
        best_for = comp._determine_best_for([m])
        self.assertNotIn("Lowest cost", best_for)
        self.assertNotIn("Best value (quality/cost)", best_for)

    def test_no_repro_no_most_reproducible(self):
        comp = self._make_comparator()
        r = Recipe(name="norep")
        m = DatasetMetrics(dataset_id="norep", recipe=r)
        best_for = comp._determine_best_for([m])
        self.assertNotIn("Most reproducible", best_for)

    def test_zero_cost_no_best_value(self):
        comp = self._make_comparator()
        cost = _make_cost(total=0)
        m = _make_metric("free", cost=cost, quality=_make_quality(overall=80))
        best_for = comp._determine_best_for([m])
        self.assertNotIn("Best value (quality/cost)", best_for)


# ==================== _generate_recommendations tests ====================


class TestGenerateRecommendations(unittest.TestCase):
    """Test DatasetComparator._generate_recommendations()."""

    def _make_comparator(self):
        with patch("datarecipe.comparator.DatasetAnalyzer"), \
             patch("datarecipe.comparator.CostCalculator"), \
             patch("datarecipe.comparator.QualityAnalyzer"):
            return DatasetComparator(include_cost=False)

    def test_single_metric_recommendation(self):
        comp = self._make_comparator()
        m = _make_metric("only")
        recs = comp._generate_recommendations([m])
        self.assertEqual(recs, ["Add more datasets for meaningful comparison"])

    def test_empty_metrics(self):
        comp = self._make_comparator()
        recs = comp._generate_recommendations([])
        self.assertEqual(recs, ["Add more datasets for meaningful comparison"])

    def test_large_cost_difference_recommendation(self):
        comp = self._make_comparator()
        m1 = _make_metric("cheap", cost=_make_cost(total=100))
        m2 = _make_metric("expensive", cost=_make_cost(total=500))
        recs = comp._generate_recommendations([m1, m2])
        found = any("budget-constrained" in r for r in recs)
        self.assertTrue(found, f"Expected budget recommendation in {recs}")

    def test_small_cost_difference_no_recommendation(self):
        comp = self._make_comparator()
        m1 = _make_metric("a", cost=_make_cost(total=100))
        m2 = _make_metric("b", cost=_make_cost(total=200))
        recs = comp._generate_recommendations([m1, m2])
        found = any("budget-constrained" in r for r in recs)
        self.assertFalse(found)

    def test_high_quality_recommendation(self):
        comp = self._make_comparator()
        m1 = _make_metric("hq", quality=_make_quality(overall=85))
        m2 = _make_metric("lq", quality=_make_quality(overall=50))
        recs = comp._generate_recommendations([m1, m2])
        found = any("highest quality" in r for r in recs)
        self.assertTrue(found, f"Expected quality recommendation in {recs}")

    def test_quality_below_80_no_quality_recommendation(self):
        comp = self._make_comparator()
        m1 = _make_metric("a", quality=_make_quality(overall=75))
        m2 = _make_metric("b", quality=_make_quality(overall=60))
        recs = comp._generate_recommendations([m1, m2])
        found = any("highest quality" in r for r in recs)
        self.assertFalse(found)

    def test_reproducibility_recommendation(self):
        comp = self._make_comparator()
        r1 = _make_recipe(name="repro", repro_score=9)
        r2 = _make_recipe(name="low", repro_score=3)
        m1 = _make_metric("repro", recipe=r1)
        m2 = _make_metric("low", recipe=r2)
        recs = comp._generate_recommendations([m1, m2])
        found = any("reproducible" in r for r in recs)
        self.assertTrue(found, f"Expected reproducibility recommendation in {recs}")

    def test_no_reproducibility_rec_when_all_below_8(self):
        comp = self._make_comparator()
        r1 = _make_recipe(name="a", repro_score=5)
        r2 = _make_recipe(name="b", repro_score=6)
        m1 = _make_metric("a", recipe=r1)
        m2 = _make_metric("b", recipe=r2)
        recs = comp._generate_recommendations([m1, m2])
        found = any("reproducible" in r for r in recs)
        self.assertFalse(found)

    def test_diversity_recommendation(self):
        comp = self._make_comparator()
        m1 = _make_metric("diverse", quality=_make_quality(overall=70, diversity_sd=0.7))
        m2 = _make_metric("uniform", quality=_make_quality(overall=70, diversity_sd=0.3))
        recs = comp._generate_recommendations([m1, m2])
        found = any("diverse training data" in r for r in recs)
        self.assertTrue(found, f"Expected diversity recommendation in {recs}")
        found_ds = any("diverse" in r and "diverse" in r for r in recs)
        self.assertTrue(found_ds)

    def test_no_diversity_rec_when_all_low(self):
        comp = self._make_comparator()
        m1 = _make_metric("a", quality=_make_quality(overall=70, diversity_sd=0.3))
        m2 = _make_metric("b", quality=_make_quality(overall=70, diversity_sd=0.4))
        recs = comp._generate_recommendations([m1, m2])
        found = any("diverse training data" in r for r in recs)
        self.assertFalse(found)

    def test_fallback_recommendation(self):
        comp = self._make_comparator()
        # Two metrics, no cost, no quality, no repro -> fallback
        r1 = Recipe(name="a")
        r2 = Recipe(name="b")
        m1 = DatasetMetrics(dataset_id="a", recipe=r1)
        m2 = DatasetMetrics(dataset_id="b", recipe=r2)
        recs = comp._generate_recommendations([m1, m2])
        self.assertEqual(recs, ["All datasets appear comparable - choose based on specific requirements"])


# ==================== Integration: full compare flow ====================


class TestComparatorIntegration(unittest.TestCase):
    """Integration tests for the full comparison flow."""

    def _make_comparator(self):
        with patch("datarecipe.comparator.DatasetAnalyzer"), \
             patch("datarecipe.comparator.CostCalculator") as mock_cc, \
             patch("datarecipe.comparator.QualityAnalyzer"):
            comp = DatasetComparator(include_cost=True, include_quality=False)
            comp.cost_calculator = mock_cc.return_value
        return comp

    def test_full_compare_produces_complete_report(self):
        comp = self._make_comparator()
        cost1 = _make_cost(api=100, human=50, compute=30, total=180)
        cost2 = _make_cost(api=500, human=200, compute=100, total=800)
        comp.cost_calculator.estimate_from_recipe.side_effect = [cost1, cost2]

        r1 = _make_recipe(name="ds1", num_examples=5000, repro_score=8)
        r2 = _make_recipe(name="ds2", num_examples=50000, repro_score=4)
        report = comp.compare([r1, r2])

        # Check all sections populated
        self.assertEqual(len(report.datasets), 2)
        self.assertEqual(len(report.metrics), 2)
        self.assertIn("ds1", report.cost_comparison)
        self.assertIn("ds2", report.cost_comparison)
        self.assertIn("ds1", report.strengths)
        self.assertIn("ds2", report.strengths)

        # Verify markdown generation
        md = report.to_markdown()
        self.assertIn("# Dataset Comparison Report", md)
        self.assertIn("ds1", md)
        self.assertIn("ds2", md)

        # Verify table generation
        table = report.to_table()
        self.assertIn("ds1", table)
        self.assertIn("ds2", table)

    def test_compare_with_all_features(self):
        """Test compare with cost + quality + repro + synthetic + teacher models."""
        with patch("datarecipe.comparator.DatasetAnalyzer"), \
             patch("datarecipe.comparator.CostCalculator") as mock_cc, \
             patch("datarecipe.comparator.QualityAnalyzer") as mock_qa:
            comp = DatasetComparator(include_cost=True, include_quality=True)
            comp.cost_calculator = mock_cc.return_value
            comp.quality_analyzer = mock_qa.return_value

            cost = _make_cost(total=200)
            quality = _make_quality(overall=85, diversity_sd=0.7)
            comp.cost_calculator.estimate_from_recipe.return_value = cost
            comp.quality_analyzer.analyze_from_huggingface.return_value = quality

            r = _make_recipe(
                name="full",
                source_id="org/full",
                num_examples=20000,
                repro_score=9,
                synthetic_ratio=0.8,
                teacher_models=["gpt-4"],
            )
            report = comp.compare([r])

            self.assertEqual(len(report.metrics), 1)
            m = report.metrics[0]
            self.assertEqual(m.cost, cost)
            self.assertEqual(m.quality, quality)

            # Check strengths include premium model and synthetic
            self.assertIn("Generated with premium AI models", report.strengths["org/full"])


# ==================== Edge cases ====================


class TestEdgeCases(unittest.TestCase):
    """Edge case tests."""

    def _make_comparator(self):
        with patch("datarecipe.comparator.DatasetAnalyzer"), \
             patch("datarecipe.comparator.CostCalculator"), \
             patch("datarecipe.comparator.QualityAnalyzer"):
            return DatasetComparator(include_cost=False)

    def test_synthetic_ratio_none_no_crash(self):
        comp = self._make_comparator()
        r = _make_recipe(name="ds", synthetic_ratio=None)
        m = _make_metric("ds", recipe=r)
        strengths, weaknesses = comp._analyze_strengths_weaknesses([m])
        self.assertNotIn("Highly scalable (mostly synthetic)", strengths.get("ds", []))
        self.assertNotIn("High-quality human annotations", strengths.get("ds", []))

    def test_synthetic_ratio_mid_range(self):
        comp = self._make_comparator()
        r = _make_recipe(name="ds", synthetic_ratio=0.5)
        m = _make_metric("ds", recipe=r)
        strengths, weaknesses = comp._analyze_strengths_weaknesses([m])
        # 0.5 is not > 0.9 and not < 0.1
        self.assertNotIn("Highly scalable (mostly synthetic)", strengths.get("ds", []))
        self.assertNotIn("High-quality human annotations", strengths.get("ds", []))

    def test_reproducibility_score_5_no_strength_no_weakness(self):
        comp = self._make_comparator()
        r = _make_recipe(name="ds", repro_score=5)
        m = _make_metric("ds", recipe=r)
        strengths, weaknesses = comp._analyze_strengths_weaknesses([m])
        self.assertNotIn("Excellent documentation and reproducibility", strengths.get("ds", []))
        self.assertNotIn("Limited reproducibility information", weaknesses.get("ds", []))

    def test_quality_score_between_50_and_80(self):
        comp = self._make_comparator()
        m = _make_metric("ds", quality=_make_quality(overall=65, diversity_sd=0.45))
        strengths, weaknesses = comp._analyze_strengths_weaknesses([m])
        self.assertNotIn("High overall quality", strengths.get("ds", []))
        self.assertNotIn("Below average quality metrics", weaknesses.get("ds", []))

    def test_semantic_diversity_mid_range(self):
        comp = self._make_comparator()
        m = _make_metric("ds", quality=_make_quality(overall=70, diversity_sd=0.45))
        strengths, weaknesses = comp._analyze_strengths_weaknesses([m])
        self.assertNotIn("High content diversity", strengths.get("ds", []))
        self.assertNotIn("Low content diversity", weaknesses.get("ds", []))

    def test_to_markdown_dataset_in_strengths_but_empty_list(self):
        report = ComparisonReport(
            datasets=["a"],
            strengths={"a": []},
            weaknesses={"a": []},
        )
        md = report.to_markdown()
        self.assertIn("### a", md)
        self.assertNotIn("**Strengths:**", md)
        self.assertNotIn("**Weaknesses:**", md)

    def test_to_markdown_dataset_not_in_strengths_dict(self):
        report = ComparisonReport(
            datasets=["a"],
            strengths={},
            weaknesses={},
        )
        md = report.to_markdown()
        self.assertIn("### a", md)
        self.assertNotIn("**Strengths:**", md)

    def test_num_examples_zero_treated_as_falsy(self):
        """num_examples=0 should show N/A in overview."""
        r = Recipe(name="zero", num_examples=0)
        m = DatasetMetrics(dataset_id="zero", recipe=r)
        report = ComparisonReport(datasets=["zero"], metrics=[m])
        md = report.to_markdown()
        self.assertIn("N/A", md)


if __name__ == "__main__":
    unittest.main()
