"""Tests for the observer SHADOW-READ of Prometheus up{} liveness (cutover etap 1).

The whole point of etap 1 is a PARALLEL-RUN: the observer additionally queries
Prometheus and LOGS disagreements, but authoritative liveness stays 100%
event-driven (compute_liveness).  These tests prove exactly that: shadow-read
never mutates node_info["liveness"]/["status"], is fail-open on any Prometheus
error, and logs a grep-able SHADOW_LIVENESS_MISMATCH line on disagreement.
"""
from __future__ import annotations

import logging
import sys
import time
from pathlib import Path

import pytest

# Observer lives outside the control-plane package; add scripts/ to path.
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "scripts"))
import observer.observer as obs_mod
from observer.observer import Observer
from liveness import FRESH, DEAD


# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------

@pytest.fixture(autouse=True)
def _redirect_observer_paths(tmp_path, monkeypatch):
    """Redirect every observer runtime path into a per-test tmp_path.

    Mirrors the fixture in test_incident_lifecycle.py so these tests never touch
    the real /opt/homelab state.  Also defaults PROM_SHADOW_URL to "" (disabled)
    so a test that forgets to set it exercises the graceful no-op path.
    """
    world = tmp_path / "world"
    state = tmp_path / "state"
    events = tmp_path / "events"
    logs = tmp_path / "logs"
    repo = tmp_path / "repo"

    for d in (world, state, events, logs, repo / "inventory", repo / "hosts"):
        d.mkdir(parents=True, exist_ok=True)

    (repo / "inventory" / "topology.yaml").write_text(
        "nodes:\n"
        "  vps:\n    roles: [control-plane]\n    connectivity: {}\n"
        "  piha:\n    roles: [infra]\n    connectivity: {}\n"
    )

    monkeypatch.setattr(obs_mod, "WORLD_DIR", world)
    monkeypatch.setattr(obs_mod, "STATE_DIR", state)
    monkeypatch.setattr(obs_mod, "EVENTS_DIR", events)
    monkeypatch.setattr(obs_mod, "LOGS_DIR", logs)
    monkeypatch.setattr(obs_mod, "INVENTORY_TOPOLOGY", repo / "inventory" / "topology.yaml")
    monkeypatch.setattr(obs_mod, "REPO_ROOT", repo)
    monkeypatch.setattr(obs_mod, "FAILED_EVENTS_DIR", state / "observer_failed_events")
    monkeypatch.setattr(obs_mod, "OBSERVER_STATE_FILE", state / "observer_checkpoint.json")
    monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "")
    # Persistent SHADOW_LIVENESS_MISMATCH log → per-test tmp dir (never /opt or /var).
    monkeypatch.setattr(obs_mod, "SHADOW_LOG_DIR", logs / "observer")


class _FakeResp:
    """Minimal context-manager stand-in for urllib.request.urlopen()'s return."""

    def __init__(self, body: str):
        self._body = body

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        return False

    def read(self):
        return self._body.encode("utf-8")


def _mock_prom(monkeypatch, body):
    """Make urllib.request.urlopen return `body` (str) or raise (if Exception)."""
    def _fake_urlopen(url, timeout=None):
        if isinstance(body, Exception):
            raise body
        return _FakeResp(body)
    monkeypatch.setattr(obs_mod.urllib.request, "urlopen", _fake_urlopen)


_SAMPLE_UP_JSON = (
    '{"status":"success","data":{"resultType":"vector","result":['
    '{"metric":{"__name__":"up","job":"fleet-node","node":"vps"},"value":[1720000000,"1"]},'
    '{"metric":{"__name__":"up","job":"fleet-node","node":"piha"},"value":[1720000000,"0"]},'
    '{"metric":{"__name__":"up","job":"prometheus"},"value":[1720000000,"1"]}'
    ']}}'
)


# ---------------------------------------------------------------------------
# 1. _query_prometheus_liveness — disabled / parsing / fail-open
# ---------------------------------------------------------------------------

def test_query_returns_empty_when_url_unset(monkeypatch):
    """PROM_SHADOW_URL empty → {} (shadow disabled), no HTTP attempted."""
    monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "")

    def _boom(*a, **k):  # pragma: no cover - must never be called
        raise AssertionError("urlopen must not be called when shadow is disabled")
    monkeypatch.setattr(obs_mod.urllib.request, "urlopen", _boom)

    assert Observer()._query_prometheus_liveness() == {}


