"""Tests for create_rubric_gated.py and canvas_api rubric helpers.

Covers:
- build_criteria merges cell text into rating description (not long_description)
- build_criteria assigns descending points for correct Canvas column order
- render_preview_html produces one column per level in descending-points order
- update_rubric: deletes, recreates, and re-associates (mock; old id deleted,
  new associations cover same assignment ids + grading flags)
- Gated script refuses to write without a valid confirm verb (no POST called)
- Gated script --dry-run writes a preview file and performs no Canvas write

All canvas_api.get_config / _get_config calls are mocked so the suite passes
token-less in CI (get_config does sys.exit(1) with no CANVAS_TOKEN).
"""

import json
import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch, call

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

import canvas_api
import create_rubric_gated as crg


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _mock_config(course_id="12345"):
    return {
        "token": "fake-token",
        "domain": "canvas.test.edu",
        "course_id": 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-token"},
    }


MINIMAL_SPEC = {
    "title": "Test Rubric",
    "points_per_criterion": 10,
    "criteria": [
        {
            "description": "Criterion A",
            "levels": [
                {"name": "Exemplary", "text": "Outstanding work."},
                {"name": "Proficient", "text": "Meets expectations."},
                {"name": "Developing", "text": "Partially meets."},
                {"name": "Beginning", "text": "Does not meet."},
            ],
        }
    ],
}


# ---------------------------------------------------------------------------
# build_criteria
# ---------------------------------------------------------------------------

class TestBuildCriteria(unittest.TestCase):

    def test_cell_text_in_description_not_long_description(self):
        """Rating description must include the cell text; long_description not set."""
        criteria, level_names = crg.build_criteria(MINIMAL_SPEC)
        rating = criteria[0]["ratings"][0]
        self.assertIn("Outstanding work.", rating["description"])
        self.assertNotIn("long_description", rating)

    def test_level_name_in_description(self):
        """Level name is prepended to cell text in description."""
        criteria, _ = crg.build_criteria(MINIMAL_SPEC)
        self.assertIn("Exemplary", criteria[0]["ratings"][0]["description"])

    def test_descending_points(self):
        """Ratings are assigned in descending point order."""
        criteria, _ = crg.build_criteria(MINIMAL_SPEC)
        pts = [r["points"] for r in criteria[0]["ratings"]]
        self.assertEqual(pts, sorted(pts, reverse=True))

    def test_top_level_equals_points_per(self):
        """Top-level rating has points equal to points_per_criterion."""
        criteria, _ = crg.build_criteria(MINIMAL_SPEC)
        self.assertEqual(criteria[0]["ratings"][0]["points"], 10)

    def test_bottom_level_is_zero(self):
        """Bottom-level (last) rating always has 0 points."""
        criteria, _ = crg.build_criteria(MINIMAL_SPEC)
        self.assertEqual(criteria[0]["ratings"][-1]["points"], 0)

    def test_level_names_returned(self):
        """Level names are returned in spec order."""
        _, level_names = crg.build_criteria(MINIMAL_SPEC)
        self.assertEqual(level_names, ["Exemplary", "Proficient", "Developing", "Beginning"])

    def test_criterion_long_description_preserved(self):
        """long_description on a criterion (not a rating) is passed through."""
        spec = {
            "title": "R",
            "points_per_criterion": 5,
            "criteria": [
                {
                    "description": "C",
                    "long_description": "Detail for the criterion row.",
                    "levels": [
                        {"name": "Good", "text": "Good work."},
                        {"name": "Bad", "text": "Poor work."},
                    ],
                }
            ],
        }
        criteria, _ = crg.build_criteria(spec)
        self.assertEqual(criteria[0]["long_description"], "Detail for the criterion row.")

    def test_no_levels_raises(self):
        """Empty levels list raises ValueError."""
        spec = {
            "title": "R",
            "points_per_criterion": 5,
            "criteria": [{"description": "C", "levels": []}],
        }
        with self.assertRaises(ValueError):
            crg.build_criteria(spec)


