"""
tests/test_stepan_v47_farm_state_bridge.py

Unit tests for v4.7 FarmOS Farm State Bridge in run.py:
  - _load_farm_state_snapshot: reads fact from memory-service
  - TTL enforcement (24h)
  - Fail-closed: HTTP errors, JSON errors, missing fields → None
  - PII: no raw chat_id in URL (synthetic uid used)
  - Integration guard: injected into task_parts only in non-doc / non-web mode
"""
from __future__ import annotations
import json
import os
import sys
import types
import pytest
from datetime import datetime, timezone, timedelta
from unittest.mock import patch, MagicMock

# ── Paths ─────────────────────────────────────────────────────────────────────
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_PKG = os.path.join(_ROOT, "packages", "agromatrix-tools")
_CREWS = os.path.join(_ROOT, "crews")
for _p in (_PKG, _CREWS, _ROOT):
    if _p not in sys.path:
        sys.path.insert(0, _p)

# ── Minimal stubs so run.py importable without full stack ──────────────────────
def _stub(name: str) -> types.ModuleType:
    m = types.ModuleType(name)
    sys.modules.setdefault(name, m)
    return sys.modules[name]

# crewai
_crewai = _stub("crewai")
_crewai.Crew = type("Crew", (), {"__init__": lambda *a, **k: None, "kickoff": lambda *a, **k: ""})
_crewai.Task = type("Task", (), {"__init__": lambda *a, **k: None})

# langchain_core
_lcc = _stub("langchain_core")
_lcc_tools = _stub("langchain_core.tools")
_lcc_tools.Tool = type("Tool", (), {"__init__": lambda *a, **k: None})

# agromatrix_tools
_agt = _stub("agromatrix_tools")
_agt.tool_dictionary = _stub("agromatrix_tools.tool_dictionary")
_agt.tool_operation_plan = _stub("agromatrix_tools.tool_operation_plan")
_stub("agromatrix_tools.tool_farmos_read")

# crews modules stubs
for _mod in [
    "crews.agromatrix_crew.agents.stepan_orchestrator",
    "crews.agromatrix_crew.agents.operations_agent",
    "crews.agromatrix_crew.agents.iot_agent",
    "crews.agromatrix_crew.agents.platform_agent",
    "crews.agromatrix_crew.agents.spreadsheet_agent",
    "crews.agromatrix_crew.agents.sustainability_agent",
    "crews.agromatrix_crew.audit",
    "crews.agromatrix_crew.operator_commands",
    "crews.agromatrix_crew.memory_manager",
    "crews.agromatrix_crew.style_adapter",
    "crews.agromatrix_crew.reflection_engine",
    "crews.agromatrix_crew.light_reply",
    "crews.agromatrix_crew.depth_classifier",
    "crews.agromatrix_crew.telemetry",
    "crews.agromatrix_crew.session_context",
    "crews.agromatrix_crew.proactivity",
    "crews.agromatrix_crew.doc_facts",
    "crews.agromatrix_crew.doc_focus",
    "crews.agromatrix_crew.farm_state",
]:
    m = _stub(_mod)
    # Set needed attributes
    m.route_operator_command = lambda *a, **k: None
    m.route_operator_text = lambda *a, **k: None
    m.load_user_profile = lambda *a, **k: {}
    m.save_user_profile = lambda *a, **k: None
    m.load_farm_profile = lambda *a, **k: {}
    m.save_farm_profile = lambda *a, **k: None
    m.update_profile_if_needed = lambda *a, **k: {}
    m.adapt_response_style = lambda *a, **k: ""
    m.build_style_prefix = lambda *a, **k: ""
    m.reflect_on_response = lambda *a, **k: ""
    m.build_light_reply = lambda *a, **k: None
    m.classify_light_event = lambda *a, **k: None
    m.classify_depth = lambda *a, **k: "deep"
    m.tlog = lambda *a, **k: None
    m.load_session = lambda *a, **k: {}
    m.update_session = lambda *a, **k: None
    m.is_doc_focus_active = lambda *a, **k: False
    m.is_doc_focus_cooldown_active = lambda *a, **k: False
    m.DOC_FOCUS_TTL = 600
    m.DOC_FOCUS_COOLDOWN_S = 60
    m.maybe_add_proactivity = lambda *a, **k: ""
    m.extract_doc_facts = lambda *a, **k: {}
    m.merge_doc_facts = lambda *a, **k: {}
    m.can_answer_from_facts = lambda *a, **k: (False, None)
    m.compute_scenario = lambda *a, **k: None
    m.format_facts_as_text = lambda *a, **k: ""
    m.extract_fact_claims = lambda *a, **k: []
    m.build_self_correction = lambda *a, **k: ""
    m._is_doc_question = lambda *a, **k: False
    m._detect_domain = lambda *a, **k: "general"
    m.detect_context_signals = lambda *a, **k: {}
    m.build_mode_clarifier = lambda *a, **k: ""
    m.detect_farm_state_updates = lambda *a, **k: {}
    m.update_farm_state = lambda *a, **k: None
    m.build_farm_state_prefix = lambda *a, **k: ""
    m.audit_event = lambda *a, **k: None
    m.new_trace = lambda *a, **k: {"trace_id": "t1"}
    m.build_stepan = lambda *a, **k: MagicMock()
    m.build_operations = lambda *a, **k: MagicMock()
    m.build_iot = lambda *a, **k: MagicMock()
    m.build_platform = lambda *a, **k: MagicMock()
    m.build_spreadsheet = lambda *a, **k: MagicMock()
    m.build_sustainability = lambda *a, **k: MagicMock()

