import datetime
import json
from unittest import mock
from unittest.mock import patch, MagicMock
from copy import deepcopy
from freezegun import freeze_time

import pandas as pd
import pytest

from carnotpy import process_equipment, process_date_range, CarnotPy


# ======================================================================
# Helper factories
# ======================================================================

def _mock_response(status_code=200, json_data=None, text=None):
    """Return a MagicMock that behaves like a requests.Response."""
    resp = MagicMock()
    resp.status_code = status_code
    resp.json.return_value = json_data or {}
    resp.text = text or json.dumps(json_data or {})
    return resp


def _make_client(**overrides):
    defaults = dict(
        url="https://api.example.com",
        username="user",
        password="pass",
    )
    defaults.update(overrides)
    return CarnotPy(**defaults)


# ======================================================================
# Tests for process_equipment()
# ======================================================================

class TestProcessEquipment:

    def test_replaces_equipment_in_filter_stage(self):
        payload = {
            "filters": {"equipment_name": "Chiller-1"},
            "pipeline": [
                {"type": "filter", "filter_by": "Chiller-1 - temperature"},
            ],
        }
        process_equipment(payload, "Chiller-2")
        assert payload["pipeline"][0]["filter_by"] == "Chiller-2 - temperature"
        assert payload["filters"]["equipment_name"] == "Chiller-2"

    def test_replaces_equipment_in_formula_stage(self):
        payload = {
            "filters": {"equipment_name": "AHU-1"},
            "pipeline": [
                {"type": "formula", "formula": "AHU-1 - flow * 2"},
            ],
        }
        process_equipment(payload, "AHU-3")
        assert payload["pipeline"][0]["formula"] == "AHU-3 - flow * 2"

    def test_replaces_equipment_in_insert_series_stage(self):
        payload = {
            "filters": {"equipment_name": "Pump-A"},
            "pipeline": [
                {
                    "type": "insert_series",
                    "name": "Pump-A - pressure",
                    "filters": {"equipment_name": "Pump-A"},
                },
            ],
        }
        process_equipment(payload, "Pump-B")
        assert payload["pipeline"][0]["name"] == "Pump-B - pressure"
        assert payload["pipeline"][0]["filters"]["equipment_name"] == "Pump-B"

    def test_insert_series_no_matching_filter(self):
        """insert_series whose sub-filter equipment_name differs from the top-level one."""
        payload = {
            "filters": {"equipment_name": "Pump-A"},
            "pipeline": [
                {
                    "type": "insert_series",
                    "name": "Pump-A - pressure",
                    "filters": {"equipment_name": "Other"},
                },
            ],
        }
        process_equipment(payload, "Pump-B")
        # Sub-filter should NOT be replaced because it doesn't match the top-level name
        assert payload["pipeline"][0]["filters"]["equipment_name"] == "Other"

    def test_noop_when_no_equipment_name_in_filters(self):
        payload = {"filters": {}, "pipeline": []}
        process_equipment(payload, "X")
        assert payload["filters"] == {}

    def test_noop_when_no_pipeline(self):
        payload = {"filters": {"equipment_name": "A"}}
        process_equipment(payload, "B")
        assert payload["filters"]["equipment_name"] == "B"

    def test_empty_pipeline(self):
        payload = {"filters": {"equipment_name": "A"}, "pipeline": []}
        process_equipment(payload, "B")
        assert payload["filters"]["equipment_name"] == "B"


# ======================================================================
# Tests for process_date_range()
# ======================================================================

