"""Tests for retire_file.py — delete-with-backup retire path (Ally scans

unpublished files too, so unpublish-only "retire" doesn't help; see
skills/file-remediation/SKILL.md). All Canvas API calls are stubbed.
Covers: happy path, verb refusal, backup-size-mismatch hard abort (delete
never called), download failure abort, and restore. A stub for every write
method other than the one under test raises if called unexpectedly.
"""

import json
import os
import sys

import pytest
from pathlib import Path

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

import canvas_api  # noqa: E402
import retire_file  # noqa: E402

COURSE_ID = "99997"
FILE_ID = "555"


def _config():
    return {
        "token": "fake", "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"},
    }


def _live_meta():
    return {
        "id": int(FILE_ID), "display_name": "stray.pdf", "filename": "stray.pdf",
        "folder_id": 42, "content-type": "application/pdf",
        "url": "https://canvas.test.edu/files/555/download",
        "size": 16, "updated_at": "2026-01-01T00:00:00Z",
    }


def _forbid(monkeypatch, *names):
    def _boom(*a, **k):
        raise AssertionError("unexpected Canvas write method called")
    for name in names:
        monkeypatch.setattr(canvas_api, name, _boom, raising=False)


@pytest.fixture(autouse=True)
def _tmp_plugin_root(monkeypatch, tmp_path):
    monkeypatch.setattr(retire_file, "get_plugin_root", lambda: str(tmp_path))
    (tmp_path / "courses" / COURSE_ID).mkdir(parents=True, exist_ok=True)
    return tmp_path


class _FakeResponse:
    def __init__(self, content=b"stray file bytes"):
        self.content = content

    def raise_for_status(self):
        pass


def test_happy_path_backs_up_deletes_and_verifies(monkeypatch, tmp_path):
    _forbid(monkeypatch, "update_page", "delete_page", "delete_quiz",
            "delete_assignment", "delete_discussion")
    monkeypatch.setattr(canvas_api, "get_config", lambda course_id=None: _config())
    monkeypatch.setattr(canvas_api, "require_course_id", lambda config: None)
    monkeypatch.setattr(retire_file.requests, "get",
                        lambda url, headers=None, timeout=None: _FakeResponse())

    responses = iter([_live_meta(), None])  # pre-delete fetch, then post-delete verify (404)
    monkeypatch.setattr(canvas_api, "get_file", lambda config, file_id: next(responses))

    delete_calls = []
    monkeypatch.setattr(canvas_api, "delete_file",
                        lambda config, file_id, confirm=False: (
                            delete_calls.append((file_id, confirm)) or True))

    result = retire_file.retire_file(COURSE_ID, FILE_ID, "retire")

    assert result["ok"], result
    assert result["verified_deleted"] is True
    assert delete_calls == [(FILE_ID, True)]

    assert os.path.exists(result["backup_path"])
    assert open(result["backup_path"], "rb").read() == b"stray file bytes"
    meta = json.loads(open(result["meta_path"]).read())
    assert meta["file_id"] == FILE_ID
    assert meta["display_name"] == "stray.pdf"
    assert meta["folder_id"] == 42
    assert meta["size_bytes"] == len(b"stray file bytes")


def test_refuses_bad_confirm_verb(monkeypatch, tmp_path):
    called = {"n": 0}
    monkeypatch.setattr(canvas_api, "get_config",
                        lambda course_id=None: called.update(n=called["n"] + 1))
    for verb in ("yes", "ok", "confirm", ""):
        result = retire_file.retire_file(COURSE_ID, FILE_ID, verb)
        assert not result["ok"]
        assert "confirm-verb" in result["error"]
    assert called["n"] == 0, "should refuse before ever touching Canvas config"


def test_download_failure_aborts_before_delete(monkeypatch, tmp_path):
    _forbid(monkeypatch, "delete_file")
    monkeypatch.setattr(canvas_api, "get_config", lambda course_id=None: _config())
    monkeypatch.setattr(canvas_api, "require_course_id", lambda config: None)
    monkeypatch.setattr(canvas_api, "get_file", lambda config, file_id: _live_meta())

    import requests as _requests

    def _raise(*a, **k):
        raise _requests.RequestException("network down")
    monkeypatch.setattr(retire_file.requests, "get", _raise)

    result = retire_file.retire_file(COURSE_ID, FILE_ID, "retire")

    assert not result["ok"]
    assert "download" in result["error"].lower()
    backup_dir = tmp_path / "courses" / COURSE_ID / "backups" / "retired-files"
    assert not backup_dir.exists() or not list(backup_dir.glob("*"))


def test_backup_size_mismatch_hard_aborts_before_delete(monkeypatch, tmp_path):
    _forbid(monkeypatch, "delete_file")
    monkeypatch.setattr(canvas_api, "get_config", lambda course_id=None: _config())
    monkeypatch.setattr(canvas_api, "require_course_id", lambda config: None)
    monkeypatch.setattr(canvas_api, "get_file", lambda config, file_id: _live_meta())
    monkeypatch.setattr(retire_file.requests, "get",
                        lambda url, headers=None, timeout=None: _FakeResponse())

    # Simulate a write that silently truncates: patch Path.write_bytes to write
    # fewer bytes than the fetched content, so the post-write size check fails.
    real_write_bytes = retire_file.Path.write_bytes

    def _short_write(self, data):
        return real_write_bytes(self, data[:1])
    monkeypatch.setattr(retire_file.Path, "write_bytes", _short_write)

    result = retire_file.retire_file(COURSE_ID, FILE_ID, "retire")

    assert not result["ok"]
    assert "verification failed before delete" in result["error"].lower()


