"""Tests for merge_trivial_tasks() — trivial task merger.

Covers:
  1. Empty list returns ([], [])
  2. No trivial tasks — all preserved, empty report
  3. Simple fold into parent — trivial child folded into parent's notes
  4. Fold into dependent — trivial task with no parent folds into first dependent
  5. Epic preservation — epics never merged even if trivial
  6. Dependency rewiring — dependents of removed task get its dependencies
  7. Cycle detection — cyclic trivial tasks are skipped
  8. Cascading merges — multi-pass handles chains of trivial tasks
  9. Idempotency — calling twice produces same result
  10. None notes normalisation — tasks with None notes don't cause errors
  11. Custom threshold — threshold parameter overrides default
"""

from __future__ import annotations

import copy

import pytest

from loom.complexity import COMPLEXITY_THRESHOLD, compute_complexity_score
from loom.validator import merge_trivial_tasks


# ── Helpers ──────────────────────────────────────────────────────────


def _task(
    tid: str,
    title: str,
    *,
    description: str = "",
    task_type: str = "task",
    parent_id: str | None = None,
    dependencies: list[str] | None = None,
    files: list[str] | None = None,
    subtasks: list[str] | None = None,
    notes: str | None = None,
    done_when: str | None = None,
) -> dict:
    """Build a task dict matching the merge_trivial_tasks input format."""
    return {
        "id": tid,
        "title": title,
        "description": description,
        "type": task_type,
        "parent_id": parent_id,
        "dependencies": dependencies or [],
        "files": files or [],
        "subtasks": subtasks or [],
        "notes": notes,
        "done_when": done_when,
    }


def _trivial_task(
    tid: str,
    title: str = "Fix typo",
    **kwargs,
) -> dict:
    """Build a task dict guaranteed to be trivial (score < COMPLEXITY_THRESHOLD).

    Default: short title, no description, no files, no subtasks.
    compute_complexity_score({}) == 1.0, COMPLEXITY_THRESHOLD == 2.0, so
    any task with score < 2.0 is trivial.
    """
    return _task(tid, title, description="fix", **kwargs)


def _nontrivial_task(
    tid: str,
    title: str = "Implement comprehensive caching layer with Redis fallback",
    **kwargs,
) -> dict:
    """Build a task dict guaranteed NOT to be trivial.

    Long description + files + subtasks => high complexity score.
    """
    return _task(
        tid,
        title,
        description=(
            "Implement a full caching layer that handles failover scenarios "
            "with circuit breaker pattern, retry logic, and fallback to "
            "Postgres when Redis is unavailable. Include comprehensive "
            "error handling, metrics emission, and structured logging "
            "across all cache operations in the system."
        ),
        files=["cache.py", "errors.py", "metrics.py", "logging.py"],
        subtasks=["design schema", "implement cache", "add tests"],
        **kwargs,
    )


# ── 1. Empty list ───────────────────────────────────────────────────


class TestEmptyList:
    def test_empty_returns_empty(self) -> None:
        remaining, report = merge_trivial_tasks([])
        assert remaining == []
        assert report == []


# ── 2. No trivial tasks ─────────────────────────────────────────────


class TestNoTrivialTasks:
    def test_all_tasks_preserved(self) -> None:
        tasks = [
            _nontrivial_task("t1"),
            _nontrivial_task("t2", title="Build authentication system"),
        ]

        remaining, report = merge_trivial_tasks(tasks)

        assert len(remaining) == 2
        remaining_ids = {t["id"] for t in remaining}
        assert remaining_ids == {"t1", "t2"}
        assert report == []

    def test_report_empty_when_nothing_merged(self) -> None:
        tasks = [_nontrivial_task("t1")]
        _, report = merge_trivial_tasks(tasks)
        assert report == []


# ── 3. Simple fold into parent ───────────────────────────────────────