@freeze_time("2025-06-15 14:30:00")
class TestProcessDateRangeRelative:
    """Custom relative ranges (useCustomRange + useCustomRelativeRange)."""

    def _make_payload(self, rel_range, **extra):
        p = {
            "useCustomRange": True,
            "useCustomRelativeRange": True,
            "customRelativeRange": rel_range,
        }
        p.update(extra)
        return p

    def test_current_week(self):
        payload = self._make_payload("current week")
        process_date_range(payload, None)
        # 2025-06-15 is a Sunday (weekday=6)
        assert payload["date_range"][0] == "2025-06-09"  # Monday

    def test_current_month(self):
        payload = self._make_payload("current month")
        process_date_range(payload, None)
        assert payload["date_range"][0] == "2025-06-01"

    def test_today(self):
        payload = self._make_payload("today")
        process_date_range(payload, None)
        assert payload["date_range"][0] == "2025-06-15 00:00:00"
        assert payload["date_range"][1] == "2025-06-15 14:30:00"

    def test_today_full(self):
        payload = self._make_payload("today full")
        process_date_range(payload, None)
        # 'today full' is not in the explicit time-format list, so date-only start
        assert payload["date_range"][0] == "2025-06-15"

    def test_current_year(self):
        payload = self._make_payload("current year")
        process_date_range(payload, None)
        assert payload["date_range"][0] == "2025-01-01"

    def test_last_week(self):
        payload = self._make_payload("last week")
        process_date_range(payload, None)
        # last week: base moves back 1 week to 2025-06-08 (Sun)
        # start_date = end_date - weekday offset → Monday of that week
        start = payload["date_range"][0]
        assert "2025-06" in start

    def test_last_month(self):
        payload = self._make_payload("last month")
        process_date_range(payload, None)
        assert payload["date_range"][0] == "2025-05-01"

    def test_yesterday(self):
        payload = self._make_payload("yesterday")
        process_date_range(payload, None)
        assert payload["date_range"][0] == "2025-06-14 00:00:00"
        # end uses base_date (which keeps the original frozen time component)
        assert payload["date_range"][1] == "2025-06-14 14:30:00"

    def test_last_year(self):
        payload = self._make_payload("last year")
        process_date_range(payload, None)
        # Note: source code does year-1 twice for start (base_date already decremented)
        assert payload["date_range"][0].startswith("2023-01-01")
        assert payload["date_range"][1].startswith("2024-12-31")

    def test_today_and_tomorrow(self):
        payload = self._make_payload("today and tomorrow")
        process_date_range(payload, None)
        assert payload["date_range"][0] == "2025-06-15 00:00:00"
        assert payload["date_range"][1] == "2025-06-16 23:59:59"

    def test_default_30_days(self):
        payload = self._make_payload("30 days")
        process_date_range(payload, None)
        assert payload["date_range"][0] == "2025-05-16"

    def test_custom_relative_start_only(self):
        payload = self._make_payload("today", customRelativeRangeStartOnly=True)
        date_range = {"start": "2025-01-01", "end": "2025-12-31"}
        process_date_range(payload, date_range)
        assert payload["date_range"][0] == "2025-06-15 00:00:00"
        assert payload["date_range"][1] == "2025-12-31 23:59:59"


class TestProcessDateRangeCustom:
    """Custom fixed ranges (useCustomRange + customRange)."""

    def test_custom_range(self):
        payload = {
            "useCustomRange": True,
            "customRange": {"start": "2025-03-01", "end": "2025-03-31"},
        }
        process_date_range(payload, None)
        assert payload["date_range"] == ["2025-03-01", "2025-03-31 23:59:59"]

    def test_custom_range_start_only(self):
        payload = {
            "useCustomRange": True,
            "customRange": {"start": "2025-03-01", "end": "2025-03-31"},
            "customRangeStartOnly": True,
        }
        date_range = {"start": "2025-01-01", "end": "2025-06-30"}
        process_date_range(payload, date_range)
        assert payload["date_range"][0] == "2025-03-01"
        assert payload["date_range"][1] == "2025-06-30 23:59:59"

    def test_custom_range_end_already_has_time(self):
        payload = {
            "useCustomRange": True,
            "customRange": {"start": "2025-03-01", "end": "2025-03-31 12:00:00"},
        }
        process_date_range(payload, None)
        assert payload["date_range"][1] == "2025-03-31 12:00:00"


