"""Tests for loom.complexity — complexity scoring heuristics.

Covers:
  - compute_complexity_score with various task shapes
  - is_trivial predicate and threshold edge cases
  - Word-count boundaries, file/subtask boundaries
  - Trivial keyword matching (case-insensitive, substring, single penalty)
  - Action keyword boost
  - Edge cases: empty/None description, missing keys, non-string files
  - Parametrized sweep across all TRIVIAL_KEYWORDS
"""

from __future__ import annotations

import logging

import pytest

from loom.complexity import (
    COMPLEXITY_THRESHOLD,
    TRIVIAL_KEYWORDS,
    compute_complexity_score,
    is_trivial,
)


# ── Tests: compute_complexity_score ──────────────────────────────────


class TestComputeComplexityScore:
    """Core scoring function."""

    def test_empty_task_returns_minimum(self) -> None:
        """An empty dict should produce the minimum score of 1.0."""
        assert compute_complexity_score({}) == 1.0

    def test_none_description_treated_as_empty(self) -> None:
        assert compute_complexity_score({"description": None}) == 1.0

    def test_non_string_description_treated_as_empty(self) -> None:
        assert compute_complexity_score({"description": 12345}) == 1.0

    def test_empty_string_description(self) -> None:
        assert compute_complexity_score({"description": ""}) == 1.0

    def test_whitespace_only_description(self) -> None:
        assert compute_complexity_score({"description": "   "}) == 1.0

    # ── Description word-count bands ─────────────────────────────

    def test_description_0_to_5_words(self) -> None:
        """0-5 words => desc_score=1."""
        task = {"description": "Fix the bug"}
        score = compute_complexity_score(task)
        # desc=1 + no action + no files + no subtasks = 1.0
        assert score == 1.0

    def test_description_exactly_5_words(self) -> None:
        task = {"description": "one two three four five"}
        score = compute_complexity_score(task)
        assert score == 1.0  # desc=1

    def test_description_6_words(self) -> None:
        """6 words => desc_score=2."""
        task = {"description": "one two three four five six"}
        score = compute_complexity_score(task)
        assert score == 2.0  # desc=2

    def test_description_20_words(self) -> None:
        task = {"description": " ".join(f"word{i}" for i in range(20))}
        score = compute_complexity_score(task)
        assert score == 2.0  # desc=2, 20 words is still in 6-20 band

    def test_description_21_words(self) -> None:
        """21 words => desc_score=3."""
        task = {"description": " ".join(f"word{i}" for i in range(21))}
        score = compute_complexity_score(task)
        assert score == 3.0  # desc=3

    def test_description_50_words(self) -> None:
        task = {"description": " ".join(f"word{i}" for i in range(50))}
        score = compute_complexity_score(task)
        assert score == 3.0  # desc=3, 50 words is still in 21-50 band

    def test_description_51_words(self) -> None:
        """51+ words => desc_score=4."""
        task = {"description": " ".join(f"word{i}" for i in range(51))}
        score = compute_complexity_score(task)
        assert score == 4.0  # desc=4

    # ── Action keyword boost ─────────────────────────────────────

    @pytest.mark.parametrize(
        "keyword",
        ["implement", "refactor", "architect", "design", "migrate", "integrate"],
    )
    def test_action_keyword_adds_one(self, keyword: str) -> None:
        task = {"description": f"We need to {keyword} the module properly"}
        score = compute_complexity_score(task)
        # 8 words => desc=2, action=+1, no files, no subtasks = 3.0
        assert score == 3.0

    def test_action_keyword_case_insensitive(self) -> None:
        task = {"description": "IMPLEMENT the new feature now"}
        score = compute_complexity_score(task)
        # 5 words => desc=1, action=+1 = 2.0
        assert score == 2.0

    def test_action_keyword_only_counted_once(self) -> None:
        """Multiple action keywords still only add +1."""
        task = {"description": "implement and refactor and design the system properly here now"}
        score = compute_complexity_score(task)
        # 10 words => desc=2, action=+1 (only once) = 3.0
        assert score == 3.0

    def test_no_action_keyword_no_boost(self) -> None:
        task = {"description": "update the logging configuration for output"}
        score = compute_complexity_score(task)
        # 6 words => desc=2, action=0 = 2.0
        assert score == 2.0

    # ── File count score ─────────────────────────────────────────

    def test_zero_files(self) -> None:
        task = {"description": "a task", "files": []}
        score = compute_complexity_score(task)
        assert score == 1.0  # desc=1, files=0

    def test_one_file(self) -> None:
        task = {"description": "a task", "files": ["a.py"]}
        score = compute_complexity_score(task)
        assert score == 2.0  # desc=1, files=1

    def test_three_files(self) -> None:
        task = {"description": "a task", "files": ["a.py", "b.py", "c.py"]}
        score = compute_complexity_score(task)
        assert score == 2.0  # desc=1, files=1 (1-3 files => 1)

    def test_four_files(self) -> None:
        task = {"description": "a task", "files": ["a.py", "b.py", "c.py", "d.py"]}
        score = compute_complexity_score(task)
        assert score == 3.0  # desc=1, files=2 (4+ files => 2)

    def test_ten_files(self) -> None:
        task = {"description": "a task", "files": [f"{i}.py" for i in range(10)]}
        score = compute_complexity_score(task)
        assert score == 3.0  # desc=1, files=2 (capped at 2)

    def test_missing_files_key(self) -> None:
        """No 'files' key at all => treated as empty list."""
        task = {"description": "a task"}
        score = compute_complexity_score(task)
        assert score == 1.0

    def test_non_string_files_skipped(self) -> None:
        """Non-string entries in files list are ignored."""
        task = {"description": "a task", "files": [123, None, "real.py", True]}
        score = compute_complexity_score(task)
        assert score == 2.0  # desc=1, files=1 (only "real.py" counts)

    def test_files_not_a_list(self) -> None:
        """If files is not a list, treat as empty."""
        task = {"description": "a task", "files": "not_a_list"}
        score = compute_complexity_score(task)
        assert score == 1.0  # desc=1, files=0

    # ── Subtask score ────────────────────────────────────────────

    def test_zero_subtasks(self) -> None:
        task = {"description": "a task", "subtasks": []}
        score = compute_complexity_score(task)
        assert score == 1.0

    def test_one_subtask(self) -> None:
        task = {"description": "a task", "subtasks": ["s1"]}
        score = compute_complexity_score(task)
        assert score == 2.0  # desc=1, subtasks=1

    def test_two_subtasks(self) -> None:
        task = {"description": "a task", "subtasks": ["s1", "s2"]}
        score = compute_complexity_score(task)
        assert score == 2.0  # desc=1, subtasks=1 (1-2 => 1)

    def test_three_subtasks(self) -> None:
        task = {"description": "a task", "subtasks": ["s1", "s2", "s3"]}
        score = compute_complexity_score(task)
        assert score == 3.0  # desc=1, subtasks=2 (3+ => 2)

    def test_missing_subtasks_key(self) -> None:
        task = {"description": "a task"}
        score = compute_complexity_score(task)
        assert score == 1.0

    def test_subtasks_not_a_list(self) -> None:
        task = {"description": "a task", "subtasks": "not_a_list"}
        score = compute_complexity_score(task)
        assert score == 1.0  # subtasks treated as empty

    # ── Trivial keyword penalty ──────────────────────────────────

    def test_trivial_keyword_applies_penalty(self) -> None:
        task = {"description": "add dependency for requests library"}
        score = compute_complexity_score(task)
        # desc=1 (5 words) + trivial=-3 => raw=-2 => clamped to 1.0
        assert score == 1.0

    def test_trivial_keyword_case_insensitive(self) -> None:
        task = {"description": "ADD DEPENDENCY for requests"}
        score = compute_complexity_score(task)
        assert score == 1.0

    def test_trivial_keyword_substring_match(self) -> None:
        """Trivial keywords match as substrings of the description."""
        task = {"description": "We need to add dependency management to the project now"}
        score = compute_complexity_score(task)
        # 11 words => desc=2, trivial=-3 => raw=-1 => clamped to 1.0
        assert score == 1.0

    def test_multiple_trivial_keywords_penalty_once(self) -> None:
        """Even if multiple trivial keywords match, penalty is -3 total, not per keyword."""
        task = {"description": "add dependency and update config for the new version"}
        score = compute_complexity_score(task)
        # 10 words => desc=2, trivial=-3 => raw=-1 => clamped to 1.0
        assert score == 1.0

    def test_custom_trivial_keywords(self) -> None:
        """Custom trivial_keywords override replaces the defaults."""
        task = {"description": "add dependency for requests"}
        # Using custom keywords that DON'T include "add dependency"
        score = compute_complexity_score(task, trivial_keywords=["custom keyword"])
        # desc=1 (5 words), no trivial penalty => 1.0 (still minimum, but no penalty)
        assert score == 1.0

    def test_custom_trivial_keywords_match(self) -> None:
        task = {"description": "this is a custom keyword task with many words"}
        score = compute_complexity_score(task, trivial_keywords=["custom keyword"])
        # 9 words => desc=2, trivial=-3 => raw=-1 => clamped to 1.0
        assert score == 1.0

    def test_empty_trivial_keywords_no_penalty(self) -> None:
        task = {"description": "add dependency for requests library and stuff more words here"}
        score = compute_complexity_score(task, trivial_keywords=[])
        # 10 words => desc=2, no trivial penalty => 2.0
        assert score == 2.0

    @pytest.mark.parametrize("keyword", TRIVIAL_KEYWORDS)
    def test_each_trivial_keyword_triggers_penalty(self, keyword: str) -> None:
        """Every keyword in TRIVIAL_KEYWORDS must trigger the -3 penalty."""
        # Build a description long enough for desc_score >= 2 so the penalty is visible
        task = {"description": f"We need to {keyword} in the project setup now soon"}
        score = compute_complexity_score(task)
        # With desc=2 (8-11 words depending on keyword) + trivial=-3 => clamped to 1.0
        assert score == 1.0

    # ── Clamping ─────────────────────────────────────────────────

    def test_score_never_below_one(self) -> None:
        """Even with maximum penalty, score floors at 1.0."""
        task = {"description": "bump version"}
        score = compute_complexity_score(task)
        assert score >= 1.0

    def test_score_never_above_ten(self) -> None:
        """Maximum possible components: desc=4 + action=1 + files=2 + subtasks=2 = 9.
        That's below 10, but ensure clamping works for safety."""
        task = {
            "description": " ".join(f"implement word{i}" for i in range(100)),
            "files": [f"{i}.py" for i in range(10)],
            "subtasks": [f"s{i}" for i in range(10)],
        }
        score = compute_complexity_score(task)
        assert score <= 10.0

    def test_maximum_score_is_nine(self) -> None:
        """The theoretical maximum is 4+1+2+2 = 9."""
        task = {
            "description": " ".join(["implement"] + [f"word{i}" for i in range(60)]),
            "files": [f"{i}.py" for i in range(5)],
            "subtasks": [f"s{i}" for i in range(5)],
        }
        score = compute_complexity_score(task)
        assert score == 9.0

    # ── Composite / high-complexity tasks ────────────────────────

    def test_high_complexity_task(self) -> None:
        """A task with long description, many files, and subtasks scores >= 7."""
        task = {
            "description": (
                "Implement the new authentication middleware that integrates "
                "with OAuth2 providers, handles token refresh, validates scopes, "
                "and manages session lifecycle across all API endpoints in the "
                "system with proper error handling and logging"
            ),
            "files": ["auth.py", "middleware.py", "oauth.py", "session.py", "api.py"],
            "subtasks": ["design schema", "implement tokens", "add tests"],
        }
        score = compute_complexity_score(task)
        assert score >= 7.0

    def test_medium_complexity_task(self) -> None:
        """A moderate task should land in the middle range."""
        task = {
            "description": "Add error handling to the cache module for timeout scenarios",
            "files": ["cache.py", "errors.py"],
            "subtasks": ["add timeout"],
        }
        score = compute_complexity_score(task)
        assert 2.0 <= score <= 6.0

    def test_trivial_task_scores_low(self) -> None:
        """A task with 'add dependency' should score very low."""
        task = {"description": "add dependency for pydantic"}
        score = compute_complexity_score(task)
        assert score <= 2.0


