"""
tests/test_stepan_v46_farm_state.py

Unit tests for v4.6 /farm state operator command.

Scope:
  - handle_farm_command (routing + unknown subcommand)
  - _handle_farm_state (full flow: ping → assets → snapshot → save)
  - _parse_asset_lines / _label_from_asset_line / _build_snapshot_text
  - _save_farm_state_snapshot (fail-closed, no leaking)
"""
from __future__ import annotations
import json
import os
import sys
import types
import pytest
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):
    if _p not in sys.path:
        sys.path.insert(0, _p)

# ── Mocks for external deps ────────────────────────────────────────────────────
def _stub(name: str) -> types.ModuleType:
    m = types.ModuleType(name)
    sys.modules[name] = m
    return m

for _mod in ("nats", "nats.aio", "nats.aio.client", "crewai", "crewai.tools"):
    if _mod not in sys.modules:
        _stub(_mod)

sys.modules["crewai.tools"].tool = lambda name: (lambda f: f)  # type: ignore
sys.modules["nats.aio.client"].Client = MagicMock  # type: ignore

_audit_stub = types.ModuleType("agromatrix_tools.audit")
_audit_stub.audit_tool_call = lambda *a, **kw: None  # type: ignore
sys.modules["agromatrix_tools.audit"] = _audit_stub

# ── Import targets ─────────────────────────────────────────────────────────────
import importlib as _il
if "agromatrix_tools.tool_farmos_read" in sys.modules:
    _il.reload(sys.modules["agromatrix_tools.tool_farmos_read"])
if "crews.agromatrix_crew.operator_commands" in sys.modules:
    _il.reload(sys.modules["crews.agromatrix_crew.operator_commands"])

from crews.agromatrix_crew.operator_commands import (  # noqa: E402
    handle_farm_command,
    _parse_asset_lines,
    _label_from_asset_line,
    _build_snapshot_text,
    _save_farm_state_snapshot,
    OPERATOR_COMMANDS,
    parse_operator_command,
)


# ── Fixtures ───────────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def clear_farmos_env(monkeypatch):
    for k in ("FARMOS_BASE_URL", "FARMOS_TOKEN", "FARMOS_USER", "FARMOS_PASS",
              "FARMOS_PASSWORD", "AGX_MEMORY_SERVICE_URL", "MEMORY_SERVICE_URL"):
        monkeypatch.delenv(k, raising=False)
    yield


def _sample_asset_text(labels: list[str]) -> str:
    """Генерує рядки у форматі farmos_search_assets_impl."""
    lines = [f"- {lbl} | asset--land | id=abc12345" for lbl in labels]
    return "\n".join(lines)


# ─── OPERATOR_COMMANDS registry ───────────────────────────────────────────────

class TestFarmCommandRegistry:
    def test_farm_in_operator_commands(self):
        assert "farm" in OPERATOR_COMMANDS

    def test_parse_farm_state(self):
        parsed = parse_operator_command("/farm state")
        assert parsed is not None
        assert parsed["cmd"] == "farm"
        assert "state" in parsed["args"]

    def test_parse_farm_no_args(self):
        parsed = parse_operator_command("/farm")
        assert parsed is not None
        assert parsed["cmd"] == "farm"
        assert parsed["args"] == []


# ─── handle_farm_command ──────────────────────────────────────────────────────

