"""Fail-first hardening tests for setup_env.py / push_syllabus.py (issue #215).

Covers two hardening gaps:

1. migrate() rewrites .env (to strip the legacy CANVAS_COURSE_ID key) with a
   bare open(path, 'w'), skipping the 0600 enforcement that _write_env_vars
   applies. A plain open() only sets permissions when *creating* a file —
   for a pre-existing .env it leaves whatever mode the file already had, so
   a token-bearing .env that predates this hardening (or was created under a
   permissive umask) stays group/world-readable after every migration.

2. Raw Canvas error response bodies (resp.text) reach stdout/JSON output
   (setup_env.py) and log lines (push_syllabus.py) unredacted. Bearer tokens,
   emails, or other secrets embedded in a Canvas error body must never be
   echoed back verbatim. canvas_api._sanitize_error_text() already strips
   these — every call site that surfaces resp.text must route through it.

All HTTP is mocked. The migrate() test scopes filesystem I/O to tmp_path via
monkeypatch.setattr on setup_env's module-level globals (REGISTRY_PATH,
COURSES_DIR, PLUGIN_ROOT), matching tests/test_setup_env.py's TestMigrate
pattern. The push_syllabus test uses the same real-courses-dir + setUp/
tearDown pattern as tests/test_push_syllabus_timeout.py, since
push_syllabus.COURSES_DIR is computed once at import time from this file's
own location and isn't designed to be monkeypatched per-test.
"""

import json
import os
import shutil
import stat
import sys
import unittest
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 setup_env  # noqa: E402
import push_syllabus  # noqa: E402

PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def _make_course(id_, name="Test Course", code="TEST 101", term="Fall 2025",
                 role="TeacherEnrollment"):
    return {
        "id": id_,
        "name": name,
        "course_code": code,
        "term": {"name": term},
        "enrollments": [{"type": role}],
    }


def _patch_env(monkeypatch, token="tok-test", domain="canvas.asu.edu", course_id=None):
    monkeypatch.setenv("CANVAS_TOKEN", token)
    monkeypatch.setenv("CANVAS_DOMAIN", domain)
    if course_id:
        monkeypatch.setenv("CANVAS_COURSE_ID", str(course_id))
    else:
        monkeypatch.delenv("CANVAS_COURSE_ID", raising=False)


# A fake secret long enough to trip canvas_api._sanitize_error_text's
# catch-all >=24-char opaque-token pattern, plus an email address to exercise
# the email-redaction branch. Shared by both redaction test classes below.
FAKE_SECRET = "abcdefghijklmnopqrstuvwxABCDEF0123456789"
FAKE_EMAIL = "instructor@asu.edu"
RAW_ERROR_BODY = (
    f"Server error. Authorization: Bearer {FAKE_SECRET} "
    f"contact {FAKE_EMAIL} for help. trace=deadbeef1234"
)


# ---------------------------------------------------------------------------
# Item 1 — migrate() must enforce 0600 on its .env rewrite
# ---------------------------------------------------------------------------

class TestMigrateEnvPermissions:
    def test_migrate_enforces_0600_on_env_after_removing_course_id(
        self, monkeypatch, tmp_path
    ):
        _patch_env(monkeypatch, course_id="12345")
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(tmp_path / "registry.json"))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        monkeypatch.setattr(setup_env, "PLUGIN_ROOT", str(tmp_path))

        env_file = tmp_path / ".env"
        env_file.write_text(
            "CANVAS_TOKEN=tok\n"
            "CANVAS_DOMAIN=canvas.test.edu\n"
            "CANVAS_COURSE_ID=12345\n"
        )
        # Simulate a .env that predates this hardening (or was created under a
        # permissive umask): a bare open(path, 'w') on an *existing* file does
        # NOT reset its mode bits, so if migrate() rewrites via plain open(),
        # this stays 0644 after the call.
        os.chmod(env_file, 0o644)

        with patch("setup_env.canvas_api.paginated_get",
                   return_value=[_make_course(12345, "My Course")]):
            result = setup_env.migrate()

        assert result is True
        # Content behavior (already covered by test_setup_env.py) — sanity check.
        assert "CANVAS_COURSE_ID" not in env_file.read_text()

        mode = stat.S_IMODE(env_file.stat().st_mode)
        assert mode == 0o600, (
            "migrate() must rewrite .env through the same 0600-enforcing path "
            f"as _write_env_vars — a token-bearing .env must never be "
            f"group/world-readable; got {oct(mode)}"
        )