class TestFoldIntoParent:
    def test_trivial_child_folded_into_parent_notes(self) -> None:
        parent = _nontrivial_task("parent1", task_type="epic")
        child = _trivial_task("child1", title="Add import", parent_id="parent1")

        remaining, report = merge_trivial_tasks([parent, child])

        remaining_ids = {t["id"] for t in remaining}
        assert "child1" not in remaining_ids
        assert "parent1" in remaining_ids

        # Parent should have the folded note
        parent_task = next(t for t in remaining if t["id"] == "parent1")
        assert "[Folded from: Add import]" in parent_task["notes"]

    def test_report_records_fold_into_parent(self) -> None:
        parent = _nontrivial_task("p1", task_type="epic")
        child = _trivial_task("c1", title="Bump version", parent_id="p1")

        _, report = merge_trivial_tasks([parent, child])

        assert len(report) == 1
        assert report[0]["task_id"] == "c1"
        assert report[0]["destination_id"] == "p1"
        assert "parent" in report[0]["reason"]

    def test_fold_preserves_description_in_notes(self) -> None:
        parent = _nontrivial_task("p1", task_type="epic")
        child = _task(
            "c1",
            title="Update config",
            description="fix a small thing",
            parent_id="p1",
        )
        # Confirm this task is trivial
        assert compute_complexity_score(child) < COMPLEXITY_THRESHOLD

        remaining, _ = merge_trivial_tasks([parent, child])

        parent_task = next(t for t in remaining if t["id"] == "p1")
        assert "fix a small thing" in parent_task["notes"]

    def test_multiple_children_folded_into_same_parent(self) -> None:
        parent = _nontrivial_task("p1", task_type="epic")
        child1 = _trivial_task("c1", title="Add import A", parent_id="p1")
        child2 = _trivial_task("c2", title="Add import B", parent_id="p1")

        remaining, report = merge_trivial_tasks([parent, child1, child2])

        remaining_ids = {t["id"] for t in remaining}
        assert "c1" not in remaining_ids
        assert "c2" not in remaining_ids
        assert "p1" in remaining_ids
        assert len(report) == 2

        parent_task = next(t for t in remaining if t["id"] == "p1")
        assert "[Folded from: Add import A]" in parent_task["notes"]
        assert "[Folded from: Add import B]" in parent_task["notes"]


# ── 4. Fold into dependent ──────────────────────────────────────────


class TestFoldIntoDependent:
    def test_trivial_task_folds_into_dependent(self) -> None:
        """A trivial task with no parent but a dependent folds into that dependent."""
        trivial = _trivial_task("t1", title="Add config entry")
        dependent = _nontrivial_task("t2", dependencies=["t1"])

        remaining, report = merge_trivial_tasks([trivial, dependent])

        remaining_ids = {t["id"] for t in remaining}
        assert "t1" not in remaining_ids
        assert "t2" in remaining_ids
        assert len(report) == 1
        assert report[0]["task_id"] == "t1"
        assert report[0]["destination_id"] == "t2"
        assert "dependent" in report[0]["reason"]

    def test_trivial_no_parent_no_dependent_kept(self) -> None:
        """A trivial task with no parent AND no dependents is kept."""
        trivial = _trivial_task("orphan", title="Fix typo")

        remaining, report = merge_trivial_tasks([trivial])

        assert len(remaining) == 1
        assert remaining[0]["id"] == "orphan"
        assert report == []

    def test_fold_notes_appended_to_dependent(self) -> None:
        trivial = _trivial_task("t1", title="Pin version")
        dependent = _nontrivial_task("t2", dependencies=["t1"])

        remaining, _ = merge_trivial_tasks([trivial, dependent])

        dest = next(t for t in remaining if t["id"] == "t2")
        assert "[Folded from: Pin version]" in (dest.get("notes") or "")


# ── 5. Epic preservation ────────────────────────────────────────────


class TestEpicPreservation:
    def test_epic_never_merged_even_if_trivial(self) -> None:
        """An epic with a trivial-level complexity is never merged."""
        epic = _task(
            "epic1",
            title="Fix",
            description="fix",
            task_type="epic",
        )
        # Verify this would be trivial if it were a task
        assert compute_complexity_score(epic) < COMPLEXITY_THRESHOLD

        remaining, report = merge_trivial_tasks([epic])

        assert len(remaining) == 1
        assert remaining[0]["id"] == "epic1"
        assert report == []

    def test_epic_not_merged_into_parent(self) -> None:
        """Even with a parent_id, an epic is never folded."""
        parent = _nontrivial_task("p1", task_type="epic")
        child_epic = _task(
            "ce1",
            title="Small epic",
            description="fix",
            task_type="epic",
            parent_id="p1",
        )

        remaining, report = merge_trivial_tasks([parent, child_epic])

        remaining_ids = {t["id"] for t in remaining}
        assert "ce1" in remaining_ids
        assert report == []