class TestProcessDateRangeOffset:
    """Offset date ranges (no useCustomRange, using offsetRange)."""

    def test_offset_days(self):
        payload = {
            "offsetRange": True,
            "offsetType": "days",
            "offsetAmount": 7,
            "offsetRangeStartOnly": False,
        }
        date_range = {"start": "2025-06-15", "end": "2025-06-22"}
        process_date_range(payload, date_range)
        assert payload["date_range"][0] == "2025-06-08"
        assert payload["date_range"][1] == "2025-06-15 23:59:59"

    def test_offset_months(self):
        payload = {
            "offsetRange": True,
            "offsetType": "months",
            "offsetAmount": 1,
            "offsetRangeStartOnly": False,
        }
        date_range = {"start": "2025-06-15", "end": "2025-06-22"}
        process_date_range(payload, date_range)
        assert payload["date_range"][0] == "2025-05-15"
        assert payload["date_range"][1] == "2025-05-22 23:59:59"

    def test_offset_years(self):
        payload = {
            "offsetRange": True,
            "offsetType": "years",
            "offsetAmount": 1,
            "offsetRangeStartOnly": False,
        }
        date_range = {"start": "2025-06-15", "end": "2025-06-22"}
        process_date_range(payload, date_range)
        assert payload["date_range"][0] == "2024-06-15"
        assert payload["date_range"][1] == "2024-06-22 23:59:59"

    def test_offset_precise_year(self):
        payload = {
            "offsetRange": True,
            "offsetType": "years",
            "offsetAmount": 1,
            "offsetPreciseYear": 2023,
            "offsetRangeStartOnly": False,
        }
        date_range = {"start": "2025-06-15", "end": "2025-06-22"}
        process_date_range(payload, date_range)
        assert payload["date_range"][0] == "2023-06-15"
        assert payload["date_range"][1] == "2023-06-22 23:59:59"

    def test_offset_start_only_days(self):
        payload = {
            "offsetRange": True,
            "offsetType": "days",
            "offsetAmount": 7,
            "offsetRangeStartOnly": True,
        }
        date_range = {"start": "2025-06-15", "end": "2025-06-22"}
        process_date_range(payload, date_range)
        assert payload["date_range"][0] == "2025-06-08"
        assert payload["date_range"][1] == "2025-06-22 23:59:59"

    def test_offset_start_only_months(self):
        payload = {
            "offsetRange": True,
            "offsetType": "months",
            "offsetAmount": 2,
            "offsetRangeStartOnly": True,
        }
        date_range = {"start": "2025-06-15", "end": "2025-06-22"}
        process_date_range(payload, date_range)
        assert payload["date_range"][0] == "2025-04-15"
        assert payload["date_range"][1] == "2025-06-22 23:59:59"

    def test_offset_start_only_years(self):
        payload = {
            "offsetRange": True,
            "offsetType": "years",
            "offsetAmount": 2,
            "offsetRangeStartOnly": True,
        }
        date_range = {"start": "2025-06-15", "end": "2025-06-22"}
        process_date_range(payload, date_range)
        assert payload["date_range"][0] == "2023-06-15"
        assert payload["date_range"][1] == "2025-06-22 23:59:59"

    def test_offset_start_only_precise_year(self):
        payload = {
            "offsetRange": True,
            "offsetType": "year",
            "offsetAmount": 1,
            "offsetPreciseYear": 2020,
            "offsetRangeStartOnly": True,
        }
        date_range = {"start": "2025-06-15", "end": "2025-06-22"}
        process_date_range(payload, date_range)
        assert payload["date_range"][0] == "2020-06-15"
        assert payload["date_range"][1] == "2025-06-22 23:59:59"


class TestProcessDateRangePassthrough:
    """No custom range, no offset → simple passthrough."""

    def test_passthrough(self):
        payload = {}
        date_range = {"start": "2025-01-01", "end": "2025-12-31"}
        process_date_range(payload, date_range)
        assert payload["date_range"] == ["2025-01-01", "2025-12-31 23:59:59"]

    def test_passthrough_end_has_time(self):
        payload = {}
        date_range = {"start": "2025-01-01", "end": "2025-12-31 11:00:00"}
        process_date_range(payload, date_range)
        assert payload["date_range"] == ["2025-01-01", "2025-12-31 11:00:00"]

    def test_no_date_range(self):
        payload = {}
        process_date_range(payload, None)
        assert "date_range" not in payload


