"""Tests for scripts/proposal_builder.py

All tests are unit tests — no network calls, no external assets.

Coverage:
- Full sample data → all sections render; sidebar nav; valid HTML structure.
- Partial data → only present sections render; absent sections leave no headers/placeholders.
- Palette assertion: warm-neutral tokens present; ASU maroon/gold not used as chrome.
- Framing assertion: banned phrases absent from output.
- Escaping: user text containing <script> is HTML-escaped, not injected as live markup.
- Each section renderer is unit-tested with minimal input.
"""

from __future__ import annotations

import os
import sys

import pytest

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

import proposal_builder as pb


# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------

SAMPLE_DATA: dict = {
    "course": {
        "title": "Applied Multivariate Analysis",
        "code": "STP 533",
        "term": "Fall A 2026",
        "version": "v1",
        "date": "2026-05-28",
        "about": "A plan to convert STP 533 to a 7.5-week ASU Online course.",
    },
    "parameters": [
        {"label": "Session", "value": "Fall A 2026 — 7.5 weeks"},
        {"label": "Proctoring", "value": "None. Take-home assessment throughout."},
    ],
    "at_a_glance": {
        "now": ["17 modules of reference material", "No objectives anywhere"],
        "next": ["Fresh captioned R-primary videos", "Measurable course outcomes"],
    },
    "outcomes": [
        "Select and justify an appropriate multivariate method.",
        "Verify the assumptions a chosen method depends on.",
        "Carry out multivariate analyses in R.",
    ],
    "assessment": {
        "summary": "Project-centered assessment with applied deliverables.",
        "weighting": [
            {"item": "Applied Project", "pct": 40},
            {"item": "Homework / R labs", "pct": 25},
            {"item": "Graded discussions", "pct": 10},
        ],
        "note": "No proctoring. Take-home applied assessment throughout.",
    },
    "module_map": {
        "columns": ["Wk", "Module", "Outcomes", "Checkpoint"],
        "rows": [
            ["1", "M1 — Multivariate foundations", "CLO1, CLO2", "HW 1"],
            ["2", "M2 — Hotelling T² & MANOVA", "CLO1, CLO3", "HW 2"],
        ],
    },
    "decision_points": [
        {
            "title": "Scope cuts",
            "detail": "Repeated measures MANOVA is a cut candidate.",
            "recommendation": "Drop repeated measures; retain canonical correlation as optional.",
        }
    ],
    "timeline": [
        {"when": "Now → sign-off", "what": "Scope decisions per Section 5"},
        {"when": "Week 1–2", "what": "Objectives + module outlines"},
    ],
}


# ---------------------------------------------------------------------------
# Full sample data tests
# ---------------------------------------------------------------------------

class TestFullSampleData:
    def setup_method(self):
        self.html = pb.build_proposal(SAMPLE_DATA)

    def test_returns_string(self):
        assert isinstance(self.html, str)
        assert len(self.html) > 500

    def test_valid_html_structure(self):
        assert self.html.count("<html") == 1
        assert self.html.count("</html>") == 1
        assert "<head>" in self.html
        assert "<body>" in self.html

    def test_cover_section_rendered(self):
        assert 'id="cover"' in self.html
        assert "Applied Multivariate Analysis" in self.html
        assert "STP 533" in self.html

    def test_parameters_section_rendered(self):
        assert 'id="parameters"' in self.html
        assert "Fall A 2026" in self.html
        assert "Proctoring" in self.html

    def test_at_a_glance_section_rendered(self):
        assert 'id="at-a-glance"' in self.html
        assert "17 modules" in self.html
        assert "Fresh captioned" in self.html

    def test_outcomes_section_rendered(self):
        assert 'id="outcomes"' in self.html
        assert "Select and justify" in self.html

    def test_assessment_section_rendered(self):
        assert 'id="assessment"' in self.html
        assert "Applied Project" in self.html
        assert "40%" in self.html

    def test_module_map_section_rendered(self):
        assert 'id="module-map"' in self.html
        assert "M1 — Multivariate foundations" in self.html

    def test_decision_points_section_rendered(self):
        assert 'id="decision-points"' in self.html
        assert "Scope cuts" in self.html

    def test_timeline_section_rendered(self):
        assert 'id="timeline"' in self.html
        assert "Now → sign-off" in self.html

    def test_sidebar_nav_lists_all_sections(self):
        # Each rendered section's anchor should have a corresponding nav link
        for anchor in ["#cover", "#parameters", "#at-a-glance", "#outcomes",
                       "#assessment", "#module-map", "#decision-points", "#timeline"]:
            assert anchor in self.html, f"Expected nav link to {anchor}"

    def test_sidebar_nav_uses_nav_element(self):
        assert '<nav class="toc"' in self.html

    def test_page_title_reflects_course(self):
        assert "<title>" in self.html
        assert "STP 533" in self.html or "Applied Multivariate Analysis" in self.html