# stub subprocess, vision_guard
_stub("subprocess")
_vg = _stub("vision_guard")
_vg.get_vision_lock = lambda *a, **k: {}

# Now import the function under test
import importlib
import crews.agromatrix_crew.run as _run_mod
importlib.reload(_run_mod)
from crews.agromatrix_crew.run import _load_farm_state_snapshot, _FARM_STATE_SNAPSHOT_TTL_S


# ── Helpers ───────────────────────────────────────────────────────────────────
def _fresh_ts(age_seconds: float = 0.0) -> str:
    ts = datetime.now(timezone.utc) - timedelta(seconds=age_seconds)
    return ts.isoformat()


def _make_resp(text: str, age_s: float = 0.0, status: int = 200) -> MagicMock:
    # memory-service повертає fact_value_json як рядок (str) або dict залежно від версії
    # наш код підтримує обидва варіанти через isinstance(val, str) → json.loads
    payload = {
        "id": "test-uuid",
        "user_id": "farm:test",
        "fact_key": "farm_state:agromatrix:chat:test",
        "fact_value_json": json.dumps({
            "_version": 1,
            "source": "farmos",
            "generated_at": _fresh_ts(age_s),
            "counts": {"asset_land": 2, "asset_plant": 1, "asset_equipment": 0},
            "top": {"asset_land": ["Поле A", "Поле B"], "asset_plant": [], "asset_equipment": []},
            "text": text,
        })
    }
    r = MagicMock()
    r.status_code = status
    r.json.return_value = payload
    return r


# ── Tests: _load_farm_state_snapshot ─────────────────────────────────────────

