"""Characterization tests for scripts/link_validator.py.

Locks the CURRENT behavior of:
- _categorize_issue: classifies single link issues into broken_link/broken_image/review/ignored
- categorize_results: aggregates issues + sets link_status/image_status
- _extract_issues: flattens nested Canvas link-validation response
- _extract_slug: extracts page slug from Canvas content URLs
- build_affected_pages: groups issues by source page, keyed by criterion

No HTTP calls — all HTTP is mocked where needed. No real Canvas API calls.
"""

import json
import os
import sys
from unittest.mock import MagicMock, patch

import pytest

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

import link_validator as lv


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _issue(url="https://example.com/broken", reason="unreachable", is_image=False,
           source_name="Test Page", source_type="wiki_page",
           content_url="/courses/123/pages/test-page/edit"):
    return {
        "url": url,
        "reason": reason,
        "image": is_image,
        "source_name": source_name,
        "source_type": source_type,
        "content_url": content_url,
    }


# ---------------------------------------------------------------------------
# _categorize_issue
# ---------------------------------------------------------------------------

class TestCategorizeIssue:
    # --- broken images ---
    def test_image_issue_is_broken_image(self):
        result = lv._categorize_issue(_issue(is_image=True))
        assert result["category"] == "broken_image"
        assert result["severity"] == "fail"

    def test_image_issue_severity_is_fail_regardless_of_reason(self):
        for reason in ("unreachable", "course_mismatch", "unknown"):
            result = lv._categorize_issue(_issue(is_image=True, reason=reason))
            assert result["severity"] == "fail", f"Expected fail for reason={reason}"

    # --- ignored: tel: and mailto: ---
    def test_tel_url_is_ignored(self):
        result = lv._categorize_issue(_issue(url="tel:+15551234567"))
        assert result["category"] == "ignored"
        assert result["severity"] == "ignore"

    def test_mailto_url_is_ignored(self):
        result = lv._categorize_issue(_issue(url="mailto:test@example.com"))
        assert result["category"] == "ignored"
        assert result["severity"] == "ignore"

    # --- FAIL_REASONS ---
    def test_course_mismatch_is_broken_link(self):
        result = lv._categorize_issue(_issue(reason="course_mismatch"))
        assert result["category"] == "broken_link"
        assert result["severity"] == "fail"

    def test_unpublished_item_is_broken_link(self):
        result = lv._categorize_issue(_issue(reason="unpublished_item"))
        assert result["category"] == "broken_link"
        assert result["severity"] == "fail"

    def test_missing_item_is_broken_link(self):
        result = lv._categorize_issue(_issue(reason="missing_item"))
        assert result["category"] == "broken_link"
        assert result["severity"] == "fail"

    def test_deleted_is_broken_link(self):
        result = lv._categorize_issue(_issue(reason="deleted"))
        assert result["category"] == "broken_link"
        assert result["severity"] == "fail"

    # --- unreachable: review ---
    def test_unreachable_external_is_review(self):
        result = lv._categorize_issue(_issue(reason="unreachable",
                                             url="https://external-site.com/page"))
        assert result["category"] == "review"
        assert result["severity"] == "review"

    def test_doi_org_unreachable_is_review(self):
        result = lv._categorize_issue(_issue(reason="unreachable",
                                             url="https://doi.org/10.1234/xyz"))
        assert result["category"] == "review"
        assert result["severity"] == "review"

    # --- unknown reason: review ---
    def test_unknown_reason_is_review(self):
        result = lv._categorize_issue(_issue(reason="some_new_unknown_reason"))
        assert result["category"] == "review"
        assert result["severity"] == "review"

    # --- field passthrough ---
    def test_url_preserved_in_output(self):
        result = lv._categorize_issue(_issue(url="https://test.com/broken"))
        assert result["url"] == "https://test.com/broken"

    def test_source_name_preserved(self):
        result = lv._categorize_issue(_issue(source_name="My Page"))
        assert result["source_name"] == "My Page"

    def test_source_type_preserved(self):
        result = lv._categorize_issue(_issue(source_type="assignment"))
        assert result["source_type"] == "assignment"

    def test_reason_preserved(self):
        result = lv._categorize_issue(_issue(reason="course_mismatch"))
        assert result["reason"] == "course_mismatch"


# ---------------------------------------------------------------------------
# categorize_results
# ---------------------------------------------------------------------------