# ---------------------------------------------------------------------------
# Partial data tests
# ---------------------------------------------------------------------------

class TestPartialData:
    def test_only_course_and_outcomes(self):
        data = {
            "course": {"title": "Stats 101", "code": "STP 101"},
            "outcomes": ["Understand basic statistics."],
        }
        html_out = pb.build_proposal(data)

        # Present sections should render
        assert 'id="cover"' in html_out
        assert "Stats 101" in html_out
        assert 'id="outcomes"' in html_out
        assert "Understand basic statistics" in html_out

        # Absent sections should NOT appear
        assert 'id="parameters"' not in html_out
        assert 'id="at-a-glance"' not in html_out
        assert 'id="assessment"' not in html_out
        assert 'id="module-map"' not in html_out
        assert 'id="decision-points"' not in html_out
        assert 'id="timeline"' not in html_out

    def test_absent_sections_produce_no_broken_placeholders(self):
        data = {"course": {"title": "Minimal Course"}}
        html_out = pb.build_proposal(data)

        # No unreplaced template placeholder tokens should remain
        assert "{{" not in html_out
        assert "}}" not in html_out

    def test_absent_section_produces_no_empty_headers(self):
        """When parameters is absent, the 'Proposal parameters' h2 must not appear."""
        data = {"course": {"title": "No Parameters Course"}}
        html_out = pb.build_proposal(data)
        assert "Proposal parameters" not in html_out
        assert "Assessment architecture" not in html_out

    def test_nav_lists_only_present_sections(self):
        data = {
            "course": {"title": "Partial Course"},
            "timeline": [{"when": "Now", "what": "Do stuff"}],
        }
        html_out = pb.build_proposal(data)
        assert "#timeline" in html_out
        assert "#parameters" not in html_out
        assert "#outcomes" not in html_out

    def test_empty_data_dict(self):
        """build_proposal({}) must return valid HTML without crashing."""
        html_out = pb.build_proposal({})
        assert "<html" in html_out
        assert "{{" not in html_out


# ---------------------------------------------------------------------------
# Palette assertion
# ---------------------------------------------------------------------------

class TestPalette:
    def setup_method(self):
        self.html = pb.build_proposal(SAMPLE_DATA)

    def test_warm_neutral_cream_present(self):
        assert "#F7F2EA" in self.html

    def test_warm_neutral_sienna_present(self):
        assert "#9A5B3F" in self.html

    def test_warm_neutral_paper_present(self):
        assert "#FFFEFB" in self.html

    def test_warm_neutral_ink_present(self):
        assert "#2A2521" in self.html

    def test_asu_maroon_not_used_as_chrome(self):
        """#8C1D40 (ASU maroon) must not appear in the proposal chrome/CSS.
        This is a non-negotiable constraint — see docs/plan-proposal-mode.md."""
        assert "#8C1D40" not in self.html

    def test_asu_gold_not_used_as_chrome(self):
        """#FFC627 (ASU gold) must not appear in the proposal chrome/CSS.
        This is a non-negotiable constraint — see docs/plan-proposal-mode.md."""
        assert "#FFC627" not in self.html


# ---------------------------------------------------------------------------
# Framing / banned-phrase assertion
# ---------------------------------------------------------------------------