# ── Tests: is_trivial ────────────────────────────────────────────────


class TestIsTrivial:
    """Predicate built on compute_complexity_score."""

    def test_trivial_when_score_below_threshold(self) -> None:
        """Empty task has score 1.0, default threshold is 2 => trivial."""
        assert is_trivial({}) is True

    def test_not_trivial_when_score_at_threshold(self) -> None:
        """Score == threshold => NOT trivial (strict less-than)."""
        # 6 words => desc=2, total=2.0 which == COMPLEXITY_THRESHOLD
        task = {"description": "one two three four five six"}
        assert is_trivial(task) is False

    def test_not_trivial_when_score_above_threshold(self) -> None:
        task = {
            "description": "implement a full authentication system with many components",
            "files": ["a.py", "b.py"],
            "subtasks": ["s1", "s2", "s3"],
        }
        assert is_trivial(task) is False

    def test_threshold_zero_never_trivial(self) -> None:
        """threshold <= 0 means never trivial."""
        assert is_trivial({}, threshold=0) is False
        assert is_trivial({}, threshold=-1) is False
        assert is_trivial({}, threshold=-100) is False

    def test_threshold_above_ten_always_trivial(self, caplog: pytest.LogCaptureFixture) -> None:
        """threshold > 10 means always trivial, with a warning."""
        big_task = {
            "description": " ".join(["implement"] + [f"word{i}" for i in range(60)]),
            "files": [f"{i}.py" for i in range(5)],
            "subtasks": [f"s{i}" for i in range(5)],
        }
        with caplog.at_level(logging.WARNING, logger="loom.complexity"):
            result = is_trivial(big_task, threshold=11)

        assert result is True
        assert "threshold=11.0" in caplog.text
        assert "all tasks will be considered trivial" in caplog.text

    def test_threshold_exactly_ten_not_always_trivial(self) -> None:
        """threshold=10 is valid (not > 10), so it doesn't trigger the always-trivial path."""
        big_task = {
            "description": " ".join(["implement"] + [f"word{i}" for i in range(60)]),
            "files": [f"{i}.py" for i in range(5)],
            "subtasks": [f"s{i}" for i in range(5)],
        }
        # Score is 9.0, threshold is 10 => score < threshold => trivial
        assert is_trivial(big_task, threshold=10) is True

    def test_custom_threshold(self) -> None:
        """A custom threshold changes the decision boundary."""
        task = {"description": "one two three four five six"}  # score=2.0
        assert is_trivial(task, threshold=3) is True
        assert is_trivial(task, threshold=2) is False
        assert is_trivial(task, threshold=1) is False

    def test_default_threshold_matches_constant(self) -> None:
        assert COMPLEXITY_THRESHOLD == 2