def test_query_parses_up_series_by_node_label(monkeypatch):
    """Parses up{} into {node: bool}; series without a node label are ignored."""
    monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "http://prom:9090")
    _mock_prom(monkeypatch, _SAMPLE_UP_JSON)

    result = Observer()._query_prometheus_liveness()

    assert result == {"vps": True, "piha": False}
    assert "prometheus" not in result  # up{job="prometheus"} has no node label


def test_query_fail_open_on_unreachable(monkeypatch):
    """Prometheus unreachable → {} and NO exception propagates (fail-open)."""
    monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "http://prom:9090")
    _mock_prom(monkeypatch, ConnectionRefusedError("nope"))

    assert Observer()._query_prometheus_liveness() == {}


def test_query_fail_open_on_bad_json(monkeypatch):
    """Malformed JSON body → {} and no raise."""
    monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "http://prom:9090")
    _mock_prom(monkeypatch, "this is not json")

    assert Observer()._query_prometheus_liveness() == {}


# ---------------------------------------------------------------------------
# 2. The load-bearing invariant: shadow-read NEVER changes liveness
# ---------------------------------------------------------------------------

def _fresh_node_world(obs: Observer):
    """Seed one node whose event-driven liveness computes to FRESH."""
    obs.world_state["nodes"] = {
        "vps": {"status": "unknown", "last_seen": int(time.time()), "roles": ["control-plane"]},
    }


def test_shadow_does_not_change_liveness(monkeypatch):
    """Event liveness after a cycle WITH shadow (mismatching prom) is byte-for-byte
    identical to a cycle WITHOUT shadow. Proof that shadow switches nothing."""
    # Baseline: shadow disabled.
    baseline = Observer()
    _fresh_node_world(baseline)
    baseline._prune_stale_world()
    base_liveness = baseline.world_state["nodes"]["vps"]["liveness"]
    base_status = baseline.world_state["nodes"]["vps"]["status"]

    assert base_liveness == FRESH
    assert base_status == "online"

    # Shadow enabled, and Prometheus DISAGREES (says vps is down).
    monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "http://prom:9090")
    _mock_prom(
        monkeypatch,
        '{"data":{"result":['
        '{"metric":{"node":"vps"},"value":[1720000000,"0"]}]}}',
    )
    shadow = Observer()
    _fresh_node_world(shadow)
    shadow._prune_stale_world()

    # Authoritative liveness/status must be IDENTICAL to the no-shadow baseline.
    assert shadow.world_state["nodes"]["vps"]["liveness"] == base_liveness
    assert shadow.world_state["nodes"]["vps"]["status"] == base_status
    # And no prom-derived field leaked into the node record.
    assert "prom_up" not in shadow.world_state["nodes"]["vps"]


# ---------------------------------------------------------------------------
# 3. Mismatch logging
# ---------------------------------------------------------------------------

def test_mismatch_is_logged(monkeypatch, caplog):
    """event=fresh but prom=down → grep-able SHADOW_LIVENESS_MISMATCH at INFO."""
    monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "http://prom:9090")
    _mock_prom(
        monkeypatch,
        '{"data":{"result":['
        '{"metric":{"node":"vps"},"value":[1720000000,"0"]}]}}',
    )
    obs = Observer()
    _fresh_node_world(obs)

    with caplog.at_level(logging.INFO, logger="observer"):
        obs._prune_stale_world()

    assert "SHADOW_LIVENESS_MISMATCH" in caplog.text
    assert "node=vps" in caplog.text
    assert "event=fresh" in caplog.text
    assert "prom=down" in caplog.text


def test_agreement_does_not_log_mismatch(monkeypatch, caplog):
    """event=fresh and prom=up → agreement, no mismatch line at INFO."""
    monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "http://prom:9090")
    _mock_prom(
        monkeypatch,
        '{"data":{"result":['
        '{"metric":{"node":"vps"},"value":[1720000000,"1"]}]}}',
    )
    obs = Observer()
    _fresh_node_world(obs)

    with caplog.at_level(logging.INFO, logger="observer"):
        obs._prune_stale_world()

    assert "SHADOW_LIVENESS_MISMATCH" not in caplog.text


def test_dead_node_agrees_with_prom_down(monkeypatch, caplog):
    """event=dead and prom=down → agreement (DEAD maps to down)."""
    monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "http://prom:9090")
    _mock_prom(
        monkeypatch,
        '{"data":{"result":['
        '{"metric":{"node":"vps"},"value":[1720000000,"0"]}]}}',
    )
    obs = Observer()
    obs.world_state["nodes"] = {
        "vps": {"status": "unknown", "last_seen": int(time.time()) - 700, "roles": ["control-plane"]},
    }

    with caplog.at_level(logging.INFO, logger="observer"):
        obs._prune_stale_world()

    assert obs.world_state["nodes"]["vps"]["liveness"] == DEAD
    assert "SHADOW_LIVENESS_MISMATCH" not in caplog.text