# ---------------------------------------------------------------------------
# render_preview_html
# ---------------------------------------------------------------------------

class TestRenderPreviewHtml(unittest.TestCase):

    def setUp(self):
        self.criteria, self.level_names = crg.build_criteria(MINIMAL_SPEC)

    def test_one_column_per_level(self):
        """HTML contains one table header per level name."""
        html = crg.render_preview_html(MINIMAL_SPEC, self.criteria, self.level_names)
        for name in self.level_names:
            self.assertIn(name, html)

    def test_descending_points_order_in_html(self):
        """Column headers appear in descending-points order (Exemplary before Beginning)."""
        html = crg.render_preview_html(MINIMAL_SPEC, self.criteria, self.level_names)
        exemplary_pos = html.index("Exemplary")
        beginning_pos = html.index("Beginning")
        self.assertLess(exemplary_pos, beginning_pos)

    def test_criterion_name_in_html(self):
        """Criterion description appears in the rendered HTML."""
        html = crg.render_preview_html(MINIMAL_SPEC, self.criteria, self.level_names)
        self.assertIn("Criterion A", html)

    def test_no_em_or_en_dashes(self):
        """No em dashes or en dashes in generated HTML."""
        html = crg.render_preview_html(MINIMAL_SPEC, self.criteria, self.level_names)
        self.assertNotIn("—", html)
        self.assertNotIn("–", html)


# ---------------------------------------------------------------------------
# update_rubric (via canvas_api)
# ---------------------------------------------------------------------------

class TestUpdateRubric(unittest.TestCase):

    def _mock_response(self, status, body=None):
        resp = MagicMock()
        resp.status_code = status
        resp.reason = "OK" if status < 400 else "Error"
        resp.text = json.dumps(body or {})
        resp.json.return_value = body or {}
        return resp

    @patch("canvas_api._request_with_retry")
    def test_delete_called_with_old_id(self, mock_req):
        """update_rubric must issue a DELETE for the old rubric id."""
        import requests as req_mod

        existing_assoc_body = {"id": 99, "associations": []}
        mock_req.side_effect = [
            # GET to read existing associations
            self._mock_response(200, existing_assoc_body),
            # DELETE old rubric
            self._mock_response(200, {}),
            # POST create new rubric
            self._mock_response(201, {"rubric": {"id": 200, "title": "New"}}),
        ]

        cfg = _mock_config()
        result = canvas_api.update_rubric(cfg, 99, "New", [
            {"description": "C", "points": 10,
             "ratings": [{"description": "Good", "points": 10},
                         {"description": "Bad", "points": 0}]}
        ])

        self.assertIsNotNone(result)
        # Confirm a DELETE call was made with the old rubric id in the URL
        delete_call = mock_req.call_args_list[1]
        self.assertEqual(delete_call.args[0], req_mod.delete)
        self.assertIn("/rubrics/99", delete_call.args[1])

    @patch("canvas_api._request_with_retry")
    def test_new_associations_use_old_assignment_ids(self, mock_req):
        """update_rubric re-associates to the same assignment ids as the old rubric."""
        import requests as req_mod

        existing_body = {
            "id": 99,
            "associations": [
                {"association_type": "Assignment", "association_id": 555,
                 "use_for_grading": True},
            ],
        }
        mock_req.side_effect = [
            self._mock_response(200, existing_body),       # GET associations
            self._mock_response(200, {}),                   # DELETE
            self._mock_response(201, {"rubric": {"id": 200, "title": "New"}}),  # POST create
            self._mock_response(200, {}),                   # POST associate
        ]

        cfg = _mock_config()
        result = canvas_api.update_rubric(cfg, 99, "New", [
            {"description": "C", "points": 10,
             "ratings": [{"description": "Good", "points": 10},
                         {"description": "Bad", "points": 0}]}
        ])

        self.assertIsNotNone(result)
        # Fourth call should be a POST to rubric_associations with assignment 555
        assoc_call = mock_req.call_args_list[3]
        self.assertEqual(assoc_call.args[0], req_mod.post)
        payload = assoc_call.kwargs.get("json", {})
        self.assertEqual(
            payload.get("rubric_association", {}).get("association_id"), 555
        )

    @patch("canvas_api._request_with_retry")
    def test_use_for_grading_preserved(self, mock_req):
        """update_rubric preserves use_for_grading flag from the old association."""
        import requests as req_mod

        existing_body = {
            "id": 99,
            "associations": [
                {"association_type": "Assignment", "association_id": 777,
                 "use_for_grading": False},
            ],
        }
        mock_req.side_effect = [
            self._mock_response(200, existing_body),
            self._mock_response(200, {}),
            self._mock_response(201, {"rubric": {"id": 201, "title": "N"}}),
            self._mock_response(200, {}),
        ]

        cfg = _mock_config()
        canvas_api.update_rubric(cfg, 99, "N", [
            {"description": "C", "points": 5,
             "ratings": [{"description": "A", "points": 5},
                         {"description": "B", "points": 0}]}
        ])

        assoc_call = mock_req.call_args_list[3]
        payload = assoc_call.kwargs.get("json", {})
        self.assertFalse(payload.get("rubric_association", {}).get("use_for_grading"))

    @patch("canvas_api._request_with_retry")
    def test_returns_none_on_delete_failure(self, mock_req):
        """update_rubric returns None if DELETE fails."""
        def _mock_resp(status, body=None):
            r = MagicMock()
            r.status_code = status
            r.reason = "Error"
            r.text = json.dumps(body or {})
            r.json.return_value = body or {}
            return r

        mock_req.side_effect = [
            _mock_resp(200, {"id": 99, "associations": []}),
            _mock_resp(500, {"error": "server error"}),
        ]
        cfg = _mock_config()
        result = canvas_api.update_rubric(cfg, 99, "New", [])
        self.assertIsNone(result)


