"""Tests for aablocks.banners module."""

from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

import pytest
import requests

import aablocks.banners as banners
from aablocks.banners import _banners_suppressed, show_banners_once


@pytest.fixture(autouse=True)
def reset_banner_cache():
    """Reset the once-per-process cache so each test starts fresh."""
    show_banners_once.cache_clear()
    yield
    show_banners_once.cache_clear()


@pytest.fixture(autouse=True)
def mock_log_request():
    """Stub activity logging so tests don't write to ~/.aab/logs."""
    with patch("aablocks.banners.log_request") as mock_log:
        yield mock_log


@pytest.fixture(autouse=True)
def banner_display_env(monkeypatch: pytest.MonkeyPatch):
    """Bypass suppression (pytest stderr is not a tty). Suppression itself
    is tested via `_banners_suppressed` directly in TestBannersSuppressed."""
    monkeypatch.setattr(banners, "_banners_suppressed", lambda: False)


@pytest.fixture(autouse=True)
def isolate_seen_file(tmp_path, monkeypatch: pytest.MonkeyPatch):
    """Redirect the once-per-day state file to a temp path so tests neither
    touch ~/.aab nor leak state between each other."""
    monkeypatch.setattr(banners, "SEEN_FILE_PATH", tmp_path / "banners.json")


def _json_response(data, status_code=200):
    resp = MagicMock(spec=requests.Response)
    resp.status_code = status_code
    resp.ok = status_code < 400
    resp.json.return_value = data
    resp.text = json.dumps(data)
    resp.raise_for_status.return_value = None
    return resp


SAMPLE_BANNERS = [
    {
        "id": 1,
        "message": "Scheduled maintenance Saturday 02:00-04:00 UTC.",
        "kind": "maintenance",
        "starts_at": "2026-06-01T00:00:00+00:00",
        "ends_at": "2026-06-07T00:00:00+00:00",
    },
    {
        "id": 2,
        "message": "New VHH release available.",
        "kind": "announcement",
        "starts_at": "2026-06-01T00:00:00+00:00",
        "ends_at": "2026-06-30T00:00:00+00:00",
    },
]