class TestCategorizeResults:
    def test_empty_list_returns_all_zero_counts(self):
        result = lv.categorize_results([])
        assert result["summary"]["total"] == 0
        assert result["summary"]["broken_links"] == 0
        assert result["summary"]["broken_images"] == 0
        assert result["summary"]["review"] == 0
        assert result["summary"]["ignored"] == 0

    def test_empty_list_link_status_pass(self):
        result = lv.categorize_results([])
        assert result["link_status"] == "PASS"
        assert result["image_status"] == "PASS"

    def test_broken_link_makes_link_status_fail(self):
        issues = [_issue(reason="missing_item")]
        result = lv.categorize_results(issues)
        assert result["link_status"] == "FAIL"

    def test_review_item_only_makes_link_status_review(self):
        issues = [_issue(reason="unreachable")]
        result = lv.categorize_results(issues)
        assert result["link_status"] == "REVIEW"

    def test_broken_image_makes_image_status_fail(self):
        issues = [_issue(is_image=True)]
        result = lv.categorize_results(issues)
        assert result["image_status"] == "FAIL"

    def test_no_broken_images_makes_image_status_pass(self):
        issues = [_issue(reason="unreachable")]
        result = lv.categorize_results(issues)
        assert result["image_status"] == "PASS"

    def test_ignored_links_not_in_broken_count(self):
        issues = [_issue(url="mailto:a@b.com")]
        result = lv.categorize_results(issues)
        assert result["summary"]["broken_links"] == 0
        assert result["summary"]["ignored"] == 1
        assert result["link_status"] == "PASS"

    def test_mixed_issues_counted_correctly(self):
        issues = [
            _issue(reason="missing_item"),          # broken_link
            _issue(is_image=True),                   # broken_image
            _issue(reason="unreachable"),             # review
            _issue(url="tel:+1555"),                  # ignored
        ]
        result = lv.categorize_results(issues)
        s = result["summary"]
        assert s["broken_links"] == 1
        assert s["broken_images"] == 1
        assert s["review"] == 1
        assert s["ignored"] == 1
        assert s["total"] == 4

    def test_broken_link_trumps_review_for_link_status(self):
        issues = [
            _issue(reason="missing_item"),
            _issue(reason="unreachable"),
        ]
        result = lv.categorize_results(issues)
        # Presence of a broken link means FAIL, not REVIEW
        assert result["link_status"] == "FAIL"

    def test_result_has_all_required_keys(self):
        result = lv.categorize_results([])
        for key in ("issues", "broken_links", "broken_images", "review_items",
                    "ignored", "summary", "link_status", "image_status"):
            assert key in result, f"Missing key: {key}"


# ---------------------------------------------------------------------------
# _extract_issues — flattens nested Canvas response
# ---------------------------------------------------------------------------

class TestExtractIssues:
    def test_empty_data_returns_empty_list(self):
        assert lv._extract_issues({}) == []

    def test_no_issues_key_returns_empty_list(self):
        assert lv._extract_issues({"workflow_state": "completed"}) == []

    def test_flattens_nested_invalid_links(self):
        data = {
            "issues": [
                {
                    "name": "Module 1 Overview",
                    "type": "wiki_page",
                    "content_url": "/courses/123/pages/module-1-overview/edit",
                    "invalid_links": [
                        {"url": "https://broken.com", "reason": "unreachable"},
                        {"url": "https://missing.com", "reason": "missing_item"},
                    ],
                }
            ]
        }
        flat = lv._extract_issues(data)
        assert len(flat) == 2

    def test_source_name_inherited_from_parent(self):
        data = {
            "issues": [
                {
                    "name": "My Page",
                    "type": "wiki_page",
                    "content_url": "/courses/123/pages/my-page/edit",
                    "invalid_links": [{"url": "https://broken.com", "reason": "unreachable"}],
                }
            ]
        }
        flat = lv._extract_issues(data)
        assert flat[0]["source_name"] == "My Page"

    def test_source_type_inherited_from_parent(self):
        data = {
            "issues": [
                {
                    "name": "Assignment 1",
                    "type": "assignment",
                    "content_url": "/courses/123/assignments/456",
                    "invalid_links": [{"url": "https://broken.com", "reason": "unreachable"}],
                }
            ]
        }
        flat = lv._extract_issues(data)
        assert flat[0]["source_type"] == "assignment"

    def test_content_url_inherited_from_parent(self):
        data = {
            "issues": [
                {
                    "name": "Page",
                    "type": "wiki_page",
                    "content_url": "/courses/123/pages/slug/edit",
                    "invalid_links": [{"url": "https://broken.com", "reason": "unreachable"}],
                }
            ]
        }
        flat = lv._extract_issues(data)
        assert flat[0]["content_url"] == "/courses/123/pages/slug/edit"

    def test_page_with_no_invalid_links_skipped(self):
        data = {
            "issues": [
                {
                    "name": "Clean Page",
                    "type": "wiki_page",
                    "content_url": "/courses/123/pages/clean/edit",
                    "invalid_links": [],
                }
            ]
        }
        flat = lv._extract_issues(data)
        assert flat == []

    def test_multiple_pages_all_flattened(self):
        data = {
            "issues": [
                {
                    "name": "Page A",
                    "type": "wiki_page",
                    "content_url": "/courses/123/pages/a/edit",
                    "invalid_links": [{"url": "https://broken-a.com", "reason": "unreachable"}],
                },
                {
                    "name": "Page B",
                    "type": "wiki_page",
                    "content_url": "/courses/123/pages/b/edit",
                    "invalid_links": [
                        {"url": "https://broken-b1.com", "reason": "missing_item"},
                        {"url": "https://broken-b2.com", "reason": "deleted"},
                    ],
                },
            ]
        }
        flat = lv._extract_issues(data)
        assert len(flat) == 3

    def test_wrapped_in_results_key(self):
        """Canvas sometimes wraps issues under data['results']['issues']."""
        data = {
            "results": {
                "issues": [
                    {
                        "name": "Page",
                        "type": "wiki_page",
                        "content_url": "/courses/123/pages/slug/edit",
                        "invalid_links": [{"url": "https://broken.com", "reason": "unreachable"}],
                    }
                ]
            }
        }
        flat = lv._extract_issues(data)
        assert len(flat) == 1

    def test_old_flat_format_fallback(self):
        """Old format: issue directly has 'url' + 'reason' (no invalid_links)."""
        data = {
            "issues": [
                {"url": "https://broken.com", "reason": "unreachable", "name": "page"}
            ]
        }
        flat = lv._extract_issues(data)
        assert len(flat) == 1
        assert flat[0]["url"] == "https://broken.com"


