"""Fetch-count dedup tests (issue #216, perf pass).

Three redundant-Canvas-fetch findings were investigated. Items 2 and 3 are
true fail-first tests: the call-count assertion fails against the pre-fix
code (too many calls) and passes after the fix, with a companion assertion
that findings/output are unchanged.

Item 1 (MLO drift re-fetch inside criterion_evaluator's Phase 2 pass) turned
out to already be deduped by an earlier refactor (commit 9ab29517,
2026-05-19, "feat: add scripts/audit_bloom_alignment.py (Phase 2.3)"):
audit_mlo_drift.audit_mlo_drift() only calls Canvas via
fetch_canvas_assessments(), and that helper is called solely from
run_audit() (the CLI entry point) -- never from audit_mlo_drift() itself.
criterion_evaluator.py calls audit_mlo_drift.audit_mlo_drift(course_id=...)
directly (line ~1085), so it resolves MLOs and assessment items entirely
from the on-disk course-config.json, with zero Canvas calls today. The class
below documents/locks in that zero-refetch state as a regression guard; no
production code changed for item 1.
"""

import os
import sys
from unittest.mock import MagicMock

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))

import assurance_audit
import assurance_inventory
import audit_mlo_drift
import audit_rubrics
import canvas_api
import criterion_evaluator


# ===========================================================================
# Item 1: criterion_evaluator Phase 2 MLO drift pass -- already deduped
# ===========================================================================

class TestMloDriftPhase2NoRefetch:
    """audit_mlo_drift.audit_mlo_drift(course_id=...), as called from
    criterion_evaluator._collect_phase2_findings, must never call Canvas.
    """

    def test_phase2_mlo_drift_makes_zero_canvas_calls(self, monkeypatch, tmp_path):
        # Isolate from the real repo's courses/ tree.
        monkeypatch.setattr(audit_mlo_drift, "PLUGIN_ROOT", tmp_path)
        course_dir = tmp_path / "courses" / "99991"
        course_dir.mkdir(parents=True)
        (course_dir / "course-config.json").write_text(
            '{"modules": [{"number": 1, "objectives": '
            '[{"id": "M1.1", "text": "Analyze case data."}]}]}',
            encoding="utf-8",
        )

        fetch_spy = MagicMock(wraps=audit_mlo_drift.fetch_canvas_assessments)
        monkeypatch.setattr(audit_mlo_drift, "fetch_canvas_assessments", fetch_spy)
        list_assignments_spy = MagicMock(return_value=[])
        list_quizzes_spy = MagicMock(return_value=[])
        list_discussions_spy = MagicMock(return_value=[])
        monkeypatch.setattr(canvas_api, "list_assignments", list_assignments_spy)
        monkeypatch.setattr(canvas_api, "list_quizzes", list_quizzes_spy)
        monkeypatch.setattr(canvas_api, "list_discussions", list_discussions_spy)

        cd = {
            "course_id": "99991",
            "assignments": [{"id": 1, "name": "A", "points_possible": 10}],
            "quizzes": [],
            "discussions": [],
            "page_data": {},
        }
        findings = criterion_evaluator._collect_phase2_findings(cd)

        assert fetch_spy.call_count == 0
        assert list_assignments_spy.call_count == 0
        assert list_quizzes_spy.call_count == 0
        assert list_discussions_spy.call_count == 0
        # Sanity: the audit still ran and found the expected drift (module
        # has an MLO but the config carries no assessments field at all).
        assert findings["mlo_drift_findings"]
        assert findings["mlo_drift_findings"][0]["drift_type"] == "missing_assessment"


# ===========================================================================
# Item 2: audit_rubrics standalone Canvas path -- N+1 per-assignment GET
# ===========================================================================

def _index_assignment(aid, name="Assignment", points=30.0, rubric=None):
    """Shape returned by GET /courses/:id/assignments (the index endpoint)."""
    return {
        "id": aid,
        "name": name,
        "points_possible": points,
        "grading_type": "points",
        "published": True,
        "workflow_state": "published",
        "rubric": rubric,
    }


def _mock_rubrics_config(course_id="55501"):
    return {
        "token": "fake",
        "domain": "canvas.test.edu",
        "course_id": str(course_id),
        "base_url": "https://canvas.test.edu/api/v1",
        "course_url": f"https://canvas.test.edu/api/v1/courses/{course_id}",
        "headers": {"Authorization": "Bearer fake"},
    }