# ── Tests: debug logging ────────────────────────────────────────────


class TestDebugLogging:
    """Verify that per-heuristic breakdowns are logged at DEBUG."""

    def test_debug_log_emitted(self, caplog: pytest.LogCaptureFixture) -> None:
        task = {
            "description": "implement a new feature with testing",
            "files": ["a.py"],
            "subtasks": ["s1"],
        }
        with caplog.at_level(logging.DEBUG, logger="loom.complexity"):
            compute_complexity_score(task)

        assert "complexity score=" in caplog.text
        assert "desc=" in caplog.text
        assert "action=" in caplog.text
        assert "files=" in caplog.text
        assert "subtasks=" in caplog.text
        assert "trivial=" in caplog.text


# ── Tests: boundary conditions ───────────────────────────────────────


class TestBoundaryConditions:
    """Word-count and list-length boundaries."""

    @pytest.mark.parametrize(
        "word_count, expected_desc_score",
        [
            (0, 1.0),
            (1, 1.0),
            (5, 1.0),
            (6, 2.0),
            (20, 2.0),
            (21, 3.0),
            (50, 3.0),
            (51, 4.0),
            (100, 4.0),
        ],
    )
    def test_word_count_boundaries(self, word_count: int, expected_desc_score: float) -> None:
        """Each word-count band maps to the correct description score."""
        desc = " ".join(f"w{i}" for i in range(word_count)) if word_count > 0 else ""
        task = {"description": desc}
        score = compute_complexity_score(task)
        # Score = desc_score (no other components)
        assert score == expected_desc_score

    @pytest.mark.parametrize(
        "file_count, expected_file_score",
        [
            (0, 0.0),
            (1, 1.0),
            (2, 1.0),
            (3, 1.0),
            (4, 2.0),
            (10, 2.0),
        ],
    )
    def test_file_count_boundaries(self, file_count: int, expected_file_score: float) -> None:
        files = [f"f{i}.py" for i in range(file_count)]
        task = {"description": "", "files": files}
        score = compute_complexity_score(task)
        # desc=1 (empty) + file_score
        assert score == max(1.0, 1.0 + expected_file_score)

    @pytest.mark.parametrize(
        "subtask_count, expected_subtask_score",
        [
            (0, 0.0),
            (1, 1.0),
            (2, 1.0),
            (3, 2.0),
            (10, 2.0),
        ],
    )
    def test_subtask_count_boundaries(self, subtask_count: int, expected_subtask_score: float) -> None:
        subtasks = [f"s{i}" for i in range(subtask_count)]
        task = {"description": "", "subtasks": subtasks}
        score = compute_complexity_score(task)
        # desc=1 (empty) + subtask_score
        assert score == max(1.0, 1.0 + expected_subtask_score)