# ---------------------------------------------------------------------------
# _extract_slug
# ---------------------------------------------------------------------------

class TestExtractSlug:
    def test_page_url_with_edit_suffix(self):
        result = lv._extract_slug("/courses/123/pages/module-1-overview/edit")
        assert result == "module-1-overview"

    def test_page_url_without_edit_suffix(self):
        result = lv._extract_slug("/courses/123/pages/my-page")
        assert result == "my-page"

    def test_absolute_url_with_pages(self):
        result = lv._extract_slug("https://canvas.asu.edu/courses/12345/pages/some-slug")
        assert result == "some-slug"

    def test_assignment_url_returns_type_plus_id(self):
        result = lv._extract_slug("/courses/123/assignments/67890")
        assert "assignments" in result
        assert "67890" in result

    def test_quiz_url_returns_type_plus_id(self):
        result = lv._extract_slug("/courses/123/quizzes/111")
        assert "quizzes" in result
        assert "111" in result

    def test_empty_string_returns_unknown(self):
        result = lv._extract_slug("")
        assert result == "unknown"

    def test_none_returns_unknown(self):
        result = lv._extract_slug(None)
        assert result == "unknown"

    def test_url_with_query_string_stripped(self):
        result = lv._extract_slug("/courses/123/pages/my-slug?preview=1")
        assert result == "my-slug"


# ---------------------------------------------------------------------------
# build_affected_pages
# ---------------------------------------------------------------------------