# ── 6. Dependency rewiring ──────────────────────────────────────────


class TestDependencyRewiring:
    def test_dependents_inherit_removed_tasks_dependencies(self) -> None:
        """A -> B(trivial) -> C: when B is removed, C should depend on A."""
        a = _nontrivial_task("A")
        b = _trivial_task("B", dependencies=["A"], parent_id="A")
        c = _nontrivial_task("C", dependencies=["B"])

        remaining, report = merge_trivial_tasks([a, b, c])

        remaining_map = {t["id"]: t for t in remaining}
        assert "B" not in remaining_map
        # C should now depend on A (transitively from B)
        assert "A" in remaining_map["C"]["dependencies"]
        assert "B" not in remaining_map["C"]["dependencies"]

    def test_dependency_rewiring_no_self_reference(self) -> None:
        """Rewiring should not create a task depending on itself."""
        a = _trivial_task("A", parent_id="B")
        b = _nontrivial_task("B", dependencies=["A"])

        remaining, _ = merge_trivial_tasks([a, b])

        remaining_map = {t["id"]: t for t in remaining}
        assert "B" in remaining_map
        # B should not depend on itself
        assert "B" not in remaining_map["B"]["dependencies"]

    def test_dependency_rewiring_deduplicates(self) -> None:
        """If rewiring creates duplicate deps, they are deduplicated."""
        a = _nontrivial_task("A")
        b = _trivial_task("B", dependencies=["A"], parent_id="A")
        c = _nontrivial_task("C", dependencies=["A", "B"])

        remaining, _ = merge_trivial_tasks([a, b, c])

        remaining_map = {t["id"]: t for t in remaining}
        # C depended on A and B. B is removed, B's deps=[A]. So C should have [A] (deduplicated)
        assert remaining_map["C"]["dependencies"] == ["A"]

    def test_chain_rewiring(self) -> None:
        """A -> B(trivial) -> C(trivial) -> D: after cascading, D depends on A."""
        a = _nontrivial_task("A")
        b = _trivial_task("B", dependencies=["A"], parent_id="A")
        c = _trivial_task("C", dependencies=["B"], parent_id="A")
        d = _nontrivial_task("D", dependencies=["C"])

        remaining, report = merge_trivial_tasks([a, b, c, d])

        remaining_map = {t["id"]: t for t in remaining}
        assert "B" not in remaining_map
        assert "C" not in remaining_map
        # D should ultimately depend on A
        assert "A" in remaining_map["D"]["dependencies"]
        assert len(report) == 2


# ── 7. Cycle detection ──────────────────────────────────────────────


class TestCycleDetection:
    def test_cyclic_trivial_tasks_skipped(self) -> None:
        """Tasks in a dependency cycle are never merged, even if trivial."""
        a = _trivial_task("A", dependencies=["B"], parent_id="X")
        b = _trivial_task("B", dependencies=["A"], parent_id="X")
        x = _nontrivial_task("X", task_type="epic")

        remaining, report = merge_trivial_tasks([a, b, x])

        remaining_ids = {t["id"] for t in remaining}
        # Both A and B should be kept because they're in a cycle
        assert "A" in remaining_ids
        assert "B" in remaining_ids
        assert report == []

    def test_non_cyclic_trivial_still_merged(self) -> None:
        """Non-cyclic trivial tasks are still merged even when cycles exist elsewhere."""
        # Cycle between A and B
        a = _trivial_task("A", dependencies=["B"])
        b = _trivial_task("B", dependencies=["A"])
        # Non-cyclic trivial task with a parent
        parent = _nontrivial_task("P", task_type="epic")
        orphan = _trivial_task("C", title="Add import", parent_id="P")

        remaining, report = merge_trivial_tasks([a, b, parent, orphan])

        remaining_ids = {t["id"] for t in remaining}
        # A and B kept (cyclic)
        assert "A" in remaining_ids
        assert "B" in remaining_ids
        # C should be merged into P
        assert "C" not in remaining_ids
        assert len(report) == 1