class TestHandleFarmCommand:
    def test_no_args_returns_help(self):
        result = handle_farm_command([])
        assert "підтримується" in result["summary"].lower()
        assert "/farm state" in result["summary"]

    def test_unknown_sub_returns_help(self):
        result = handle_farm_command(["foo"])
        assert "підтримується" in result["summary"].lower()

    def test_returns_dict_with_required_keys(self):
        result = handle_farm_command([])
        for k in ("status", "summary", "artifacts", "tool_calls", "next_actions"):
            assert k in result

    def test_state_no_farmos_env(self):
        """Без FARMOS_BASE_URL → повертає config message, не падає."""
        result = handle_farm_command(["state"], chat_id="test_chat")
        assert "FarmOS" in result["summary"]
        assert "FARMOS_BASE_URL" in result["summary"] or "налаштований" in result["summary"]

    def test_state_ping_unreachable(self, monkeypatch):
        """Якщо ping повертає 'недоступний' → command зупиняється."""
        monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
        monkeypatch.setenv("FARMOS_TOKEN", "tok")
        with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl",
                   return_value="FarmOS недоступний: timeout (3s)."):
            result = handle_farm_command(["state"], chat_id="chat1")
        assert "недоступний" in result["summary"]
        assert "timeout" in result["summary"]

    def test_state_ping_ok_no_assets(self, monkeypatch):
        """Ping OK, але активів немає → повертає 'немає даних'."""
        monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
        monkeypatch.setenv("FARMOS_TOKEN", "tok")
        with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl",
                   return_value="FarmOS доступний."):
            with patch("agromatrix_tools.tool_farmos_read._farmos_search_assets_impl",
                       return_value="FarmOS: нічого не знайдено."):
                result = handle_farm_command(["state"], chat_id="chat1")
        assert "немає даних" in result["summary"] or "FarmOS" in result["summary"]

    def test_state_ping_ok_with_assets(self, monkeypatch):
        """Ping OK, активи знайдено → snapshot contains field count."""
        monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
        monkeypatch.setenv("FARMOS_TOKEN", "tok")

        def _mock_search(asset_type="asset_land", name_contains="", limit=10):
            if asset_type == "asset_land":
                return _sample_asset_text(["Поле Північ", "Поле Південь"])
            if asset_type == "asset_plant":
                return _sample_asset_text(["Пшениця"])
            return "FarmOS: нічого не знайдено."

        with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl",
                   return_value="FarmOS доступний."):
            with patch("agromatrix_tools.tool_farmos_read._farmos_search_assets_impl",
                       side_effect=_mock_search):
                with patch("crews.agromatrix_crew.operator_commands._save_farm_state_snapshot",
                           return_value=True):
                    result = handle_farm_command(["state"], chat_id="chat1")

        assert "Farm state" in result["summary"]
        assert "Поля" in result["summary"]
        assert "2" in result["summary"]  # 2 fields found

    def test_state_snapshot_save_failure_appends_suffix(self, monkeypatch):
        """Якщо memory save fails → suffix додається до відповіді."""
        monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
        monkeypatch.setenv("FARMOS_TOKEN", "tok")

        with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl",
                   return_value="FarmOS доступний."):
            with patch("agromatrix_tools.tool_farmos_read._farmos_search_assets_impl",
                       return_value=_sample_asset_text(["Поле 1"])):
                with patch("crews.agromatrix_crew.operator_commands._save_farm_state_snapshot",
                           return_value=False):
                    result = handle_farm_command(["state"], chat_id="chat1")

        assert "пам'ять" in result["summary"].lower() or "зберег" in result["summary"].lower()

    def test_state_no_chat_id_still_returns_snapshot(self, monkeypatch):
        """Без chat_id snapshot формується, але не зберігається."""
        monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
        monkeypatch.setenv("FARMOS_TOKEN", "tok")

        with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl",
                   return_value="FarmOS доступний."):
            with patch("agromatrix_tools.tool_farmos_read._farmos_search_assets_impl",
                       return_value=_sample_asset_text(["Поле 1"])):
                result = handle_farm_command(["state"], chat_id=None)

        assert "Farm state" in result["summary"] or "FarmOS" in result["summary"]

    def test_never_raises_on_exception(self, monkeypatch):
        """handle_farm_command ніколи не кидає виняток."""
        monkeypatch.setenv("FARMOS_BASE_URL", "http://farmos.example.com")
        monkeypatch.setenv("FARMOS_TOKEN", "tok")
        with patch("agromatrix_tools.tool_farmos_read._farmos_ping_impl",
                   side_effect=RuntimeError("boom")):
            result = handle_farm_command(["state"], chat_id="chat1")
        assert isinstance(result, dict)
        assert "summary" in result