class TestNeutralFraming:
    def setup_method(self):
        self.html = pb.build_proposal(SAMPLE_DATA)

    def test_no_faculty_review_phrase(self):
        """'for your review and approval' must not appear — neutral proposal tone."""
        assert "for your review and approval" not in self.html.lower()

    def test_no_keep_modify_or_replace_phrase(self):
        """'keep, modify, or replace' is faculty-review deferential — must not appear."""
        assert "keep, modify, or replace" not in self.html.lower()

    def test_no_your_calls_phrase(self):
        """'your calls' is faculty-deferential shorthand — must not appear."""
        assert "your calls" not in self.html.lower()

    def test_no_ai_attribution_badge(self):
        """The proposal must NOT carry 'Drafted by Claude' or any AI badge.
        Deliberate exception to quiz-mockup LLM-attribution principle —
        documented in skills/course-mockup/SKILL.md Proposal Mode section."""
        assert "Drafted by Claude" not in self.html
        assert "drafted by claude" not in self.html.lower()
        assert "Generated by Claude" not in self.html
        assert "generated by claude" not in self.html.lower()


# ---------------------------------------------------------------------------
# HTML escaping / XSS prevention
# ---------------------------------------------------------------------------

class TestEscaping:
    def test_script_tag_in_outcome_is_escaped(self):
        """User-supplied text with <script> must not inject live markup."""
        data = {
            "course": {"title": "Security Test"},
            "outcomes": ["<script>alert(1)</script>"],
        }
        html_out = pb.build_proposal(data)

        # The literal <script> tag must NOT appear as live HTML
        assert "<script>alert(1)</script>" not in html_out
        # The escaped form must be present
        assert "&lt;script&gt;" in html_out

    def test_script_tag_in_course_title_is_escaped(self):
        data = {"course": {"title": "<script>alert('xss')</script>"}}
        html_out = pb.build_proposal(data)
        assert "<script>alert" not in html_out
        assert "&lt;script&gt;" in html_out

    def test_script_tag_in_parameter_value_is_escaped(self):
        data = {
            "course": {"title": "Test"},
            "parameters": [{"label": "Safe Label", "value": "<script>evil()</script>"}],
        }
        html_out = pb.build_proposal(data)
        assert "<script>evil" not in html_out
        assert "&lt;script&gt;" in html_out

    def test_script_tag_in_decision_point_detail_is_escaped(self):
        data = {
            "course": {"title": "Test"},
            "decision_points": [
                {
                    "title": "A Decision",
                    "detail": "<script>steal_token()</script>",
                    "recommendation": "Safe text",
                }
            ],
        }
        html_out = pb.build_proposal(data)
        assert "<script>steal_token" not in html_out

    def test_html_entities_in_timeline_escaped(self):
        data = {
            "course": {"title": "Test"},
            "timeline": [{"when": "Week 1", "what": '<img src=x onerror="alert(1)">'}],
        }
        html_out = pb.build_proposal(data)
        assert 'onerror="alert(1)"' not in html_out


# ---------------------------------------------------------------------------
# Unit tests for individual section renderers
# ---------------------------------------------------------------------------

class TestRenderCover:
    def test_returns_none_for_empty_dict(self):
        assert pb.render_cover({}) is None

    def test_returns_tuple_with_anchor_and_html(self):
        result = pb.render_cover({"title": "My Course", "code": "TST 101"})
        assert result is not None
        anchor, nav_label, fragment = result
        assert anchor == "cover"
        assert "My Course" in fragment
        assert "TST 101" in fragment

    def test_about_rendered_when_present(self):
        result = pb.render_cover({"title": "C", "about": "This is the about text."})
        assert result is not None
        assert "This is the about text." in result[2]

    def test_meta_row_has_term_when_present(self):
        result = pb.render_cover({"title": "C", "term": "Spring 2026"})
        assert result is not None
        assert "Spring 2026" in result[2]


class TestRenderParameters:
    def test_returns_none_for_empty_list(self):
        assert pb.render_parameters([]) is None

    def test_returns_none_for_none(self):
        assert pb.render_parameters(None) is None  # type: ignore[arg-type]

    def test_renders_label_value_pairs(self):
        result = pb.render_parameters([{"label": "Session", "value": "Fall A"}])
        assert result is not None
        assert "Session" in result[2]
        assert "Fall A" in result[2]

    def test_anchor_is_parameters(self):
        result = pb.render_parameters([{"label": "X", "value": "Y"}])
        assert result[0] == "parameters"