class TestLoadFarmStateSnapshot:

    def test_returns_text_when_fresh(self):
        with patch("httpx.get", return_value=_make_resp("Farm state (FarmOS):\n- Поля: 2")) as mock_get:
            result = _load_farm_state_snapshot("chat_123")
        assert result == "Farm state (FarmOS):\n- Поля: 2"

    def test_uses_synthetic_uid_not_raw_chat_id(self):
        """PII: URL params must use synthetic uid farm:{chat_id}, not raw chat_id directly as user."""
        captured = {}
        def fake_get(url, params=None, timeout=None):
            captured["params"] = params or {}
            return _make_resp("text")
        with patch("httpx.get", side_effect=fake_get):
            _load_farm_state_snapshot("sensitive_42")
        uid = captured["params"].get("user_id", "")
        assert uid == "farm:sensitive_42", f"Expected synthetic uid, got: {uid!r}"
        assert "sensitive_42" not in captured["params"].get("fact_key", "").split(":")[0], \
            "chat_id should not appear as user_id directly"

    def test_correct_fact_key(self):
        captured = {}
        def fake_get(url, params=None, timeout=None):
            captured["params"] = params or {}
            return _make_resp("text")
        with patch("httpx.get", side_effect=fake_get):
            _load_farm_state_snapshot("chat_999")
        assert captured["params"].get("fact_key") == "farm_state:agromatrix:chat:chat_999"

    def test_uses_correct_endpoint_path(self):
        """URL повинен бути /facts/{fact_key} а не /facts/get."""
        captured = {}
        def fake_get(url, params=None, timeout=None):
            captured["url"] = url
            captured["params"] = params or {}
            return _make_resp("text")
        with patch("httpx.get", side_effect=fake_get):
            _load_farm_state_snapshot("chat_ep_test")
        assert "/facts/farm_state:agromatrix:chat:chat_ep_test" in captured["url"]
        assert "/facts/get" not in captured["url"]

    def test_returns_none_when_non_200(self):
        with patch("httpx.get", return_value=_make_resp("text", status=404)):
            result = _load_farm_state_snapshot("chat_x")
        assert result is None

    def test_returns_none_when_snapshot_older_than_24h(self):
        with patch("httpx.get", return_value=_make_resp("text", age_s=86401.0)):
            result = _load_farm_state_snapshot("chat_x")
        assert result is None

    def test_returns_text_when_snapshot_just_within_24h(self):
        with patch("httpx.get", return_value=_make_resp("ok_text", age_s=86399.0)):
            result = _load_farm_state_snapshot("chat_x")
        assert result == "ok_text"

    def test_returns_none_on_connection_error(self):
        import httpx as _httpx
        with patch("httpx.get", side_effect=_httpx.ConnectError("fail")):
            result = _load_farm_state_snapshot("chat_x")
        assert result is None

    def test_returns_none_on_timeout(self):
        import httpx as _httpx
        with patch("httpx.get", side_effect=_httpx.ReadTimeout("t/o")):
            result = _load_farm_state_snapshot("chat_x")
        assert result is None

    def test_returns_none_when_fact_value_missing(self):
        r = MagicMock()
        r.status_code = 200
        r.json.return_value = {}
        with patch("httpx.get", return_value=r):
            result = _load_farm_state_snapshot("chat_x")
        assert result is None

    def test_returns_none_when_text_empty(self):
        payload = {
            "fact_value_json": json.dumps({
                "source": "farmos",
                "generated_at": _fresh_ts(),
                "text": "",
            })
        }
        r = MagicMock()
        r.status_code = 200
        r.json.return_value = payload
        with patch("httpx.get", return_value=r):
            result = _load_farm_state_snapshot("chat_x")
        assert result is None

    def test_handles_fact_value_as_plain_json_string(self):
        """fact_value (not fact_value_json) as a JSON string also works."""
        payload = {
            "fact_value": json.dumps({
                "source": "farmos",
                "generated_at": _fresh_ts(),
                "text": "snapshot from fact_value",
            })
        }
        r = MagicMock()
        r.status_code = 200
        r.json.return_value = payload
        with patch("httpx.get", return_value=r):
            result = _load_farm_state_snapshot("chat_x")
        assert result == "snapshot from fact_value"

    def test_returns_none_when_data_not_dict(self):
        """If fact_value_json parses to a list — return None."""
        payload = {"fact_value_json": json.dumps(["bad", "data"])}
        r = MagicMock()
        r.status_code = 200
        r.json.return_value = payload
        with patch("httpx.get", return_value=r):
            result = _load_farm_state_snapshot("chat_x")
        assert result is None

    def test_uses_memory_service_url_env(self):
        captured = {}
        def fake_get(url, params=None, timeout=None):
            captured["url"] = url
            return _make_resp("text")
        with patch.dict(os.environ, {"MEMORY_SERVICE_URL": "http://custom-mem:9999"}):
            with patch("httpx.get", side_effect=fake_get):
                _load_farm_state_snapshot("chat_x")
        assert "custom-mem:9999" in captured.get("url", "")

    def test_uses_agx_memory_service_url_env_priority(self):
        """AGX_MEMORY_SERVICE_URL takes priority over MEMORY_SERVICE_URL."""
        captured = {}
        def fake_get(url, params=None, timeout=None):
            captured["url"] = url
            return _make_resp("text")
        with patch.dict(os.environ, {
            "MEMORY_SERVICE_URL": "http://old-mem:8000",
            "AGX_MEMORY_SERVICE_URL": "http://new-mem:7777",
        }):
            with patch("httpx.get", side_effect=fake_get):
                _load_farm_state_snapshot("chat_x")
        assert "new-mem:7777" in captured.get("url", "")

    def test_timeout_is_2s(self):
        captured = {}
        def fake_get(url, params=None, timeout=None):
            captured["timeout"] = timeout
            return _make_resp("text")
        with patch("httpx.get", side_effect=fake_get):
            _load_farm_state_snapshot("chat_x")
        assert captured.get("timeout") == 2.0

    def test_missing_generated_at_still_returns_text(self):
        """If generated_at is absent, we can't enforce TTL — still return text."""
        payload = {
            "fact_value_json": json.dumps({
                "source": "farmos",
                "text": "snapshot without date",
            })
        }
        r = MagicMock()
        r.status_code = 200
        r.json.return_value = payload
        with patch("httpx.get", return_value=r):
            result = _load_farm_state_snapshot("chat_x")
        assert result == "snapshot without date"

    def test_farm_state_snapshot_ttl_constant_is_24h(self):
        assert _FARM_STATE_SNAPSHOT_TTL_S == 86400.0