class TestShowBannersOnce:
    @patch("aablocks.banners.requests.get")
    def test_prints_active_banners_to_stderr(self, mock_get, capsys):
        """Each active banner is printed to stderr as 'aablocks: [kind] message'."""
        mock_get.return_value = _json_response(SAMPLE_BANNERS)
        show_banners_once()
        captured = capsys.readouterr()
        assert captured.out == ""
        assert (
            "aablocks: [maintenance] Scheduled maintenance Saturday 02:00-04:00 UTC."
            in captured.err
        )
        assert "aablocks: [announcement] New VHH release available." in captured.err

    @patch("aablocks.banners.requests.get")
    def test_shown_only_once_per_process(self, mock_get, capsys):
        """The second call neither re-fetches nor re-prints."""
        mock_get.return_value = _json_response(SAMPLE_BANNERS)
        show_banners_once()
        capsys.readouterr()
        show_banners_once()
        captured = capsys.readouterr()
        assert captured.err == ""
        assert mock_get.call_count == 1

    @patch("aablocks.banners.requests.get")
    def test_no_active_banners_prints_nothing(self, mock_get, capsys):
        """An empty banner list produces no output."""
        mock_get.return_value = _json_response([])
        show_banners_once()
        captured = capsys.readouterr()
        assert captured.err == ""

    @patch("aablocks.banners.requests.get")
    def test_fetch_failure_is_swallowed(self, mock_get, capsys):
        """A network error neither raises nor prints."""
        mock_get.side_effect = requests.ConnectionError("offline")
        show_banners_once()
        captured = capsys.readouterr()
        assert captured.err == ""

    @patch("aablocks.banners.requests.get")
    def test_fetch_failure_is_not_retried(self, mock_get):
        """After a failed fetch, subsequent calls do not retry."""
        mock_get.side_effect = requests.ConnectionError("offline")
        show_banners_once()
        show_banners_once()
        assert mock_get.call_count == 1

    @patch("aablocks.banners.requests.get")
    def test_http_error_is_swallowed(self, mock_get, capsys):
        """A 500 from the banners endpoint neither raises nor prints."""
        resp = _json_response([], status_code=500)
        resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
        mock_get.return_value = resp
        show_banners_once()
        captured = capsys.readouterr()
        assert captured.err == ""

    @patch("aablocks.banners.requests.get")
    def test_malformed_payload_is_ignored(self, mock_get, capsys):
        """Non-list payloads and entries without a message are skipped."""
        mock_get.return_value = _json_response({"unexpected": "shape"})
        show_banners_once()
        assert capsys.readouterr().err == ""

    @patch("aablocks.banners.requests.get")
    def test_missing_kind_defaults_to_announcement(self, mock_get, capsys):
        """A banner without a kind falls back to 'announcement'."""
        mock_get.return_value = _json_response([{"id": 3, "message": "Hello."}])
        show_banners_once()
        assert "aablocks: [announcement] Hello." in capsys.readouterr().err

    @patch("aablocks.banners.requests.get")
    def test_uses_configured_base_url(self, mock_get):
        """The fetch targets <base_url>/banners with a short timeout."""
        mock_get.return_value = _json_response([])
        with patch("aablocks.banners.get_base_url", return_value="https://api.test"):
            show_banners_once()
        mock_get.assert_called_once_with("https://api.test/banners", timeout=5)

    @patch("aablocks.banners.requests.get")
    def test_successful_fetch_is_logged(self, mock_get, mock_log_request):
        """A successful fetch logs the request with its status code."""
        mock_get.return_value = _json_response(SAMPLE_BANNERS)
        show_banners_once()
        mock_log_request.assert_called_once()
        args = mock_log_request.call_args.args
        assert args[0] == "GET"
        assert args[1] == "/banners"
        assert args[3] == 200  # status_code
        assert args[4] is None  # error

    @patch("aablocks.banners.requests.get")
    def test_fetch_failure_is_logged(self, mock_get, mock_log_request):
        """A failed fetch is swallowed but still logged with the error."""
        mock_get.side_effect = requests.ConnectionError("offline")
        show_banners_once()
        mock_log_request.assert_called_once()
        args = mock_log_request.call_args.args
        assert args[0] == "GET"
        assert args[1] == "/banners"
        assert args[3] is None  # no status_code
        assert "offline" in args[4]  # error message


class TestOncePerDay:
    """The seen-file throttles each banner id to once per calendar day."""

    @patch("aablocks.banners.requests.get")
    def test_not_reshown_same_day_new_process(self, mock_get, capsys):
        """A second invocation the same day fetches but prints nothing."""
        mock_get.return_value = _json_response(SAMPLE_BANNERS)
        show_banners_once()
        capsys.readouterr()
        show_banners_once.cache_clear()  # simulate a fresh CLI process
        show_banners_once()
        assert capsys.readouterr().err == ""

    @patch("aablocks.banners.requests.get")
    def test_reshown_on_a_new_day(self, mock_get, capsys, monkeypatch):
        """A banner shown yesterday is shown again today."""
        mock_get.return_value = _json_response(SAMPLE_BANNERS)
        monkeypatch.setattr(banners, "_today", lambda: "2026-06-05")
        show_banners_once()
        capsys.readouterr()
        show_banners_once.cache_clear()
        monkeypatch.setattr(banners, "_today", lambda: "2026-06-06")
        show_banners_once()
        assert "New VHH release available." in capsys.readouterr().err

    @patch("aablocks.banners.requests.get")
    def test_new_banner_shows_even_after_others_seen_today(self, mock_get, capsys):
        """A banner appearing mid-day shows; already-seen ones stay quiet."""
        mock_get.return_value = _json_response([SAMPLE_BANNERS[0]])
        show_banners_once()
        capsys.readouterr()
        show_banners_once.cache_clear()
        mock_get.return_value = _json_response(SAMPLE_BANNERS)
        show_banners_once()
        err = capsys.readouterr().err
        assert "New VHH release available." in err
        assert "Scheduled maintenance" not in err

    @patch("aablocks.banners.requests.get")
    def test_corrupt_seen_file_is_tolerated(self, mock_get, capsys):
        """A malformed state file is treated as empty, so banners still show."""
        banners.SEEN_FILE_PATH.write_text("not json{")
        mock_get.return_value = _json_response(SAMPLE_BANNERS)
        show_banners_once()
        assert "New VHH release available." in capsys.readouterr().err