# ======================================================================
# Tests for CarnotPy class
# ======================================================================

class TestCarnotPyInit:
    def test_init(self):
        c = _make_client()
        assert c.url == "https://api.example.com"
        assert c.username == "user"
        assert c.password == "pass"
        assert c.preset_token is None

    def test_init_with_preset_token(self):
        c = _make_client(preset_token="tok123")
        assert c.preset_token == "tok123"


class TestGetToken:

    @patch("carnotpy.requests.post")
    def test_returns_token_from_login(self, mock_post):
        mock_post.return_value = _mock_response(200, {"token": "abc"})
        c = _make_client()
        assert c.get_token() == "abc"
        mock_post.assert_called_once_with(
            "https://api.example.com/login/",
            json={"user_name": "user", "password": "pass"},
        )

    def test_returns_preset_token(self):
        c = _make_client(preset_token="preset")
        assert c.get_token() == "preset"

    @patch("carnotpy.requests.post")
    def test_raises_on_invalid_credentials(self, mock_post):
        mock_post.return_value = _mock_response(200, {"error": "bad"})
        c = _make_client()
        with pytest.raises(ValueError, match="Invalid username or password"):
            c.get_token()

    @patch("carnotpy.requests.post")
    def test_raises_on_non_200(self, mock_post):
        mock_post.return_value = _mock_response(401)
        c = _make_client()
        with pytest.raises(AssertionError):
            c.get_token()


class TestDiscover:

    @patch("carnotpy.requests.post")
    def test_discover(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": [{"id": 1}]}),
        ]
        c = _make_client()
        assert c.discover() == [{"id": 1}]
        assert mock_post.call_count == 2


class TestGetNotifications:

    @patch("carnotpy.requests.post")
    def test_get_notifications(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": [{"msg": "alert"}]}),
        ]
        c = _make_client()
        res = c.get_notifications("facility1", date_range=["2025-01-01", "2025-12-31"])
        assert res == [{"msg": "alert"}]


class TestGetConfig:

    @patch("carnotpy.requests.post")
    def test_get_config(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": {"key": "value"}}),
        ]
        c = _make_client()
        assert c.get_config("fac") == {"key": "value"}


class TestSetConfig:

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_set_config(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "ok"})
        c = _make_client()
        res = c.set_config("fac", "cfg_name", {"a": 1}, config_id="cid")
        assert res == {"result": "ok"}
        call_json = mock_put.call_args[1]["json"]
        assert call_json["facility_name"] == "fac"
        assert call_json["config_name"] == "cfg_name"
        assert call_json["config_id"] == "cid"


class TestGetMaintenanceRecords:

    @patch("carnotpy.requests.post")
    def test_get_maintenance_records(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": []}),
        ]
        c = _make_client()
        assert c.get_maintenance_records("fac") == []


class TestReadHistory:

    @patch("carnotpy.requests.post")
    def test_returns_dataframe(self, mock_post):
        data = {
            "timestamp": ["2025-01-01T00:00:00", "2025-01-01T01:00:00"],
            "value": [10, 20],
        }
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, data),
        ]
        c = _make_client()
        df = c.read_history(["s1"], "2025-01-01", "2025-01-02")
        assert isinstance(df, pd.DataFrame)
        assert df.index.name == "timestamp"
        assert list(df["value"]) == [10, 20]


class TestReadData3:

    @patch("carnotpy.requests.post")
    def test_returns_dataframe(self, mock_post):
        data = {
            "timestamp": ["2025-01-01T00:00:00", "2025-01-01T01:00:00"],
            "col1": [1.0, 2.0],
        }
        mock_post.side_effect = [
            _mock_response(200, data, text=json.dumps(data)),
        ]
        c = _make_client()
        df = c.read_data3({"some": "payload"}, token="pre")
        assert isinstance(df, pd.DataFrame)
        assert list(df.columns) == ["col1"]