# ---------------------------------------------------------------------------
# Gated script: confirm-verb gate
# ---------------------------------------------------------------------------

class TestGatedScriptConfirmVerb(unittest.TestCase):

    def _write_spec(self, tmpdir):
        spec_path = os.path.join(tmpdir, "spec.json")
        with open(spec_path, "w") as f:
            json.dump(MINIMAL_SPEC, f)
        return spec_path

    @patch("create_rubric_gated.get_config")
    @patch("create_rubric_gated.create_rubric")
    def test_refuses_write_without_confirm_verb(self, mock_create, mock_cfg):
        """Script returns non-zero and does not call create_rubric without confirm verb."""
        mock_cfg.return_value = _mock_config()
        with tempfile.TemporaryDirectory() as tmpdir:
            spec_path = self._write_spec(tmpdir)
            preview_path = Path(tmpdir) / "preview.html"
            with patch("create_rubric_gated._stage_preview",
                       return_value=preview_path), \
                 patch("create_rubric_gated._ensure_staging_server",
                       return_value="http://127.0.0.1:8111"):
                rc = crg.main([spec_path])
        self.assertNotEqual(rc, 0)
        mock_create.assert_not_called()

    @patch("create_rubric_gated.get_config")
    @patch("create_rubric_gated.create_rubric")
    def test_refuses_ambiguous_verb(self, mock_create, mock_cfg):
        """Script returns non-zero for ambiguous verbs like 'yes'."""
        mock_cfg.return_value = _mock_config()
        with tempfile.TemporaryDirectory() as tmpdir:
            spec_path = self._write_spec(tmpdir)
            preview_path = Path(tmpdir) / "preview.html"
            # 'yes' is in AMBIGUOUS_VERBS but not in choices, argparse would reject it
            # Test that omitting confirm-verb also rejects
            with patch("create_rubric_gated._stage_preview",
                       return_value=preview_path), \
                 patch("create_rubric_gated._ensure_staging_server",
                       return_value="http://127.0.0.1:8111"):
                rc = crg.main([spec_path])
        self.assertNotEqual(rc, 0)
        mock_create.assert_not_called()

    @patch("create_rubric_gated.get_config")
    @patch("create_rubric_gated._clear_staged")
    @patch("create_rubric_gated.associate_rubric")
    @patch("create_rubric_gated.create_rubric")
    def test_accepts_valid_confirm_verb_push(self, mock_create, mock_assoc,
                                             mock_clear, mock_cfg):
        """Script proceeds with create_rubric when --confirm-verb push is given."""
        mock_cfg.return_value = _mock_config()
        mock_create.return_value = {"rubric": {"id": 42, "title": "Test Rubric"}}
        with tempfile.TemporaryDirectory() as tmpdir:
            spec_path = self._write_spec(tmpdir)
            preview_path = Path(tmpdir) / "preview.html"
            with patch("create_rubric_gated._stage_preview",
                       return_value=preview_path), \
                 patch("create_rubric_gated._ensure_staging_server",
                       return_value="http://127.0.0.1:8111"):
                rc = crg.main([spec_path, "--confirm-verb", "push"])
        self.assertEqual(rc, 0)
        mock_create.assert_called_once()