def _boom(*_args, **_kwargs):
    raise RuntimeError("boom")


class TestNeverRaises:
    """Banner display is best-effort: show_banners_once must never propagate
    an error, even from failures outside the fetch try/except."""

    def test_suppression_check_error_is_swallowed(self, monkeypatch):
        """A corrupt config (raising in the suppression check) is tolerated."""
        monkeypatch.setattr(banners, "_banners_suppressed", _boom)
        show_banners_once()  # must not raise

    @patch("aablocks.banners.requests.get")
    def test_logging_error_is_swallowed(self, mock_get, monkeypatch):
        """A failure in the finally-block logging call cannot escape."""
        mock_get.return_value = _json_response(SAMPLE_BANNERS)
        monkeypatch.setattr(banners, "log_request", _boom)
        show_banners_once()  # must not raise

    @patch("aablocks.banners.requests.get")
    def test_state_write_error_is_swallowed(self, mock_get, monkeypatch):
        """An unexpected error while persisting seen-state cannot escape."""
        mock_get.return_value = _json_response(SAMPLE_BANNERS)
        monkeypatch.setattr(banners, "write_secure_file", _boom)
        show_banners_once()  # must not raise

    @patch("aablocks.banners.requests.get")
    def test_print_error_is_swallowed(self, mock_get, monkeypatch):
        """A broken stderr pipe during printing cannot escape."""
        mock_get.return_value = _json_response(SAMPLE_BANNERS)
        monkeypatch.setattr("builtins.print", _boom)
        show_banners_once()  # must not raise


class TestBannersSuppressed:
    """Tests for `_banners_suppressed` (the autouse fixture patches the
    module attribute, so the directly imported original is used here)."""

    @pytest.fixture(autouse=True)
    def interactive_stderr(self, monkeypatch: pytest.MonkeyPatch):
        """Start from an interactive-terminal state.

        Patches the `sys` name in the banners module (not `sys.stderr`
        itself, which pytest's capture re-replaces around the test call).
        """
        monkeypatch.setattr(
            banners, "sys", MagicMock(stderr=MagicMock(isatty=lambda: True))
        )

    @patch("aablocks.banners.get_banners_enabled", return_value=True)
    def test_interactive_default_not_suppressed(self, _mock_enabled):
        assert _banners_suppressed() is False

    @patch("aablocks.banners.get_banners_enabled", return_value=True)
    def test_non_tty_stderr(self, _mock_enabled, monkeypatch):
        monkeypatch.setattr(
            banners, "sys", MagicMock(stderr=MagicMock(isatty=lambda: False))
        )
        assert _banners_suppressed() is True

    @patch("aablocks.banners.get_banners_enabled", return_value=False)
    def test_config_disabled(self, _mock_enabled):
        assert _banners_suppressed() is True

    @patch("aablocks.banners.requests.get")
    def test_suppressed_skips_fetch(self, mock_get, monkeypatch):
        """When suppressed, no request is made at all."""
        monkeypatch.setattr(banners, "_banners_suppressed", lambda: True)
        show_banners_once()
        mock_get.assert_not_called()