class TestReadData3Query:

    @patch("carnotpy.requests.post")
    def test_basic_query(self, mock_post):
        data = {
            "timestamp": ["2025-01-01T00:00:00"],
            "val": [42],
        }
        login_resp = _mock_response(200, {"token": "t"})
        data_resp = _mock_response(200, data, text=json.dumps(data))
        mock_post.side_effect = [login_resp, data_resp]

        query = {
            "data-payload": [
                {
                    "filters": {"facility_name": "fac"},
                    "seriesOverrides": [],
                }
            ]
        }
        c = _make_client()
        df = c.read_data3_query(
            query, "fac", {"start": "2025-01-01", "end": "2025-01-02"}
        )
        assert isinstance(df, pd.DataFrame)

    @patch("carnotpy.requests.post")
    def test_query_with_equipment_and_on_only(self, mock_post):
        data = {
            "timestamp": ["2025-01-01T00:00:00"],
            "val": [1],
        }
        login_resp = _mock_response(200, {"token": "t"})
        data_resp = _mock_response(200, data, text=json.dumps(data))
        mock_post.side_effect = [login_resp, data_resp]

        query = {
            "data-payload": [
                {
                    "filters": {"equipment_name": "Chiller-1"},
                    "seriesOverrides": [],
                }
            ]
        }
        c = _make_client()
        df = c.read_data3_query(
            query, "fac", {"start": "2025-01-01", "end": "2025-01-02"},
            equipment="Chiller-2", on_only=True,
        )
        assert isinstance(df, pd.DataFrame)

    @patch("carnotpy.requests.post")
    def test_independent_x_axis(self, mock_post):
        data1 = {"timestamp": ["2025-01-01T00:00:00"], "a": [1]}
        data2 = {"timestamp": ["2025-01-01T01:00:00"], "b": [2]}
        login_resp = _mock_response(200, {"token": "t"})
        resp1 = _mock_response(200, data1, text=json.dumps(data1))
        resp2 = _mock_response(200, data2, text=json.dumps(data2))
        mock_post.side_effect = [login_resp, resp1, resp2]

        query = {
            "data-payload": [
                {"filters": {"facility_name": "fac"}, "seriesOverrides": []},
                {"filters": {"facility_name": "fac"}, "seriesOverrides": []},
            ]
        }
        c = _make_client()
        df = c.read_data3_query(
            query, "fac", {"start": "2025-01-01", "end": "2025-01-02"},
            independent_x_axis=True,
        )
        assert "a" in df.columns
        assert "b" in df.columns


class TestGetSensorId:

    @patch("carnotpy.requests.post")
    def test_found(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": [
                {"sensor_name": "temp", "sensor_id": "s1"},
                {"sensor_name": "flow", "sensor_id": "s2"},
            ]}),
        ]
        c = _make_client()
        assert c.get_sensor_id("fac", "equip", "flow") == "s2"

    @patch("carnotpy.requests.post")
    def test_not_found(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": []}),
        ]
        c = _make_client()
        with pytest.raises(KeyError, match="Sensor not found"):
            c.get_sensor_id("fac", "equip", "missing")


class TestWrite:

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_write(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "ok"})
        c = _make_client()
        res = c.write("s1", 72, 8, duration=60)
        assert res == {"result": "ok"}
        call_json = mock_put.call_args[1]["json"]
        assert call_json["sensor_id"] == "s1"
        assert call_json["value"] == 72
        assert call_json["priority"] == 8
        assert call_json["duration"] == 60


class TestAddJob:

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_add_job(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "queued"})
        c = _make_client()
        res = c.add_job("fac", "export", {"format": "csv"})
        assert res == {"result": "queued"}


# ------------------------------------------------------------------ #
#  Point CRUD
# ------------------------------------------------------------------ #

class TestGetPoints:

    @patch("carnotpy.requests.post")
    def test_get_points(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": [{"sensor_id": "s1"}]}),
        ]
        c = _make_client()
        assert c.get_points("fac") == [{"sensor_id": "s1"}]

    @patch("carnotpy.requests.post")
    def test_get_points_with_equipment(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": []}),
        ]
        c = _make_client()
        res = c.get_points("fac", equipment_name="equip")
        assert res == []
        call_json = mock_post.call_args[1]["json"]
        assert call_json["equipment_name"] == "equip"