# ── 8. Cascading merges ─────────────────────────────────────────────


class TestCascadingMerges:
    def test_chain_of_trivial_tasks_merged(self) -> None:
        """A chain of trivial tasks should be handled across multiple passes."""
        parent = _nontrivial_task("P", task_type="epic")
        # Chain: t1 -> t2 -> t3, all trivial, all under parent P
        t1 = _trivial_task("t1", title="Step 1", parent_id="P")
        t2 = _trivial_task("t2", title="Step 2", parent_id="P", dependencies=["t1"])
        t3 = _trivial_task("t3", title="Step 3", parent_id="P", dependencies=["t2"])

        remaining, report = merge_trivial_tasks([parent, t1, t2, t3])

        remaining_ids = {t["id"] for t in remaining}
        assert "t1" not in remaining_ids
        assert "t2" not in remaining_ids
        assert "t3" not in remaining_ids
        assert "P" in remaining_ids
        assert len(report) == 3

    def test_multi_pass_converges(self) -> None:
        """Multi-pass loop terminates and produces stable output."""
        parent = _nontrivial_task("P", task_type="epic")
        tasks = [parent]
        # Create a chain of 5 trivial tasks
        for i in range(5):
            deps = [f"triv{i - 1}"] if i > 0 else []
            tasks.append(
                _trivial_task(
                    f"triv{i}",
                    title=f"Step {i}",
                    parent_id="P",
                    dependencies=deps,
                )
            )

        remaining, report = merge_trivial_tasks(tasks)

        remaining_ids = {t["id"] for t in remaining}
        for i in range(5):
            assert f"triv{i}" not in remaining_ids
        assert "P" in remaining_ids


# ── 9. Idempotency ──────────────────────────────────────────────────


class TestIdempotency:
    def test_calling_twice_produces_same_result(self) -> None:
        parent = _nontrivial_task("P", task_type="epic")
        child = _trivial_task("c1", title="Add import", parent_id="P")

        result1_tasks, result1_report = merge_trivial_tasks([parent, child])
        result2_tasks, result2_report = merge_trivial_tasks(result1_tasks)

        # Second call should produce no additional merges
        assert result2_report == []
        # Task sets should be identical
        ids1 = {t["id"] for t in result1_tasks}
        ids2 = {t["id"] for t in result2_tasks}
        assert ids1 == ids2

    def test_already_merged_graph_stable(self) -> None:
        """A graph with only non-trivial tasks is stable under repeated calls."""
        tasks = [
            _nontrivial_task("t1"),
            _nontrivial_task("t2", dependencies=["t1"]),
        ]

        for _ in range(3):
            tasks, report = merge_trivial_tasks(tasks)
            assert report == []
            assert len(tasks) == 2


# ── 10. None notes normalisation ────────────────────────────────────


class TestNoneNotesNormalisation:
    def test_none_notes_on_destination_no_error(self) -> None:
        """Parent with notes=None should not cause an error when folding."""
        parent = _nontrivial_task("P", task_type="epic")
        assert parent.get("notes") is None  # Confirm notes is None
        child = _trivial_task("c1", title="Fix typo", parent_id="P")

        remaining, report = merge_trivial_tasks([parent, child])

        parent_task = next(t for t in remaining if t["id"] == "P")
        assert "[Folded from: Fix typo]" in parent_task["notes"]
        assert len(report) == 1

    def test_existing_notes_preserved(self) -> None:
        """Pre-existing notes on the destination are preserved, not overwritten."""
        parent = _nontrivial_task("P", task_type="epic")
        parent["notes"] = "Existing important note"
        child = _trivial_task("c1", title="Fix typo", parent_id="P")

        remaining, _ = merge_trivial_tasks([parent, child])

        parent_task = next(t for t in remaining if t["id"] == "P")
        assert "Existing important note" in parent_task["notes"]
        assert "[Folded from: Fix typo]" in parent_task["notes"]

    def test_empty_string_notes_handled(self) -> None:
        """Destination with empty-string notes works correctly."""
        parent = _nontrivial_task("P", task_type="epic")
        parent["notes"] = ""
        child = _trivial_task("c1", title="Fix", parent_id="P")

        remaining, _ = merge_trivial_tasks([parent, child])

        parent_task = next(t for t in remaining if t["id"] == "P")
        assert "[Folded from: Fix]" in parent_task["notes"]


