"""Shared gating + fixtures for the live Canvas integration suite.

Everything under tests/integration/ talks to the real Canvas API on
canvas.asu.edu against the sandbox course (260866 by default). None of it
runs unless ASUCB_LIVE_TEST=1 — see pytest_collection_modifyitems below.

Verified empirically (not just assumed) that adding the skip marker in
pytest_collection_modifyitems prevents pytest from ever setting up the
autouse `sweep_orphans` session fixture when the gate is off: skip markers
are evaluated during test setup before fixtures resolve, so a fully-skipped
session makes zero Canvas API calls. This is what keeps
`python -m pytest tests/` clean and fast in CI, which has no Canvas token.

See docs/plan-live-integration-tests.md for the full design.
"""

import os
import sys
import time
import uuid

import pytest

# Mirror the sys.path idiom used across tests/ (see tests/test_canvas_api.py)
# so `import canvas_api` etc. resolve scripts/ two directories up from here.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "scripts"))

import backup_manager  # noqa: E402
import canvas_api  # noqa: E402 — importing this loads .env via load_dotenv
import staging_manager  # noqa: E402

LIVE_DOMAIN = "canvas.asu.edu"
DEFAULT_COURSE_ID = "260866"

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


def pytest_collection_modifyitems(config, items):
    """Skip every test collected under tests/integration/ unless gated on.

    IMPORTANT: pytest_collection_modifyitems receives the FULL session
    `items` list — the same list is handed to every conftest.py's hook
    implementation regardless of which directory that conftest lives in.
    It is NOT auto-scoped to "items collected under this conftest's
    directory" the way collection hooks (e.g. collect_ignore) are. Each
    item is therefore filtered explicitly by file path below so this hook
    only skips tests that actually live under tests/integration/ — without
    that filter this would (and, during development, briefly did) skip the
    entire tests/ suite whenever tests/integration/ was included in the run.
    """
    if os.environ.get("ASUCB_LIVE_TEST") == "1":
        return
    skip = pytest.mark.skip(reason="live integration tests: set ASUCB_LIVE_TEST=1")
    for item in items:
        item_path = os.path.abspath(str(getattr(item, "path", None) or item.fspath))
        if item_path.startswith(_THIS_DIR + os.sep):
            item.add_marker(skip)


# ============================================================
# Core fixtures
# ============================================================

@pytest.fixture(scope="session")
def live_config():
    """Resolve Canvas config for the sandbox course.

    Hard-fails (pytest.fail), rather than skipping, when ASUCB_LIVE_TEST=1
    but credentials/domain are wrong — per constraint 3, we never want to
    silently run against the wrong Canvas instance. Reads CANVAS_TOKEN /
    CANVAS_DOMAIN directly instead of calling canvas_api.get_config() first,
    because get_config() calls sys.exit(1) on a missing token, which would
    kill the whole pytest process instead of failing cleanly.
    """
    token = os.environ.get("CANVAS_TOKEN")
    domain = os.environ.get("CANVAS_DOMAIN", LIVE_DOMAIN)
    if not token:
        pytest.fail(
            "ASUCB_LIVE_TEST=1 but CANVAS_TOKEN is not set. Refusing to run "
            "live integration tests without credentials."
        )
    if domain != LIVE_DOMAIN:
        pytest.fail(
            f"ASUCB_LIVE_TEST=1 but CANVAS_DOMAIN={domain!r} (expected "
            f"{LIVE_DOMAIN!r}). Refusing to run live integration tests "
            "against a non-sandbox Canvas instance."
        )
    course_id = os.environ.get("ASUCB_LIVE_COURSE_ID", DEFAULT_COURSE_ID)
    return canvas_api.get_config(course_id=course_id)


@pytest.fixture(scope="session")
def run_id():
    """Short unique id for this test session's Canvas objects."""
    return uuid.uuid4().hex[:8]


@pytest.fixture(scope="session")
def itest_prefix(run_id):
    """Title prefix every object created by this suite carries."""
    return f"[ITEST-{run_id}]"


def _title_of(obj, object_type):
    """Return the display title/name field for a Canvas object, by type."""
    if object_type == "assignments":
        return obj.get("name") or ""
    return obj.get("title") or ""