class TestCreatePoint:

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_minimal(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "created", "record": {}})
        c = _make_client()
        res = c.create_point("fac", "equip", "temp_sensor")
        assert res["result"] == "created"
        call_json = mock_put.call_args[1]["json"]
        assert call_json["sensor_name"] == "temp_sensor"
        assert call_json["faulty"] is False
        assert "unit" not in call_json

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_all_optional_fields(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "created", "record": {}})
        c = _make_client()
        c.create_point(
            "fac", "equip", "temp",
            sensor_name_abbr="T",
            sensor_desc="Temperature",
            unit="degC",
            haystack_id="h1",
            calculations_statement="df[2]+df[3]",
            weight=2,
            connector_id="c1",
            faulty=True,
            meta={"tag": "hot"},
        )
        call_json = mock_put.call_args[1]["json"]
        assert call_json["unit"] == "degC"
        assert call_json["meta"] == {"tag": "hot"}
        assert call_json["faulty"] is True


class TestUpdatePoint:

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_updates_allowed_fields(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "updated", "record": {}})
        c = _make_client()
        c.update_point("s1", "fac", "equip", sensor_name="new_name", unit="kW")
        call_json = mock_put.call_args[1]["json"]
        assert call_json["sensor_name"] == "new_name"
        assert call_json["unit"] == "kW"

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_ignores_disallowed_fields(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "updated", "record": {}})
        c = _make_client()
        c.update_point("s1", "fac", "equip", unknown_field="should_be_ignored")
        call_json = mock_put.call_args[1]["json"]
        assert "unknown_field" not in call_json


class TestDeletePoint:

    @patch("carnotpy.requests.delete")
    @patch("carnotpy.requests.post")
    def test_delete(self, mock_post, mock_delete):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_delete.return_value = _mock_response(200, {"result": "deleted"})
        c = _make_client()
        assert c.delete_point("s1") == {"result": "deleted"}


# ------------------------------------------------------------------ #
#  Equipment CRUD
# ------------------------------------------------------------------ #

class TestGetEquipment:

    @patch("carnotpy.requests.post")
    def test_get_equipment(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": [{"equipment_name": "AHU-1"}]}),
        ]
        c = _make_client()
        assert c.get_equipment("fac") == [{"equipment_name": "AHU-1"}]


class TestCreateEquipment:

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_minimal(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "created", "record": {}})
        c = _make_client()
        res = c.create_equipment("fac", "AHU-1")
        assert res["result"] == "created"

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_with_all_optional(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "ok"})
        c = _make_client()
        c.create_equipment(
            "fac", "AHU-1",
            equipment_type="ahu",
            equipment_tags=["hvac"],
            floor="2F",
            notes="north wing",
            meta={"brand": "Acme"},
            graphic_id="g1",
        )
        call_json = mock_put.call_args[1]["json"]
        assert call_json["equipment_type"] == "ahu"
        assert call_json["graphic_id"] == "g1"


class TestUpdateEquipment:

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_update(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "updated"})
        c = _make_client()
        c.update_equipment("e1", "fac", "AHU-1", floor="3F")
        call_json = mock_put.call_args[1]["json"]
        assert call_json["floor"] == "3F"
        assert call_json["equipment_id"] == "e1"

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_ignores_disallowed(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "updated"})
        c = _make_client()
        c.update_equipment("e1", "fac", "AHU-1", bad_field="x")
        call_json = mock_put.call_args[1]["json"]
        assert "bad_field" not in call_json


class TestDeleteEquipment:

    @patch("carnotpy.requests.delete")
    @patch("carnotpy.requests.post")
    def test_delete(self, mock_post, mock_delete):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_delete.return_value = _mock_response(200, {"result": "deleted"})
        c = _make_client()
        assert c.delete_equipment("e1") == {"result": "deleted"}