# ── 11. Custom threshold ────────────────────────────────────────────


class TestCustomThreshold:
    def test_threshold_overrides_default(self) -> None:
        """A high threshold makes more tasks trivial."""
        # This task would NOT be trivial at the default threshold (score ~2-3)
        task = _task(
            "t1",
            title="Update logging config",
            description="Update the logging configuration for the service",
            parent_id="P",
        )
        parent = _nontrivial_task("P", task_type="epic")

        # With default threshold, the task might not be trivial
        score = compute_complexity_score(task)

        # Use a threshold higher than the score
        remaining, report = merge_trivial_tasks([parent, task], threshold=score + 1)

        remaining_ids = {t["id"] for t in remaining}
        assert "t1" not in remaining_ids
        assert len(report) == 1

    def test_very_low_threshold_merges_nothing(self) -> None:
        """threshold=0 means nothing is trivial (all scores >= 1.0)."""
        parent = _nontrivial_task("P", task_type="epic")
        child = _trivial_task("c1", parent_id="P")

        remaining, report = merge_trivial_tasks([parent, child], threshold=0)

        # Nothing should be merged because no score < 0
        assert len(remaining) == 2
        assert report == []

    def test_threshold_one_merges_nothing(self) -> None:
        """threshold=1.0 means nothing is trivial since min score is 1.0."""
        parent = _nontrivial_task("P", task_type="epic")
        child = _trivial_task("c1", parent_id="P")

        remaining, report = merge_trivial_tasks([parent, child], threshold=1.0)

        # score >= 1.0 always, so score < 1.0 is never true
        assert len(remaining) == 2
        assert report == []

    def test_very_high_threshold_merges_almost_all(self) -> None:
        """threshold=11.0 makes all non-epic tasks trivial."""
        parent = _nontrivial_task("P", task_type="epic")
        child = _nontrivial_task("c1", parent_id="P")

        remaining, report = merge_trivial_tasks([parent, child], threshold=11.0)

        remaining_ids = {t["id"] for t in remaining}
        assert "c1" not in remaining_ids
        assert len(report) == 1


# ── Additional edge cases ───────────────────────────────────────────


class TestInputNotMutated:
    """merge_trivial_tasks should not mutate the input list."""

    def test_original_tasks_unchanged(self) -> None:
        parent = _nontrivial_task("P", task_type="epic")
        child = _trivial_task("c1", title="Fix typo", parent_id="P")
        tasks = [parent, child]
        original = copy.deepcopy(tasks)

        merge_trivial_tasks(tasks)

        assert tasks == original


class TestMergeReportFormat:
    """Verify merge_report entries have the expected keys."""

    def test_report_entry_keys(self) -> None:
        parent = _nontrivial_task("P", task_type="epic")
        child = _trivial_task("c1", title="Fix", parent_id="P")

        _, report = merge_trivial_tasks([parent, child])

        assert len(report) == 1
        entry = report[0]
        assert "task_id" in entry
        assert "title" in entry
        assert "destination_id" in entry
        assert "reason" in entry
        assert entry["task_id"] == "c1"
        assert entry["title"] == "Fix"
        assert entry["destination_id"] == "P"


class TestParentPreference:
    """When both parent and dependent exist, parent is preferred."""

    def test_parent_preferred_over_dependent(self) -> None:
        parent = _nontrivial_task("P", task_type="epic")
        child = _trivial_task("c1", title="Fix", parent_id="P")
        dependent = _nontrivial_task("D", dependencies=["c1"])

        _, report = merge_trivial_tasks([parent, child, dependent])

        assert len(report) == 1
        # Should fold into parent, not dependent
        assert report[0]["destination_id"] == "P"