def _delete_orphan(config, object_type, obj):
    """Best-effort delete of one orphaned [ITEST- object. Never raises."""
    try:
        if object_type == "pages":
            canvas_api.delete_page(config, obj["url"], confirm=True)
        elif object_type == "assignments":
            canvas_api.delete_assignment(config, obj["id"], confirm=True)
        elif object_type == "quizzes":
            canvas_api.delete_quiz(config, obj["id"], confirm=True)
        elif object_type == "discussions":
            canvas_api.delete_discussion(config, obj["id"], confirm=True)
    except Exception as exc:  # noqa: BLE001 — sweeper must never block the run
        print(f"  sweep_orphans: failed to delete {object_type} {obj.get('id') or obj.get('url')}: {exc}")


@pytest.fixture(scope="session", autouse=True)
def sweep_orphans(live_config):
    """Delete leftover [ITEST- objects from crashed prior runs.

    Session-scoped autouse: runs once, before the first test that needs
    live_config, and only when the gate is on (see the module docstring).
    Silent when nothing is found.
    """
    listers = {
        "pages": canvas_api.get_all_pages,
        "assignments": canvas_api.list_assignments,
        "quizzes": canvas_api.list_quizzes,
        "discussions": canvas_api.list_discussions,
    }
    swept = 0
    for object_type, lister in listers.items():
        for obj in lister(live_config) or []:
            if _title_of(obj, object_type).startswith("[ITEST-"):
                _delete_orphan(live_config, object_type, obj)
                swept += 1
    if swept:
        print(f"  sweep_orphans: cleaned up {swept} leftover [ITEST- object(s).")
    yield


def _retry(fn, retries=5, delay=1.0):
    """Retry a Canvas read to absorb eventual consistency.

    Args:
        fn: Zero-arg callable. Returns a truthy value on success, a falsy
            value (e.g. None) when the object isn't visible/consistent yet.
        retries: Max attempts (first attempt + retries-1 retries).
        delay: Seconds slept between attempts.

    Returns:
        The first truthy result, or the last (falsy) result once retries
        are exhausted.
    """
    result = fn()
    for _ in range(retries - 1):
        if result:
            return result
        time.sleep(delay)
        result = fn()
    return result


@pytest.fixture()
def wait_for():
    """Expose the eventual-consistency retry helper to tests."""
    return _retry


def _clear_itest_backups(course_id, object_type, object_id):
    """Remove local backup files for one object_id (constraint 5).

    Backup filenames are ``{timestamp}_{object_id}{ext}`` (+ .meta.json
    sidecar) under courses/{id}/backups/{object_type}/ — every backup this
    suite creates is for an [ITEST- object, so clearing by object_id alone
    is safe and never touches pre-existing local course data.
    """
    for backup in backup_manager.list_backups(course_id, object_type, object_id):
        for candidate in (backup["path"], backup["path"] + ".meta.json"):
            if os.path.exists(candidate):
                os.remove(candidate)


@pytest.fixture()
def page_factory(live_config, itest_prefix):
    """Factory fixture: create a Canvas page, clean it (+ local artifacts) up.

    Usage: slug, page = page_factory("some-suffix", "<p>body</p>")
    The Canvas-assigned slug (page['url']) is what get_page/update_page/
    delete_page/staging_manager all key on — Canvas rewrites titles into
    URL-safe slugs, so this is not the same string as the title.
    """
    created = []

    def _make(title_suffix, body, published=False):
        title = f"{itest_prefix} {title_suffix}"
        page = canvas_api.create_page(
            live_config, title, body, published=published, from_staging=True,
        )
        assert page is not None, f"create_page failed for title={title!r}"
        slug = page["url"]
        created.append(slug)
        return slug, page

    yield _make

    for slug in created:
        staging_manager.clear_staged(live_config["course_id"], "pages", slug)
        try:
            canvas_api.delete_page(live_config, slug, confirm=True)
        except Exception as exc:  # noqa: BLE001 — best-effort teardown
            print(f"  page_factory teardown: failed to delete page {slug}: {exc}")
        _clear_itest_backups(live_config["course_id"], "pages", slug)