class TestAuditRubricsNoNPlusOne:
    """_collect_canvas_assignments() must not GET each assignment
    individually just to learn whether it has a rubric -- the index call
    already carries that field (see alignment_validator.py:354-360 and
    criterion_evaluator.py:283-284, both of which already trust
    list_assignments() for rubric presence without any per-item fetch).
    """

    def test_collect_canvas_assignments_issues_one_call_not_n_plus_one(self, monkeypatch):
        # Unbuilt-course scenario named in the task: 40 gradable assignments,
        # none carrying a rubric in the index response.
        index = [_index_assignment(i, name=f"Assignment {i}", rubric=None) for i in range(1, 41)]

        monkeypatch.setattr(canvas_api, "get_config", MagicMock(return_value=_mock_rubrics_config()))
        list_spy = MagicMock(return_value=index)
        monkeypatch.setattr(canvas_api, "list_assignments", list_spy)
        get_one_spy = MagicMock(side_effect=AssertionError(
            "get_assignment() must not be called per-item -- the index response is authoritative"
        ))
        monkeypatch.setattr(canvas_api, "get_assignment", get_one_spy)

        result = audit_rubrics._collect_canvas_assignments("55501")

        assert list_spy.call_count == 1
        assert get_one_spy.call_count == 0
        assert len(result) == 40

    def test_audit_rubrics_findings_unchanged_when_sourced_from_index_only(self, monkeypatch):
        """Output-equivalence: findings computed through the deduped
        Canvas path (course_id only, index-sourced) match findings computed
        by feeding audit_rubrics() the same index-shaped list directly --
        i.e. the dedup changes call count, not the audit result.
        """
        rubric = [
            {"description": "Analysis", "ratings": [{"description": f"L{i}"} for i in range(4)]},
            {"description": "Evidence", "ratings": [{"description": f"L{i}"} for i in range(4)]},
            {"description": "Communication", "ratings": [{"description": f"L{i}"} for i in range(4)]},
        ]
        index = [
            _index_assignment(1, name="Has Rubric", rubric=rubric),
            _index_assignment(2, name="No Rubric", rubric=None),
        ]

        monkeypatch.setattr(canvas_api, "get_config", MagicMock(return_value=_mock_rubrics_config("55502")))
        monkeypatch.setattr(canvas_api, "list_assignments", MagicMock(return_value=index))
        monkeypatch.setattr(canvas_api, "get_assignment", MagicMock(
            side_effect=AssertionError("get_assignment() must not be called")
        ))

        deduped_findings = audit_rubrics.audit_rubrics(course_id="55502", course_config={})
        direct_findings = audit_rubrics.audit_rubrics(assignments=index)

        assert deduped_findings == direct_findings
        issue_types_by_assignment = {
            f["assignment_id"]: f["issue_type"] for f in deduped_findings
        }
        assert issue_types_by_assignment.get(2) == "missing_rubric"
        assert 1 not in issue_types_by_assignment


# ===========================================================================
# Item 3: assurance course-object double-fetch (course flags + syllabus)
# ===========================================================================

def _mock_assurance_config(course_id="88801"):
    return {
        "token": "fake",
        "domain": "canvas.test.edu",
        "course_id": str(course_id),
        "base_url": "https://canvas.test.edu/api/v1",
        "course_url": f"https://canvas.test.edu/api/v1/courses/{course_id}",
        "headers": {"Authorization": "Bearer fake"},
    }


def _course_flags_response(weighted=False, syllabus_body="<p>policy</p>"):
    resp = MagicMock()
    resp.status_code = 200
    resp.json.return_value = {
        "apply_assignment_group_weights": weighted,
        "name": "Test Course",
        "syllabus_body": syllabus_body,
    }
    return resp