# ---------------------------------------------------------------------------
# Gated script: dry-run
# ---------------------------------------------------------------------------

class TestGatedScriptDryRun(unittest.TestCase):

    def _write_spec(self, tmpdir):
        spec_path = os.path.join(tmpdir, "spec.json")
        with open(spec_path, "w") as f:
            json.dump(MINIMAL_SPEC, f)
        return spec_path

    @patch("create_rubric_gated.get_config")
    @patch("create_rubric_gated.create_rubric")
    def test_dry_run_writes_preview_file(self, mock_create, mock_cfg):
        """--dry-run stages an HTML preview file."""
        mock_cfg.return_value = _mock_config()
        with tempfile.TemporaryDirectory() as tmpdir:
            preview_path = Path(tmpdir) / "preview.html"
            spec_path = self._write_spec(tmpdir)

            def _fake_stage(course_id, slug, html_string, title):
                preview_path.write_text(html_string, encoding="utf-8")
                return preview_path

            with patch("create_rubric_gated._stage_preview",
                       side_effect=_fake_stage), \
                 patch("create_rubric_gated._ensure_staging_server",
                       return_value="http://127.0.0.1:8111"):
                rc = crg.main([spec_path, "--dry-run"])
            # Check existence inside the with block before tmpdir is deleted
            self.assertEqual(rc, 0)
            self.assertTrue(preview_path.exists(), "Preview HTML file should be written by --dry-run")

    @patch("create_rubric_gated.get_config")
    @patch("create_rubric_gated.create_rubric")
    def test_dry_run_no_canvas_write(self, mock_create, mock_cfg):
        """--dry-run must NOT call create_rubric."""
        mock_cfg.return_value = _mock_config()
        with tempfile.TemporaryDirectory() as tmpdir:
            preview_path = Path(tmpdir) / "preview.html"
            spec_path = self._write_spec(tmpdir)
            with patch("create_rubric_gated._stage_preview",
                       return_value=preview_path), \
                 patch("create_rubric_gated._ensure_staging_server",
                       return_value="http://127.0.0.1:8111"):
                crg.main([spec_path, "--dry-run"])
        mock_create.assert_not_called()

    @patch("create_rubric_gated.get_config")
    @patch("create_rubric_gated.create_rubric")
    def test_dry_run_preview_contains_rubric_title(self, mock_create, mock_cfg):
        """--dry-run preview HTML contains the rubric title."""
        mock_cfg.return_value = _mock_config()
        with tempfile.TemporaryDirectory() as tmpdir:
            preview_path = Path(tmpdir) / "preview.html"
            spec_path = self._write_spec(tmpdir)

            def _fake_stage(course_id, slug, html_string, title):
                preview_path.write_text(html_string, encoding="utf-8")
                return preview_path

            with patch("create_rubric_gated._stage_preview",
                       side_effect=_fake_stage), \
                 patch("create_rubric_gated._ensure_staging_server",
                       return_value="http://127.0.0.1:8111"):
                crg.main([spec_path, "--dry-run"])
            # Read inside the with block before tmpdir is deleted
            content = preview_path.read_text(encoding="utf-8")
        self.assertIn("Test Rubric", content)