def test_node_unknown_to_prometheus_is_not_mismatch(monkeypatch, caplog):
    """A node with no up{} series (e.g. chelsty-infra) is not a disagreement."""
    monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "http://prom:9090")
    _mock_prom(monkeypatch, '{"data":{"result":[]}}')  # empty — knows no node
    obs = Observer()
    _fresh_node_world(obs)

    with caplog.at_level(logging.INFO, logger="observer"):
        obs._prune_stale_world()

    assert "SHADOW_LIVENESS_MISMATCH" not in caplog.text
    # And liveness still computed from events.
    assert obs.world_state["nodes"]["vps"]["liveness"] == FRESH


# ---------------------------------------------------------------------------
# 4. PERSISTENT mismatch log (cutover etap 2) — survives container recreate
# ---------------------------------------------------------------------------

def _mismatch_prom(monkeypatch):
    """Prometheus says vps is DOWN → mismatch against a FRESH event liveness."""
    monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "http://prom:9090")
    _mock_prom(
        monkeypatch,
        '{"data":{"result":['
        '{"metric":{"node":"vps"},"value":[1720000000,"0"]}]}}',
    )


def test_mismatch_is_persisted_to_file(monkeypatch, tmp_path, caplog):
    """The mismatch is written to the persistent log FILE, not only stdout.

    This is the whole point: the file lives on a host-mounted path, so it
    survives a `docker rm`/recreate that would drop stdout json-file logs.
    """
    _mismatch_prom(monkeypatch)
    obs = Observer()
    _fresh_node_world(obs)

    # stdout path still emits (unchanged behaviour) AND the file is written.
    with caplog.at_level(logging.INFO, logger="observer"):
        obs._prune_stale_world()

    log_file = tmp_path / "logs" / "observer" / "shadow-liveness.log"
    assert log_file.exists(), "persistent shadow log file was not created"
    content = log_file.read_text()
    assert "SHADOW_LIVENESS_MISMATCH" in content
    assert "node=vps" in content
    assert "event=fresh" in content
    assert "prom=down" in content
    # Line carries a timestamp (asctime format) — grep-able forensic record.
    assert " - INFO - SHADOW_LIVENESS_MISMATCH" in content
    # And stdout still got it too (no regression to docker logs visibility).
    assert "SHADOW_LIVENESS_MISMATCH" in caplog.text


def test_agreement_is_not_persisted_to_file(monkeypatch, tmp_path):
    """Agreement (event=fresh, prom=up) writes nothing to the persistent log."""
    monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "http://prom:9090")
    _mock_prom(
        monkeypatch,
        '{"data":{"result":['
        '{"metric":{"node":"vps"},"value":[1720000000,"1"]}]}}',
    )
    obs = Observer()
    _fresh_node_world(obs)
    obs._prune_stale_world()

    log_file = tmp_path / "logs" / "observer" / "shadow-liveness.log"
    # File may or may not exist (handler creates it lazily); if it does, empty.
    if log_file.exists():
        assert "SHADOW_LIVENESS_MISMATCH" not in log_file.read_text()


def test_shadow_logger_failsafe_when_dir_unwritable(monkeypatch, tmp_path, caplog):
    """If the persistent log dir cannot be created, the observer must NOT crash.

    It logs one warning, the shadow logger is left handler-less (a silent no-op),
    and a subsequent mismatch cycle runs fine and STILL reaches stdout.
    """
    # Point SHADOW_LOG_DIR under a *file*, so os.makedirs() raises.
    blocker = tmp_path / "blocker"
    blocker.write_text("i am a file, not a directory")
    monkeypatch.setattr(obs_mod, "SHADOW_LOG_DIR", blocker / "observer")

    # Construction must not raise, and must warn about the failure.
    with caplog.at_level(logging.WARNING, logger="observer"):
        obs = Observer()
    assert "could not open persistent mismatch log" in caplog.text
    # No file handler was attached (fail-safe → handler-less logger).
    assert obs.shadow_logger.handlers == []

    # A real mismatch cycle still works: no raise, stdout still logs it.
    caplog.clear()
    _mismatch_prom(monkeypatch)
    obs2 = Observer()  # re-reads the (still broken) SHADOW_LOG_DIR
    _fresh_node_world(obs2)
    with caplog.at_level(logging.INFO, logger="observer"):
        obs2._prune_stale_world()  # must not raise
    assert "SHADOW_LIVENESS_MISMATCH" in caplog.text  # stdout intact