def _run_full_inventory(monkeypatch, groups, course_resp, quizzes=None, discussions=None):
    """Run the real build_inventory() with every Canvas call mocked, mirroring
    the recipe in tests/test_assurance_content_read.py::_run_inventory.
    """
    empty_clos = {"clos": [], "bloom_index": 0, "bloom_level": "Unknown", "source": "none"}

    request_spy = MagicMock(return_value=course_resp)
    monkeypatch.setattr(assurance_inventory.canvas_api, "_request_with_retry", request_spy)
    monkeypatch.setattr(assurance_inventory.canvas_api, "paginated_get", MagicMock(return_value=groups))
    monkeypatch.setattr(assurance_inventory.canvas_api, "list_quizzes", MagicMock(return_value=quizzes or []))
    monkeypatch.setattr(assurance_inventory.canvas_api, "list_discussions", MagicMock(return_value=discussions or []))
    monkeypatch.setattr(assurance_inventory, "_build_module_map", MagicMock(return_value={}))
    monkeypatch.setattr(assurance_inventory.objective_extractor, "extract_course_clos", MagicMock(return_value=empty_clos))
    monkeypatch.setattr(assurance_inventory.objective_extractor, "extract_module_mlos", MagicMock(return_value={}))
    # course_navigator.get_tree is imported lazily inside build_inventory();
    # patch the source module so that local import picks up the stub.
    import course_navigator
    monkeypatch.setattr(course_navigator, "get_tree", MagicMock(return_value=None))

    return request_spy


class TestAssuranceCourseObjectDedup:
    """assurance_inventory._fetch_course_flags and assurance_audit's syllabus
    reader both GET /courses/:id today (one plain, one with
    include[]=syllabus_body). They should collapse into a single call.
    """

    def test_build_inventory_requests_syllabus_alongside_flags(self, monkeypatch):
        groups = []
        course_resp = _course_flags_response(syllabus_body="<p>Use of AI is permitted.</p>")
        request_spy = _run_full_inventory(monkeypatch, groups, course_resp)

        result = assurance_inventory.build_inventory(_mock_assurance_config())

        assert request_spy.call_count == 1
        _method, url, _headers = request_spy.call_args.args
        assert url == _mock_assurance_config()["course_url"]
        assert request_spy.call_args.kwargs.get("params") == {"include[]": "syllabus_body"}
        assert result["syllabus_html"] == "<p>Use of AI is permitted.</p>"

    def test_fetch_syllabus_text_reuses_prefetched_html(self, monkeypatch):
        get_syllabus_spy = MagicMock(side_effect=AssertionError("get_syllabus() must not be called"))
        monkeypatch.setattr(assurance_audit.canvas_api, "get_syllabus", get_syllabus_spy)

        text = assurance_audit._fetch_syllabus_text(
            _mock_assurance_config(), prefetched_html="<p>Generative AI is allowed.</p>"
        )

        assert get_syllabus_spy.call_count == 0
        assert "Generative AI is allowed." in text

    def test_fetch_syllabus_text_falls_back_without_prefetched_html(self, monkeypatch):
        """No prefetched_html (or an old-style caller that omits it) must
        still fetch live, exactly like today -- preserves the 3 existing
        regression tests in test_assurance_audit.py::TestFetchSyllabusHtmlKey.
        """
        get_syllabus_spy = MagicMock(return_value={"html": "<p>fallback content</p>"})
        monkeypatch.setattr(assurance_audit.canvas_api, "get_syllabus", get_syllabus_spy)

        text = assurance_audit._fetch_syllabus_text(_mock_assurance_config())

        assert get_syllabus_spy.call_count == 1
        assert "fallback content" in text

    def test_score_course_end_to_end_single_course_object_fetch(self, monkeypatch):
        """score_course(config) with no pre-built inventory must issue exactly
        one /courses/:id GET (course flags + syllabus combined), not two.
        """
        groups = []
        course_resp = _course_flags_response(
            syllabus_body="<p>Use of generative AI tools is permitted with disclosure.</p>"
        )
        request_spy = _run_full_inventory(monkeypatch, groups, course_resp)
        get_syllabus_spy = MagicMock(side_effect=AssertionError("get_syllabus() must not be called"))
        monkeypatch.setattr(assurance_audit.canvas_api, "get_syllabus", get_syllabus_spy)

        scorecard = assurance_audit.score_course(_mock_assurance_config())

        assert request_spy.call_count == 1
        assert get_syllabus_spy.call_count == 0
        # Output equivalence: the syllabus content from the single combined
        # fetch still drives AIAS declared-presence detection.
        assert scorecard["dimensions"]["D"]["aias"]["declared_presence"] is True