class TestRenderAtAGlance:
    def test_returns_none_for_empty_dict(self):
        assert pb.render_at_a_glance({}) is None

    def test_renders_now_and_next(self):
        result = pb.render_at_a_glance({"now": ["Item A"], "next": ["Item B"]})
        assert result is not None
        assert "Item A" in result[2]
        assert "Item B" in result[2]

    def test_anchor_is_at_a_glance(self):
        result = pb.render_at_a_glance({"now": ["X"], "next": ["Y"]})
        assert result[0] == "at-a-glance"


class TestRenderOutcomes:
    def test_returns_none_for_empty_list(self):
        assert pb.render_outcomes([]) is None

    def test_renders_outcome_text(self):
        result = pb.render_outcomes(["Do analysis.", "Write report."])
        assert result is not None
        assert "Do analysis." in result[2]
        assert "Write report." in result[2]

    def test_anchor_is_outcomes(self):
        result = pb.render_outcomes(["X"])
        assert result[0] == "outcomes"


class TestRenderAssessment:
    def test_returns_none_for_empty_dict(self):
        assert pb.render_assessment({}) is None

    def test_renders_summary_and_weighting(self):
        result = pb.render_assessment({
            "summary": "Project-centered.",
            "weighting": [{"item": "Project", "pct": 50}],
        })
        assert result is not None
        assert "Project-centered." in result[2]
        assert "Project" in result[2]
        assert "50%" in result[2]

    def test_anchor_is_assessment(self):
        result = pb.render_assessment({"summary": "X"})
        assert result[0] == "assessment"


class TestRenderModuleMap:
    def test_returns_none_for_empty_dict(self):
        assert pb.render_module_map({}) is None

    def test_renders_columns_and_rows(self):
        result = pb.render_module_map({
            "columns": ["Wk", "Module"],
            "rows": [["1", "M1 Intro"]],
        })
        assert result is not None
        assert "Wk" in result[2]
        assert "M1 Intro" in result[2]

    def test_anchor_is_module_map(self):
        result = pb.render_module_map({"columns": ["A"], "rows": [["1"]]})
        assert result[0] == "module-map"


class TestRenderDecisionPoints:
    def test_returns_none_for_empty_list(self):
        assert pb.render_decision_points([]) is None

    def test_renders_title_detail_recommendation(self):
        result = pb.render_decision_points([{
            "title": "Scope",
            "detail": "Cut option.",
            "recommendation": "Recommend cut.",
        }])
        assert result is not None
        assert "Scope" in result[2]
        assert "Cut option." in result[2]
        assert "Recommend cut." in result[2]

    def test_anchor_is_decision_points(self):
        result = pb.render_decision_points([{"title": "X"}])
        assert result[0] == "decision-points"


class TestRenderTimeline:
    def test_returns_none_for_empty_list(self):
        assert pb.render_timeline([]) is None

    def test_renders_when_and_what(self):
        result = pb.render_timeline([{"when": "Now", "what": "Start building."}])
        assert result is not None
        assert "Now" in result[2]
        assert "Start building." in result[2]

    def test_anchor_is_timeline(self):
        result = pb.render_timeline([{"when": "X", "what": "Y"}])
        assert result[0] == "timeline"


# ---------------------------------------------------------------------------
# Self-contained assertion — no external assets
# ---------------------------------------------------------------------------

class TestSelfContained:
    def test_no_external_font_links(self):
        """The proposal template must not reference fonts.googleapis.com or
        other external CDNs — it must be self-contained."""
        html_out = pb.build_proposal(SAMPLE_DATA)
        assert "fonts.googleapis.com" not in html_out
        assert "fonts.gstatic.com" not in html_out

    def test_no_external_script_src(self):
        """No external script tags allowed — must be self-contained."""
        html_out = pb.build_proposal(SAMPLE_DATA)
        # We allow inline <script> blocks but not src= pointing elsewhere
        import re
        external_scripts = re.findall(r'<script[^>]+src\s*=\s*["\']https?://', html_out)
        assert not external_scripts, f"Found external script refs: {external_scripts}"