class TestBuildAffectedPages:
    def test_returns_dict_with_three_criteria(self):
        result = lv.build_affected_pages([], "https://canvas.test.edu/courses/123")
        assert "B-13.3" in result
        assert "B-13.4" in result
        assert "B-13.5" in result

    def test_empty_issues_all_criteria_empty(self):
        result = lv.build_affected_pages([], "https://canvas.test.edu/courses/123")
        assert result["B-13.3"] == []
        assert result["B-13.4"] == []
        assert result["B-13.5"] == []

    def test_broken_link_appears_in_b13_3(self):
        categorized = [{
            "url": "https://broken.com",
            "reason": "missing_item",
            "image": False,
            "category": "broken_link",
            "severity": "fail",
            "source_url": "/courses/123/pages/test-page/edit",
            "source_name": "Test Page",
            "source_type": "wiki_page",
        }]
        result = lv.build_affected_pages(categorized, "https://canvas.test.edu/courses/123")
        assert len(result["B-13.3"]) == 1

    def test_broken_image_appears_in_b13_5(self):
        categorized = [{
            "url": "https://missing-img.com/img.png",
            "reason": "unreachable",
            "image": True,
            "category": "broken_image",
            "severity": "fail",
            "source_url": "/courses/123/pages/test-page/edit",
            "source_name": "Test Page",
            "source_type": "wiki_page",
        }]
        result = lv.build_affected_pages(categorized, "https://canvas.test.edu/courses/123")
        assert len(result["B-13.5"]) == 1
        assert result["B-13.3"] == []

    def test_unpublished_item_appears_in_b13_4(self):
        categorized = [{
            "url": "/courses/999/pages/other-page",
            "reason": "unpublished_item",
            "image": False,
            "category": "broken_link",
            "severity": "fail",
            "source_url": "/courses/123/pages/test-page/edit",
            "source_name": "Test Page",
            "source_type": "wiki_page",
        }]
        result = lv.build_affected_pages(categorized, "https://canvas.test.edu/courses/123")
        assert len(result["B-13.4"]) == 1

    def test_ignored_issues_excluded(self):
        categorized = [{
            "url": "mailto:a@b.com",
            "reason": "unreachable",
            "image": False,
            "category": "ignored",
            "severity": "ignore",
            "source_url": "/courses/123/pages/test/edit",
            "source_name": "Test",
            "source_type": "wiki_page",
        }]
        result = lv.build_affected_pages(categorized, "https://canvas.test.edu/courses/123")
        assert result["B-13.3"] == []

    def test_affected_page_has_required_fields(self):
        categorized = [{
            "url": "https://broken.com",
            "reason": "missing_item",
            "image": False,
            "category": "broken_link",
            "severity": "fail",
            "source_url": "/courses/123/pages/test-page/edit",
            "source_name": "Test Page",
            "source_type": "wiki_page",
        }]
        result = lv.build_affected_pages(categorized, "https://canvas.test.edu/courses/123")
        page = result["B-13.3"][0]
        for field in ("slug", "title", "url", "issue_summary", "issue_count"):
            assert field in page, f"Missing field: {field}"

    def test_capped_at_10_pages_per_criterion(self):
        # Create 15 broken links from different pages
        categorized = []
        for i in range(15):
            categorized.append({
                "url": f"https://broken-{i}.com",
                "reason": "missing_item",
                "image": False,
                "category": "broken_link",
                "severity": "fail",
                "source_url": f"/courses/123/pages/page-{i}/edit",
                "source_name": f"Page {i}",
                "source_type": "wiki_page",
            })
        result = lv.build_affected_pages(categorized, "https://canvas.test.edu/courses/123")
        # Current behavior: capped at 10
        assert len(result["B-13.3"]) <= 10

    def test_page_title_lookup_from_pages_dict(self):
        categorized = [{
            "url": "https://broken.com",
            "reason": "missing_item",
            "image": False,
            "category": "broken_link",
            "severity": "fail",
            "source_url": "/courses/123/pages/my-slug/edit",
            "source_name": "Original Name",
            "source_type": "wiki_page",
        }]
        pages_dict = {"my-slug": {"title": "Pretty Title From Dict"}}
        result = lv.build_affected_pages(
            categorized, "https://canvas.test.edu/courses/123", pages_dict=pages_dict
        )
        assert result["B-13.3"][0]["title"] == "Pretty Title From Dict"


# ---------------------------------------------------------------------------
# run_validation — mocked HTTP, characterize "already completed" fast path
# ---------------------------------------------------------------------------

class TestRunValidationAlreadyCompleted:
    """Only test the 'already completed' fast path to avoid real polling delays."""

    def _make_completed_response(self, issues=None):
        resp = MagicMock()
        resp.status_code = 200
        resp.json.return_value = {
            "workflow_state": "completed",
            "issues": issues or [],
        }
        return resp

    def test_returns_status_completed_when_already_done(self):
        config = {
            "base_url": "https://canvas.test.edu/api/v1",
            "headers": {"Authorization": "Bearer tok"},
        }
        with patch("link_validator._request_with_retry",
                   return_value=self._make_completed_response()):
            result = lv.run_validation("12345", config=config)
        assert result["status"] == "completed"

    def test_returns_categorized_results_structure(self):
        config = {
            "base_url": "https://canvas.test.edu/api/v1",
            "headers": {"Authorization": "Bearer tok"},
        }
        with patch("link_validator._request_with_retry",
                   return_value=self._make_completed_response()):
            result = lv.run_validation("12345", config=config)
        assert "results" in result
        assert "raw_issues" in result
        assert isinstance(result["raw_issues"], list)

    def test_empty_issues_gives_pass_status(self):
        config = {
            "base_url": "https://canvas.test.edu/api/v1",
            "headers": {"Authorization": "Bearer tok"},
        }
        with patch("link_validator._request_with_retry",
                   return_value=self._make_completed_response()):
            result = lv.run_validation("12345", config=config)
        assert result["results"]["link_status"] == "PASS"
        assert result["results"]["image_status"] == "PASS"