# ------------------------------------------------------------------ #
#  Connector CRUD
# ------------------------------------------------------------------ #

class TestGetConnectors:

    @patch("carnotpy.requests.post")
    def test_get(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": []}),
        ]
        c = _make_client()
        assert c.get_connectors("fac") == []


class TestCreateConnector:

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_create(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "created"})
        c = _make_client()
        res = c.create_connector("fac", "My BACnet", connector_type="bacnet", config={"ip": "10.0.0.1"})
        assert res["result"] == "created"
        call_json = mock_put.call_args[1]["json"]
        assert call_json["type"] == "bacnet"


class TestUpdateConnector:

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_update(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "updated"})
        c = _make_client()
        c.update_connector("c1", "fac", "My BACnet", connector_type="modbus")
        call_json = mock_put.call_args[1]["json"]
        assert call_json["connector_id"] == "c1"
        assert call_json["type"] == "modbus"


class TestDeleteConnector:

    @patch("carnotpy.requests.delete")
    @patch("carnotpy.requests.post")
    def test_delete(self, mock_post, mock_delete):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_delete.return_value = _mock_response(200, {"result": "deleted"})
        c = _make_client()
        assert c.delete_connector("c1") == {"result": "deleted"}


# ------------------------------------------------------------------ #
#  Equipment Relationship CRUD
# ------------------------------------------------------------------ #

class TestGetEquipmentRelationships:

    @patch("carnotpy.requests.post")
    def test_get(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": [{"equip1": "A", "equip2": "B"}]}),
        ]
        c = _make_client()
        res = c.get_equipment_relationships("fac")
        assert len(res) == 1


class TestCreateEquipmentRelationship:

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_create(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "created"})
        c = _make_client()
        res = c.create_equipment_relationship("fac", "Chiller-1", "AHU-1", "serves")
        assert res["result"] == "created"
        call_json = mock_put.call_args[1]["json"]
        assert call_json["type"] == "serves"


class TestUpdateEquipmentRelationship:

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_update(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "updated"})
        c = _make_client()
        res = c.update_equipment_relationship("r1", "fac", "A", "B", "contains")
        assert res["result"] == "updated"
        call_json = mock_put.call_args[1]["json"]
        assert call_json["equipment_relationship_id"] == "r1"


class TestDeleteEquipmentRelationship:

    @patch("carnotpy.requests.delete")
    @patch("carnotpy.requests.post")
    def test_delete(self, mock_post, mock_delete):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_delete.return_value = _mock_response(200, {"result": "deleted"})
        c = _make_client()
        assert c.delete_equipment_relationship("r1") == {"result": "deleted"}


# ------------------------------------------------------------------ #
#  Data Ingestion
# ------------------------------------------------------------------ #

class TestUploadSeries:

    @patch("carnotpy.requests.put")
    @patch("carnotpy.requests.post")
    def test_upload(self, mock_post, mock_put):
        mock_post.return_value = _mock_response(200, {"token": "t"})
        mock_put.return_value = _mock_response(200, {"result": "ok"})
        c = _make_client()
        res = c.upload_series("s1", ["2025-01-01T00:00:00"], [42])
        assert res["result"] == "ok"
        call_json = mock_put.call_args[1]["json"]
        assert call_json["sensor_id"] == "s1"
        assert call_json["timestamps"] == ["2025-01-01T00:00:00"]


class TestGetLatestReadings:

    @patch("carnotpy.requests.post")
    def test_get(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": [{"sensor_id": "s1", "value": 72}]}),
        ]
        c = _make_client()
        res = c.get_latest_readings("fac")
        assert res[0]["value"] == 72

    @patch("carnotpy.requests.post")
    def test_with_equipment(self, mock_post):
        mock_post.side_effect = [
            _mock_response(200, {"token": "t"}),
            _mock_response(200, {"result": []}),
        ]
        c = _make_client()
        c.get_latest_readings("fac", equipment_name="AHU-1")
        call_json = mock_post.call_args[1]["json"]
        assert call_json["equipment_name"] == "AHU-1"