# ─── _parse_asset_lines ───────────────────────────────────────────────────────

class TestParseAssetLines:
    def test_normal_lines(self):
        text = "- Поле А | asset--land | id=abc12345\n- Поле Б | asset--land | id=def56789"
        lines = _parse_asset_lines(text)
        assert len(lines) == 2
        assert all(l.startswith("- ") for l in lines)

    def test_ignores_non_dash_lines(self):
        text = "FarmOS: нічого не знайдено.\nsome garbage"
        lines = _parse_asset_lines(text)
        assert lines == []

    def test_empty_string(self):
        assert _parse_asset_lines("") == []

    def test_mixed_content(self):
        text = "- Поле 1 | asset--land | id=aaa\nFarmOS: записів не знайдено.\n- Поле 2 | asset--land | id=bbb"
        lines = _parse_asset_lines(text)
        assert len(lines) == 2

    def test_never_raises(self):
        result = _parse_asset_lines(None)  # type: ignore[arg-type]
        assert isinstance(result, list)


# ─── _label_from_asset_line ───────────────────────────────────────────────────

class TestLabelFromAssetLine:
    def test_normal(self):
        assert _label_from_asset_line("- Поле Північ | asset--land | id=abc") == "Поле Північ"

    def test_no_pipe(self):
        assert _label_from_asset_line("- Просто назва") == "Просто назва"

    def test_strips_dash(self):
        result = _label_from_asset_line("- Тест | x | y")
        assert result == "Тест"

    def test_empty(self):
        result = _label_from_asset_line("")
        assert isinstance(result, str)


# ─── _build_snapshot_text ─────────────────────────────────────────────────────

class TestBuildSnapshotText:
    def test_all_zeros_returns_no_data(self):
        text = _build_snapshot_text(
            {"asset_land": 0, "asset_plant": 0, "asset_equipment": 0},
            {"asset_land": [], "asset_plant": [], "asset_equipment": []},
        )
        assert "немає даних" in text

    def test_with_counts_shows_label(self):
        text = _build_snapshot_text(
            {"asset_land": 3, "asset_plant": 1, "asset_equipment": 0},
            {"asset_land": ["Поле А", "Поле Б", "Поле В"], "asset_plant": ["Кукурудза"], "asset_equipment": []},
        )
        assert "Поля" in text
        assert "3" in text
        assert "Поле А" in text

    def test_max_chars_respected(self):
        tops = {"asset_land": ["А" * 200], "asset_plant": [], "asset_equipment": []}
        counts = {"asset_land": 1, "asset_plant": 0, "asset_equipment": 0}
        text = _build_snapshot_text(counts, tops)
        assert len(text) <= 900

    def test_starts_with_header(self):
        text = _build_snapshot_text(
            {"asset_land": 1, "asset_plant": 0, "asset_equipment": 0},
            {"asset_land": ["Поле"], "asset_plant": [], "asset_equipment": []},
        )
        assert text.startswith("Farm state")

    def test_top_3_labels_shown(self):
        tops = {"asset_land": ["A", "B", "C", "D"], "asset_plant": [], "asset_equipment": []}
        counts = {"asset_land": 4, "asset_plant": 0, "asset_equipment": 0}
        text = _build_snapshot_text(counts, tops)
        assert "A" in text
        assert "B" in text
        assert "C" in text
        # D — 4-й, має не показуватись
        assert "D" not in text


# ─── _save_farm_state_snapshot ────────────────────────────────────────────────