def test_restore_uploads_and_reports_new_id(monkeypatch, tmp_path):
    backup_dir = tmp_path / "courses" / COURSE_ID / "backups" / "retired-files"
    backup_dir.mkdir(parents=True, exist_ok=True)
    backup_path = backup_dir / "20260722T000000Z_555-stray.pdf"
    backup_path.write_bytes(b"restored bytes")
    meta_path = backup_path.with_suffix(backup_path.suffix + ".meta.json")
    meta_path.write_text(json.dumps({
        "course_id": COURSE_ID, "file_id": FILE_ID, "display_name": "stray.pdf",
        "folder_id": 42, "size_bytes": 14, "checksum": "x", "deleted_at": "ts",
        "canvas_updated_at": "2026-01-01T00:00:00Z",
    }))

    _forbid(monkeypatch, "delete_file")
    monkeypatch.setattr(canvas_api, "get_config", lambda course_id=None: _config())
    monkeypatch.setattr(canvas_api, "require_course_id", lambda config: None)

    upload_calls = []
    monkeypatch.setattr(canvas_api, "upload_file",
                        lambda config, path, folder_id, content_type=None, filename=None: (
                            upload_calls.append((path, folder_id, filename)) or 9999))
    monkeypatch.setattr(canvas_api, "get_file",
                        lambda config, file_id: {"id": 9999, "display_name": "stray.pdf"})

    result = retire_file.restore_file(COURSE_ID, str(backup_path), None, "approved")

    assert result["ok"], result
    assert result["new_file_id"] == "9999"
    assert result["original_file_id"] == FILE_ID
    assert upload_calls == [(str(backup_path), 42, "stray.pdf")]


def test_restore_refuses_bad_confirm_verb(tmp_path):
    result = retire_file.restore_file(COURSE_ID, str(tmp_path / "nope.pdf"), None, "yes")
    assert not result["ok"]
    assert "confirm-verb" in result["error"]


def _patch_common(monkeypatch, live=None, post_delete=None, content=b"stray file bytes"):
    monkeypatch.setattr(canvas_api, "get_config", lambda course_id=None: _config())
    monkeypatch.setattr(canvas_api, "require_course_id", lambda config: None)
    monkeypatch.setattr(retire_file.requests, "get",
                        lambda url, headers=None, timeout=None: _FakeResponse(content))
    responses = iter([live or _live_meta(), post_delete])
    monkeypatch.setattr(canvas_api, "get_file", lambda config, file_id: next(responses))
    calls = []
    monkeypatch.setattr(canvas_api, "delete_file",
                        lambda config, file_id, confirm=False: (
                            calls.append((file_id, confirm)) or True))
    return calls


def test_backup_collision_never_overwrites(monkeypatch, tmp_path):
    """Codex finding 1: an existing backup at the computed path is never clobbered."""
    calls = _patch_common(monkeypatch, post_delete=None)
    r1 = retire_file.retire_file(COURSE_ID, FILE_ID, "retire")
    assert r1["ok"], r1
    calls2 = _patch_common(monkeypatch, post_delete=None)
    r2 = retire_file.retire_file(COURSE_ID, FILE_ID, "retire")
    assert r2["ok"], r2
    backups = [f for f in Path(r1["backup_path"]).parent.iterdir()
               if not f.name.endswith(".meta.json")]
    assert len(backups) == 2, "second retire must not overwrite the first safety copy"
    assert r1["backup_path"] != r2["backup_path"] or len(backups) == 2


def test_declared_size_mismatch_aborts(monkeypatch, tmp_path):
    """Codex finding 2: Canvas-declared size mismatch refuses to delete."""
    live = _live_meta(); live["size"] = 999999
    calls = _patch_common(monkeypatch, live=live)
    r = retire_file.retire_file(COURSE_ID, FILE_ID, "retire")
    assert not r["ok"] and "declares" in r["error"]
    assert calls == [], "delete_file must never run on a suspect backup"


def test_post_delete_still_present_fails(monkeypatch, tmp_path):
    """Codex finding 3: post-delete verify is blocking."""
    calls = _patch_common(monkeypatch,
                          post_delete={"id": 555, "workflow_state": "available"})
    r = retire_file.retire_file(COURSE_ID, FILE_ID, "retire")
    assert not r["ok"] and "still present" in r["error"]
    assert Path(r["backup_path"]).exists(), "safety copy preserved on failure"


def test_post_delete_deleted_state_counts_as_gone(monkeypatch, tmp_path):
    calls = _patch_common(monkeypatch,
                          post_delete={"id": 555, "workflow_state": "deleted"})
    r = retire_file.retire_file(COURSE_ID, FILE_ID, "retire")
    assert r["ok"] and r["verified_deleted"] is True


def test_restore_missing_folder_meta_clear_error(monkeypatch, tmp_path):
    """Codex finding 4: meta without folder_id -> clear error, not KeyError."""
    b = tmp_path / "x_555-file.pdf"
    b.write_bytes(b"data")
    (tmp_path / "x_555-file.pdf.meta.json").write_text(json.dumps(
        {"display_name": "file.pdf", "file_id": "555"}))
    monkeypatch.setattr(canvas_api, "get_config", lambda course_id=None: _config())
    monkeypatch.setattr(canvas_api, "require_course_id", lambda config: None)
    r = retire_file.restore_file(COURSE_ID, str(b), None, "retire")
    assert not r["ok"] and "--folder-id" in r["error"]


def test_sidecar_naming_pinned():
    """Codex finding 7: sidecar path contract for odd extensions."""
    for name, expected in [("a.pdf", "a.pdf.meta.json"),
                           ("archive.tar.gz", "archive.tar.gz.meta.json"),
                           ("noext", "noext.meta.json")]:
        b = Path(name)
        assert str(b.with_suffix(b.suffix + ".meta.json")) == expected