# ---------------------------------------------------------------------------
# Item 2 — error-body redaction, setup_env.py
# ---------------------------------------------------------------------------

class TestSetupEnvErrorRedaction:
    def test_test_connection_redacts_error_detail(self, monkeypatch, capsys):
        _patch_env(monkeypatch)
        fake_resp = MagicMock()
        fake_resp.status_code = 500
        fake_resp.text = RAW_ERROR_BODY

        with patch("setup_env.requests.get", return_value=fake_resp):
            result = setup_env.test_connection()

        assert result is False
        out = json.loads(capsys.readouterr().out)
        detail = out["detail"]
        assert FAKE_SECRET not in detail, "raw secret leaked into test_connection() output"
        assert FAKE_EMAIL not in detail, "raw email leaked into test_connection() output"
        assert "REDACTED" in detail

    def test_verify_course_redacts_error_detail(self, monkeypatch, capsys):
        _patch_env(monkeypatch)
        fake_resp = MagicMock()
        fake_resp.status_code = 500
        fake_resp.text = RAW_ERROR_BODY

        with patch("setup_env.requests.get", return_value=fake_resp):
            result = setup_env.verify_course("12345")

        assert result is False
        out = json.loads(capsys.readouterr().out)
        detail = out["detail"]
        assert FAKE_SECRET not in detail, "raw secret leaked into verify_course() output"
        assert FAKE_EMAIL not in detail, "raw email leaked into verify_course() output"
        assert "REDACTED" in detail


# ---------------------------------------------------------------------------
# Item 2 — error-body redaction, push_syllabus.py
# ---------------------------------------------------------------------------

REDACTION_COURSE_ID = "77241"


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


class TestPushSyllabusErrorRedaction(unittest.TestCase):
    """The PUT-failure log line must not leak the raw Canvas response body."""

    def setUp(self):
        self.staging_dir = os.path.join(
            PLUGIN_ROOT, "courses", REDACTION_COURSE_ID, "staging", "syllabus")
        os.makedirs(self.staging_dir, exist_ok=True)
        self.staged_file = os.path.join(self.staging_dir, "syllabus_body.html")
        with open(self.staged_file, "w", encoding="utf-8") as f:
            f.write(
                "<p>New syllabus content, long enough to clear the 30% "
                "shrink-guard threshold reliably across repeated test runs "
                "without flaking on content length.</p>"
            )

    def tearDown(self):
        course_dir = os.path.join(PLUGIN_ROOT, "courses", REDACTION_COURSE_ID)
        if os.path.exists(course_dir):
            shutil.rmtree(course_dir)

    @patch("push_syllabus.lint_content", return_value=[])
    @patch("push_syllabus.canvas_api.get_config", return_value=_mock_syllabus_config())
    def test_put_failure_log_redacts_secret_and_email(self, mock_get_config, mock_lint):
        backup_resp = MagicMock()
        backup_resp.raise_for_status = MagicMock()
        backup_resp.json.return_value = {"syllabus_body": "<p>Old</p>"}

        put_resp = MagicMock()
        put_resp.status_code = 422
        put_resp.text = RAW_ERROR_BODY

        with patch("requests.get", return_value=backup_resp), \
             patch("requests.put", return_value=put_resp):
            with self.assertLogs("push_syllabus", level="ERROR") as cm:
                result = push_syllabus.push_syllabus(REDACTION_COURSE_ID, dry_run=False)

        self.assertFalse(result)
        combined_log = "\n".join(cm.output)
        self.assertNotIn(FAKE_SECRET, combined_log,
                         "raw secret leaked into push_syllabus PUT-failure log")
        self.assertNotIn(FAKE_EMAIL, combined_log,
                         "raw email leaked into push_syllabus PUT-failure log")
        self.assertIn("REDACTED", combined_log)


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