class TestSaveFarmStateSnapshot:
    def test_returns_true_on_200(self):
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        with patch("httpx.post", return_value=mock_resp):
            result = _save_farm_state_snapshot(
                "chat123",
                {"asset_land": 2, "asset_plant": 0, "asset_equipment": 0},
                {"asset_land": ["Поле"], "asset_plant": [], "asset_equipment": []},
                "Farm state (FarmOS):\n- Поля: 2",
            )
        assert result is True

    def test_returns_true_on_201(self):
        mock_resp = MagicMock()
        mock_resp.status_code = 201
        with patch("httpx.post", return_value=mock_resp):
            result = _save_farm_state_snapshot("chat1", {}, {}, "text")
        assert result is True

    def test_returns_false_on_500(self):
        mock_resp = MagicMock()
        mock_resp.status_code = 500
        with patch("httpx.post", return_value=mock_resp):
            result = _save_farm_state_snapshot("chat1", {}, {}, "text")
        assert result is False

    def test_returns_false_on_exception(self):
        with patch("httpx.post", side_effect=RuntimeError("network fail")):
            result = _save_farm_state_snapshot("chat1", {}, {}, "text")
        assert result is False

    def test_payload_contains_required_fields(self):
        captured = {}
        mock_resp = MagicMock()
        mock_resp.status_code = 200

        def _capture_post(url, *, json, timeout):
            captured.update(json)
            return mock_resp

        with patch("httpx.post", side_effect=_capture_post):
            _save_farm_state_snapshot(
                "chat999",
                {"asset_land": 1, "asset_plant": 0, "asset_equipment": 0},
                {"asset_land": ["Поле"], "asset_plant": [], "asset_equipment": []},
                "Farm state (FarmOS):\n- Поля: 1",
            )

        assert captured.get("user_id") == "farm:chat999"
        assert captured.get("fact_key") == "farm_state:agromatrix:chat:chat999"
        val = captured.get("fact_value_json", {})
        assert val.get("source") == "farmos"
        assert val.get("_version") == 1
        assert "generated_at" in val
        assert "counts" in val
        assert "top" in val
        assert "text" in val

    def test_payload_no_full_uuid(self):
        """top labels не містять повних UUID — тільки short_id або відсутній."""
        captured = {}
        mock_resp = MagicMock()
        mock_resp.status_code = 200

        def _capture_post(url, *, json, timeout):
            captured.update(json)
            return mock_resp

        full_uuid = "12345678-abcd-ef01-2345-67890abcdef0"
        with patch("httpx.post", side_effect=_capture_post):
            _save_farm_state_snapshot(
                "chatX",
                {"asset_land": 1, "asset_plant": 0, "asset_equipment": 0},
                {"asset_land": ["Поле А"], "asset_plant": [], "asset_equipment": []},
                "text",
            )

        val_str = json.dumps(captured)
        assert full_uuid not in val_str

    def test_memory_url_from_env(self, monkeypatch):
        """Використовує AGX_MEMORY_SERVICE_URL з env."""
        monkeypatch.setenv("AGX_MEMORY_SERVICE_URL", "http://custom-memory:9999")
        captured_url = {}
        mock_resp = MagicMock()
        mock_resp.status_code = 200

        def _capture_post(url, *, json, timeout):
            captured_url["url"] = url
            return mock_resp

        with patch("httpx.post", side_effect=_capture_post):
            _save_farm_state_snapshot("c1", {}, {}, "t")

        assert "custom-memory:9999" in captured_url.get("url", "")

    def test_never_reveals_farmos_url(self, monkeypatch):
        """URL farmOS ніколи не потрапляє у payload."""
        monkeypatch.setenv("FARMOS_BASE_URL", "http://secret-farmos.internal")
        captured = {}
        mock_resp = MagicMock()
        mock_resp.status_code = 200

        def _capture_post(url, *, json, timeout):
            captured.update(json)
            return mock_resp

        with patch("httpx.post", side_effect=_capture_post):
            _save_farm_state_snapshot("c1", {"asset_land": 1}, {"asset_land": ["F"]}, "text")

        val_str = json.dumps(captured)
        assert "secret-farmos" not in val_str