# ---------------------------------------------------------------------------
# Staging server: rubrics type registration
# ---------------------------------------------------------------------------

import pathlib
import io as _io

STAGING_SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")


class TestStagingServerRubricsType(unittest.TestCase):
    """Verify the staging server accepts and serves a staged rubrics preview."""

    def setUp(self):
        sys.path.insert(0, STAGING_SCRIPTS_DIR)
        import staging_server as ss
        self._ss = ss

    def test_rubrics_in_extensions(self):
        """rubrics must be a key in staging_server.EXTENSIONS with .html extension."""
        self.assertIn("rubrics", self._ss.EXTENSIONS)
        self.assertEqual(self._ss.EXTENSIONS["rubrics"], ".html")

    def test_rubrics_in_staging_types(self):
        """rubrics must have a StagingType entry in staging_server.STAGING_TYPES."""
        self.assertIn("rubrics", self._ss.STAGING_TYPES)
        st = self._ss.STAGING_TYPES["rubrics"]
        self.assertEqual(st.extension, ".html")
        self.assertEqual(st.key_mode, "single")

    def test_staging_server_serves_rubric_preview_200(self):
        """GET /courses/{id}/preview/rubrics/{slug} returns 200 with rubric content."""
        ss = self._ss
        import tempfile as _tempfile

        with _tempfile.TemporaryDirectory() as tmp:
            tmp_path = pathlib.Path(tmp)
            staged_dir = tmp_path / "course123" / "staging" / "rubrics"
            staged_dir.mkdir(parents=True)
            rubric_html = (
                "<html><body><table><td>Criterion Alpha</td></table></body></html>"
            )
            (staged_dir / "test-rubric.html").write_text(rubric_html, encoding="utf-8")

            orig_courses = ss.COURSES_DIR
            ss.COURSES_DIR = tmp_path
            try:
                class _MockServer:
                    server_address = ("127.0.0.1", 8111)

                class _Handler(ss.StagingHandler):
                    def __init__(self, method, path):
                        self.command = method
                        self.path = path
                        self.headers = {"Host": "127.0.0.1:8111"}
                        self.rfile = _io.BytesIO(b"")
                        self.wfile = _io.BytesIO()
                        self.server = _MockServer()
                        self.status = None
                        self.response_headers = []

                    def send_response(self, code, message=None):
                        self.status = code

                    def send_header(self, keyword, value):
                        self.response_headers.append((keyword, value))

                    def end_headers(self):
                        pass

                h = _Handler("GET", "/courses/course123/preview/rubrics/test-rubric")
                h.do_GET()
                body = h.wfile.getvalue()
                self.assertEqual(
                    h.status, 200,
                    f"Expected 200, got {h.status}. Body: {body[:200]}",
                )
                self.assertIn(b"Criterion Alpha", body)
            finally:
                ss.COURSES_DIR = orig_courses

    def test_staging_manager_accepts_rubrics_type(self):
        """staging_manager.stage() accepts the rubrics type without error."""
        import tempfile as _tempfile
        sys.path.insert(0, STAGING_SCRIPTS_DIR)
        import staging_manager as sm

        with _tempfile.TemporaryDirectory() as tmp:
            tmp_path = pathlib.Path(tmp)
            with patch("canvas_api.get_plugin_root", return_value=str(tmp_path)):
                result = sm.stage(
                    "course999", "rubrics", "my-rubric",
                    "<html><body><p>Rubric content</p></body></html>",
                    validate=False,
                )
            self.assertTrue(result.get("ok"), f"stage() failed: {result}")
            self.assertIn("rubrics", result["path"])


if __name__ == "__main__":
    unittest.main()
