import pandas as pd
import datetime
import requests
from dateutil.relativedelta import relativedelta
import json


def process_equipment(payload, equipment_name):
    if payload['filters'].get('equipment_name'):
        if payload.get('pipeline') and len(payload['pipeline']) > 0:
            for stage in payload['pipeline']:
                if stage['type'] == 'filter':
                    stage['filter_by'] = stage['filter_by'].replace(payload['filters']['equipment_name'] + ' - ',
                                                                    equipment_name + ' - ')
                elif stage.get('formula') and stage['formula'] != '':
                    stage['formula'] = stage['formula'].replace(payload['filters']['equipment_name'] + ' - ',
                                                                equipment_name + ' - ')
                elif stage['type'] == 'insert_series':
                    stage['name'] = stage['name'].replace(payload['filters']['equipment_name'] + ' - ',
                                                          equipment_name + ' - ')
                    if stage.get('filters') and stage['filters']['equipment_name'] == payload['filters'][
                        'equipment_name']:
                        stage['filters']['equipment_name'] = equipment_name
        payload['filters']['equipment_name'] = equipment_name


def process_date_range(payload, date_range):
    if payload.get('useCustomRange'):
        if payload.get('useCustomRelativeRange'):
            rel_range = payload.get('customRelativeRange', '30 days')
            new_range = None
            base_date = datetime.datetime.now()
            start_date = base_date - datetime.timedelta(days=30)
            if rel_range == 'current week':
                start_date = base_date - datetime.timedelta(days=base_date.weekday())
            elif rel_range == 'current month':
                start_date = base_date.replace(day=1)
            elif rel_range == 'today' or rel_range == "today full":
                start_date = base_date.replace(hour=0, minute=0, second=0, microsecond=0)
            elif rel_range == 'current year':
                start_date = base_date.replace(month=1, day=1)
            elif rel_range == 'last week':
                base_date = base_date - datetime.timedelta(weeks=1)
                end_date = base_date.replace(hour=23, minute=59, second=59, microsecond=999999)
                start_date = end_date - datetime.timedelta(days=end_date.weekday())
            elif rel_range == 'last month':
                base_date = base_date.replace(day=1) - datetime.timedelta(days=1)
                end_date = base_date.replace(hour=23, minute=59, second=59, microsecond=999999)
                start_date = end_date.replace(day=1)
            elif rel_range == 'yesterday':
                base_date = base_date - datetime.timedelta(days=1)
                end_date = base_date.replace(hour=23, minute=59, second=59, microsecond=999999)
                start_date = end_date.replace(hour=0, minute=0, second=0, microsecond=0)
            elif rel_range == 'last year':
                base_date = base_date.replace(year=base_date.year - 1, month=12, day=31)
                end_date = base_date.replace(hour=23, minute=59, second=59, microsecond=999999)
                start_date = end_date.replace(year=base_date.year - 1, month=1, day=1)
            elif rel_range == 'today and tomorrow':
                start_date = base_date.replace(hour=0, minute=0, second=0, microsecond=0)
                end_date = base_date + datetime.timedelta(days=1)
                base_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999)

            if rel_range in ['yesterday', 'today', 'today and tomorrow']:
                new_range = {
                    'start': start_date.strftime('%Y-%m-%d %H:%M:%S'),
                    'end': base_date.strftime('%Y-%m-%d %H:%M:%S')
                }
            else:
                new_range = {
                    'start': start_date.strftime('%Y-%m-%d'),
                    'end': (base_date.strftime('%Y-%m-%d %H:%M:%S') if len(
                        base_date.strftime('%Y-%m-%d')) < 12 else base_date.strftime('%Y-%m-%d'))
                }
            if payload.get('customRelativeRangeStartOnly') and date_range and date_range['end']:
                payload['date_range'] = [new_range['start'], (
                    date_range['end'] + ' 23:59:59' if len(date_range['end']) < 12 else date_range['end'])]
            else:
                payload['date_range'] = [new_range['start'], (
                    new_range['end'] + ' 23:59:59' if len(new_range['end']) < 12 else new_range['end'])]

        elif payload.get('customRange'):
            if payload.get('customRangeStartOnly') and date_range and date_range['end']:
                payload['date_range'] = [payload['customRange']['start'], (
                    date_range['end'] + ' 23:59:59' if len(date_range['end']) < 12 else date_range['end'])]
            else:
                payload['date_range'] = [payload['customRange']['start'], (
                    payload['customRange']['end'] + ' 23:59:59' if len(payload['customRange']['end']) < 12 else
                    payload['customRange']['end'])]

    elif date_range:
        if payload.get('offsetRange') and payload.get('offsetType'):
            if payload['offsetRangeStartOnly']:
                if payload['offsetType'] in ['year', 'years'] and payload.get('offsetPreciseYear'):
                    payload['date_range'] = [
                        datetime.datetime.strptime(date_range['start'], '%Y-%m-%d').replace(
                            year=payload['offsetPreciseYear']).strftime('%Y-%m-%d'),
                        (date_range['end'] + ' 23:59:59' if len(date_range['end']) < 12 else date_range['end'])
                    ]
                elif payload['offsetType'] in ['month', 'months']:
                    offset = relativedelta(months=-payload['offsetAmount'])
                    payload['date_range'] = [
                        (datetime.datetime.strptime(date_range['start'], '%Y-%m-%d') + offset).strftime('%Y-%m-%d'),
                        (date_range['end'] + ' 23:59:59' if len(date_range['end']) < 12 else date_range['end'])
                    ]
                elif payload['offsetType'] in ['year', 'years']:
                    offset = relativedelta(years=-payload['offsetAmount'])
                    payload['date_range'] = [
                        (datetime.datetime.strptime(date_range['start'], '%Y-%m-%d') + offset).strftime('%Y-%m-%d'),
                        (date_range['end'] + ' 23:59:59' if len(date_range['end']) < 12 else date_range['end'])
                    ]
                else:
                    payload['date_range'] = [
                        (datetime.datetime.strptime(date_range['start'], '%Y-%m-%d') - datetime.timedelta(
                            **{payload['offsetType']: payload['offsetAmount']})).strftime('%Y-%m-%d'),
                        (date_range['end'] + ' 23:59:59' if len(date_range['end']) < 12 else date_range['end'])
                    ]
            else:
                if payload['offsetType'] in ['year', 'years'] and payload.get('offsetPreciseYear'):
                    payload['date_range'] = [
                        datetime.datetime.strptime(date_range['start'], '%Y-%m-%d').replace(
                            year=payload['offsetPreciseYear']).strftime('%Y-%m-%d'),
                        datetime.datetime.strptime(date_range['end'], '%Y-%m-%d').replace(
                            year=payload['offsetPreciseYear']).strftime('%Y-%m-%d') + ' 23:59:59'
                    ]
                elif payload['offsetType'] in ['month', 'months']:
                    offset = relativedelta(months=-payload['offsetAmount'])
                    payload['date_range'] = [
                        (datetime.datetime.strptime(date_range['start'], '%Y-%m-%d') + offset).strftime('%Y-%m-%d'),
                        (datetime.datetime.strptime(date_range['end'], '%Y-%m-%d') + offset).strftime(
                            '%Y-%m-%d') + ' 23:59:59'
                    ]
                elif payload['offsetType'] in ['year', 'years']:
                    offset = relativedelta(years=-payload['offsetAmount'])
                    payload['date_range'] = [
                        (datetime.datetime.strptime(date_range['start'], '%Y-%m-%d') + offset).strftime('%Y-%m-%d'),
                        (datetime.datetime.strptime(date_range['end'], '%Y-%m-%d') + offset).strftime(
                            '%Y-%m-%d') + ' 23:59:59'
                    ]
                else:
                    payload['date_range'] = [
                        (datetime.datetime.strptime(date_range['start'], '%Y-%m-%d') - datetime.timedelta(
                            **{payload['offsetType']: payload['offsetAmount']})).strftime('%Y-%m-%d'),
                        (datetime.datetime.strptime(date_range['end'], '%Y-%m-%d') - datetime.timedelta(
                            **{payload['offsetType']: payload['offsetAmount']})).strftime('%Y-%m-%d') + ' 23:59:59'
                    ]
        else:
            payload['date_range'] = [date_range['start'], (
                date_range['end'] + ' 23:59:59' if len(date_range['end']) < 12 else date_range['end'])]


class CarnotPy:

    def __init__(self, url, username, password, preset_token=None):
        self.url = url
        self.username = username
        self.password = password
        self.preset_token = preset_token
        if preset_token:
            self.refresh_token(long_duration=True)

    def get_token(self):
        if self.preset_token is not None:
            return self.preset_token
        result = requests.post(self.url + "/login/", json={"user_name": self.username, "password": self.password})
        assert result.status_code == 200
        try:
            token = result.json()["token"]
            return token
        except KeyError:
            raise ValueError("Invalid username or password")

    def refresh_token(self, long_duration=False):
        """
        Refresh the current JWT token via the /refresh-token/ endpoint.
        Optionally request a long-duration token.

        :param long_duration: If True, request a token with extended expiration
        :return: The new token string
        """
        result = requests.post(
            self.url + "/refresh-token/",
            json={"long_expiration": long_duration},
            headers={"Authorization": "Bearer " + self.preset_token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        data = result.json()
        if data.get('result') != 'OK':
            raise ValueError(data.get('desc', 'Token refresh failed'))
        new_token = data['token']
        self.preset_token = new_token
        return new_token

    def discover(self):
        """
        Get the point list of the facility
        :return: return a point list
        """
        # return a point list
        token = self.get_token()
        result = requests.post(self.url + "/point-discovery/",
                               headers={"Authorization": "Bearer " + token}
                               )
        assert result.status_code == 200
        return result.json()["result"]

    def get_notifications(self, facility_name, date_range=None, ignore_acknowledged=True):
        token = self.get_token()
        payload={
            "facility_name": facility_name,
            "date_range": date_range,
            "ignore_acknowledged": ignore_acknowledged
        }
        result = requests.post(
            self.url + "/notification/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()["result"]

    def get_config(self, facility_name):
        token = self.get_token()
        payload={
            "facility_name": facility_name,
        }
        result = requests.post(
            self.url + "/config/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()["result"]

    def set_config(self, facility_name, config_name, config, config_id=None):
        token = self.get_token()
        result = requests.put(
            self.url + "/config/",
            json={
                "facility_name": facility_name,
                "config_name": config_name,
                "config": config,
                "config_id": config_id
            },
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    def get_maintenance_records(self, facility_name, date_range=None, equipment_name=None):
        token = self.get_token()
        payload={
            "facility_name": facility_name,
            "date_range": date_range,
            "equipment_name": equipment_name
        }
        result = requests.post(
            self.url + "/maintenancerecord/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()["result"]

    def read_history(self, sensor_ids, start_time, end_time):
        token = self.get_token()
        result = requests.post(
            self.url + "/historical-data/",
            json={
                "date_range": [start_time, end_time],
                "sensor_ids": sensor_ids
            },
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        data = result.json()
        data['timestamp'] = pd.to_datetime(data['timestamp'])
        df = pd.DataFrame(data)
        df = df.set_index('timestamp')
        return df

    def read_data3(self, payload, token=None):
        if token is None:
            token = self.get_token()
        result = requests.post(
            self.url + "/data3/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        data = json.loads(result.text)  # check if it's valid json, will raise an error if not
        # data = result.json()
        data['timestamp'] = pd.to_datetime(data['timestamp'])
        df = pd.DataFrame(data)
        df = df.set_index('timestamp')
        return df

    def read_data3_query(self, query, facility_name, date_range, equipment=None, on_only=False, parameters=None, data3_implementation=None, independent_x_axis=False):
        if data3_implementation is None:
            def get_read_data3_func():
                # return a function that reads data3 with the token already
                token = self.get_token()
                def my_read_data3(payload):
                    return self.read_data3(payload, token)
                return my_read_data3
            data3_implementation = get_read_data3_func()
        payload_array = query['data-payload']
        df = None
        index = None
        for payload in payload_array:
            payload['parameters'] = parameters
            process_date_range(payload, date_range)
            if 'facility_name' not in payload["filters"]:
                payload["filters"]["facility_name"] = facility_name
            if equipment is not None:
                process_equipment(payload, equipment)
            if on_only or payload.get('onOnly', False):
                if 'pipeline' not in payload:
                    payload['pipeline'] = []
                payload['pipeline'].append({
                                  "type": "filter",
                                  "filter_op": "eq",
                                  "filter_by": "* - on",
                                  "filter_value": 1,
                              })
            del(payload["seriesOverrides"])
            new_df = data3_implementation(payload)
            if index is None:
                index = new_df.index
            if independent_x_axis:
                if df is None:
                    df = new_df
                else:
                    df = pd.concat([df, new_df], axis=1)
            else:
                new_df.index = range(len(new_df))
                if df is None:
                    df = new_df
                else:
                    df = df.join(new_df)
        if not independent_x_axis and df is not None:
            df.index = index
        return df

    def get_sensor_id(self, facility_name, equipment_name, sensor_name):
        token = self.get_token()
        result = requests.post(self.url + "/point/",
                               json={"facility_name": facility_name, "equipment_name": equipment_name},
                               headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
                               )
        assert result.status_code == 200
        points = result.json()['result']
        for point in points:
            if point['sensor_name'] == sensor_name:
                return point['sensor_id']
        raise KeyError("Sensor not found")

    def write(self, sensor_id, value, priority, duration=None):
        token = self.get_token()
        result = requests.put(
            self.url + "/optimizationcontrol/",
            json={
                "sensor_id": sensor_id,
                "value": value,
                "priority": priority,
                "duration": duration
            },
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    def add_job(self, facility_name, job_type, meta):
        token = self.get_token()
        result = requests.put(
            self.url + "/job/",
            json={
                "facility_name": facility_name,
                "job_type": job_type,
                "job_status": "pending",
                "meta": meta,
            },
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    # ------------------------------------------------------------------ #
    #  Point Management (CRUD)                                            #
    # ------------------------------------------------------------------ #

    def get_points(self, facility_name, equipment_name=None):
        """
        Get the full list of points (sensors) for a facility, optionally
        filtered by equipment.  Returns complete records including
        calculations_statement, unit, meta, connector_id, etc.

        :param facility_name: Name of the facility
        :param equipment_name: Optional equipment name filter
        :return: list of point dicts
        """
        token = self.get_token()
        payload = {"facility_name": facility_name}
        if equipment_name is not None:
            payload["equipment_name"] = equipment_name
        result = requests.post(
            self.url + "/point/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()["result"]

    def create_point(self, facility_name, equipment_name, sensor_name,
                     sensor_name_abbr=None, sensor_desc=None, unit=None,
                     haystack_id=None, calculations_statement=None,
                     weight=None, connector_id=None, faulty=False, meta=None):
        """
        Create a new point (sensor) under the given facility / equipment.

        :param facility_name: Facility the point belongs to
        :param equipment_name: Equipment the point belongs to
        :param sensor_name: Display name of the sensor
        :param sensor_name_abbr: Abbreviated sensor name
        :param sensor_desc: Description
        :param unit: Measurement unit (e.g. "degC", "kW")
        :param haystack_id: External system haystack ID
        :param calculations_statement: Calculation formula, e.g. "df[2]+df[3]"
        :param weight: Sensor weight (default 1)
        :param connector_id: ID of the connector providing data
        :param faulty: Mark sensor as faulty
        :param meta: JSON metadata dict (tags, writable_priority_array, etc.)
        :return: dict with 'result' and 'record'
        """
        token = self.get_token()
        payload = {
            "facility_name": facility_name,
            "equipment_name": equipment_name,
            "sensor_name": sensor_name,
            "faulty": faulty,
        }
        if sensor_name_abbr is not None:
            payload["sensor_name_abbr"] = sensor_name_abbr
        if sensor_desc is not None:
            payload["sensor_desc"] = sensor_desc
        if unit is not None:
            payload["unit"] = unit
        if haystack_id is not None:
            payload["haystack_id"] = haystack_id
        if calculations_statement is not None:
            payload["calculations_statement"] = calculations_statement
        if weight is not None:
            payload["weight"] = weight
        if connector_id is not None:
            payload["connector_id"] = connector_id
        if meta is not None:
            payload["meta"] = meta
        result = requests.put(
            self.url + "/point/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    def update_point(self, sensor_id, facility_name, equipment_name, **kwargs):
        """
        Update an existing point (sensor).

        Supported kwargs: sensor_name, sensor_name_abbr, sensor_desc, unit,
        haystack_id, calculations_statement, weight, connector_id, faulty, meta.

        :param sensor_id: The sensor_id to update
        :param facility_name: Facility the point belongs to
        :param equipment_name: Equipment the point belongs to
        :param kwargs: Fields to update
        :return: dict with 'result' and 'record'
        """
        token = self.get_token()
        payload = {
            "facility_name": facility_name,
            "equipment_name": equipment_name,
            "sensor_id": sensor_id,
        }
        allowed_fields = [
            'sensor_name', 'sensor_name_abbr', 'sensor_desc', 'unit',
            'haystack_id', 'calculations_statement', 'weight',
            'connector_id', 'faulty', 'meta'
        ]
        for key, value in kwargs.items():
            if key in allowed_fields:
                payload[key] = value
        result = requests.put(
            self.url + "/point/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    def delete_point(self, sensor_id):
        """
        Delete a point (sensor) and all its readings.

        :param sensor_id: The sensor_id to delete
        :return: dict with 'result'
        """
        token = self.get_token()
        result = requests.delete(
            self.url + "/point/",
            json={"sensor_id": sensor_id},
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    # ------------------------------------------------------------------ #
    #  Equipment Management (CRUD)                                        #
    # ------------------------------------------------------------------ #

    def get_equipment(self, facility_name):
        """
        List all equipment for a facility.

        :param facility_name: Name of the facility
        :return: list of equipment dicts
        """
        token = self.get_token()
        result = requests.post(
            self.url + "/equipment/",
            json={"facility_name": facility_name},
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()["result"]

    def create_equipment(self, facility_name, equipment_name,
                         equipment_type=None, equipment_tags=None,
                         floor=None, notes=None, meta=None, graphic_id=None):
        """
        Create a new equipment under the given facility.

        :param facility_name: Facility name
        :param equipment_name: Equipment name
        :param equipment_type: Type of equipment (e.g. "chiller", "ahu")
        :param equipment_tags: Tags list
        :param floor: Floor identifier
        :param notes: Free-text notes
        :param meta: JSON metadata dict
        :param graphic_id: Associated graphic ID
        :return: dict with 'result' and 'record'
        """
        token = self.get_token()
        payload = {
            "facility_name": facility_name,
            "equipment_name": equipment_name,
        }
        if equipment_type is not None:
            payload["equipment_type"] = equipment_type
        if equipment_tags is not None:
            payload["equipment_tags"] = equipment_tags
        if floor is not None:
            payload["floor"] = floor
        if notes is not None:
            payload["notes"] = notes
        if meta is not None:
            payload["meta"] = meta
        if graphic_id is not None:
            payload["graphic_id"] = graphic_id
        result = requests.put(
            self.url + "/equipment/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    def update_equipment(self, equipment_id, facility_name, equipment_name, **kwargs):
        """
        Update an existing equipment.

        Supported kwargs: equipment_type, equipment_tags, floor, notes, meta,
        graphic_id.

        :param equipment_id: The equipment_id to update
        :param facility_name: Facility the equipment belongs to
        :param equipment_name: New or existing equipment name
        :param kwargs: Fields to update
        :return: dict with 'result' and 'record'
        """
        token = self.get_token()
        payload = {
            "facility_name": facility_name,
            "equipment_name": equipment_name,
            "equipment_id": equipment_id,
        }
        allowed_fields = [
            'equipment_type', 'equipment_tags', 'floor', 'notes', 'meta',
            'graphic_id'
        ]
        for key, value in kwargs.items():
            if key in allowed_fields:
                payload[key] = value
        result = requests.put(
            self.url + "/equipment/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    def delete_equipment(self, equipment_id):
        """
        Delete an equipment and all its associated sensors/readings.

        :param equipment_id: The equipment_id to delete
        :return: dict with 'result'
        """
        token = self.get_token()
        result = requests.delete(
            self.url + "/equipment/",
            json={"equipment_id": equipment_id},
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    # ------------------------------------------------------------------ #
    #  Connector Management (CRUD)                                        #
    # ------------------------------------------------------------------ #

    def get_connectors(self, facility_name):
        """
        List all connectors for a facility.
        Note: config values are masked as '*****' by the backend for security.

        :param facility_name: Name of the facility
        :return: list of connector dicts
        """
        token = self.get_token()
        result = requests.post(
            self.url + "/connector/",
            json={"facility_name": facility_name},
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()["result"]

    def create_connector(self, facility_name, name, connector_type=None, config=None):
        """
        Create a new connector for third-party data integration.

        :param facility_name: Facility name
        :param name: Connector display name
        :param connector_type: Type (e.g. "bacnet", "modbus", "mqtt", "http")
        :param config: JSON dict with connection credentials/settings
        :return: dict with 'result' and 'record'
        """
        token = self.get_token()
        payload = {
            "facility_name": facility_name,
            "name": name,
        }
        if connector_type is not None:
            payload["type"] = connector_type
        if config is not None:
            payload["config"] = config
        result = requests.put(
            self.url + "/connector/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    def update_connector(self, connector_id, facility_name, name, connector_type=None, config=None):
        """
        Update an existing connector.
        Pass config values as '*****' to preserve existing secret values.

        :param connector_id: The connector_id to update
        :param facility_name: Facility name
        :param name: Connector name
        :param connector_type: Connector type
        :param config: JSON config dict
        :return: dict with 'result' and 'record'
        """
        token = self.get_token()
        payload = {
            "facility_name": facility_name,
            "connector_id": connector_id,
            "name": name,
        }
        if connector_type is not None:
            payload["type"] = connector_type
        if config is not None:
            payload["config"] = config
        result = requests.put(
            self.url + "/connector/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    def delete_connector(self, connector_id):
        """
        Delete a connector.

        :param connector_id: The connector_id to delete
        :return: dict with 'result'
        """
        token = self.get_token()
        result = requests.delete(
            self.url + "/connector/",
            json={"connector_id": connector_id},
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    # ------------------------------------------------------------------ #
    #  Equipment Relationship Management (CRUD)                           #
    # ------------------------------------------------------------------ #

    def get_equipment_relationships(self, facility_name):
        """
        List all equipment relationships for a facility.
        Relationships define parent-child or served-by links between equipment
        (e.g. chiller serves AHU, pump belongs to chiller plant).

        :param facility_name: Name of the facility
        :return: list of equipment relationship dicts
        """
        token = self.get_token()
        result = requests.post(
            self.url + "/equipment-relationship/",
            json={"facility_name": facility_name},
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()["result"]

    def create_equipment_relationship(self, facility_name, equip1, equip2, relationship_type):
        """
        Create a relationship between two equipment items.

        :param facility_name: Facility name
        :param equip1: First equipment name (e.g. parent / server)
        :param equip2: Second equipment name (e.g. child / served)
        :param relationship_type: Relationship type string (e.g. "serves", "contains")
        :return: dict with 'result' and 'record'
        """
        token = self.get_token()
        payload = {
            "facility_name": facility_name,
            "equip1": equip1,
            "equip2": equip2,
            "type": relationship_type,
        }
        result = requests.put(
            self.url + "/equipment-relationship/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    def update_equipment_relationship(self, equipment_relationship_id, facility_name,
                                      equip1, equip2, relationship_type):
        """
        Update an existing equipment relationship.

        :param equipment_relationship_id: The relationship ID to update
        :param facility_name: Facility name
        :param equip1: First equipment name
        :param equip2: Second equipment name
        :param relationship_type: Relationship type string
        :return: dict with 'result' and 'record'
        """
        token = self.get_token()
        payload = {
            "facility_name": facility_name,
            "equipment_relationship_id": equipment_relationship_id,
            "equip1": equip1,
            "equip2": equip2,
            "type": relationship_type,
        }
        result = requests.put(
            self.url + "/equipment-relationship/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    def delete_equipment_relationship(self, equipment_relationship_id):
        """
        Delete an equipment relationship.

        :param equipment_relationship_id: The relationship ID to delete
        :return: dict with 'result'
        """
        token = self.get_token()
        result = requests.delete(
            self.url + "/equipment-relationship/",
            json={"equipment_relationship_id": equipment_relationship_id},
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

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

    def upload_series(self, sensor_id, timestamps, values):
        """
        Upload time-series data for a single sensor.

        :param sensor_id: The sensor_id to upload data for
        :param timestamps: List of timestamp strings (ISO format)
        :param values: List of numeric values
        :return: server response dict
        """
        token = self.get_token()
        result = requests.put(
            self.url + "/series-upload/",
            json={
                "sensor_id": sensor_id,
                "timestamps": timestamps,
                "values": values,
            },
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    def get_latest_readings(self, facility_name, equipment_name=None):
        """
        Get the latest readings for sensors in a facility.

        :param facility_name: Facility name
        :param equipment_name: Optional equipment filter
        :return: list of latest reading dicts
        """
        token = self.get_token()
        payload = {"facility_name": facility_name}
        if equipment_name is not None:
            payload["equipment_name"] = equipment_name
        result = requests.post(
            self.url + "/latest-readings/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()["result"]

    # ------------------------------------------------------------------ #
    #  Custom View Management (CRUD)                                      #
    # ------------------------------------------------------------------ #

    def get_custom_views(self):
        """
        Get all custom views accessible to the current user.
        Admins see all views; other roles see only their own.

        :return: list of custom view dicts
        """
        token = self.get_token()
        result = requests.post(
            self.url + "/custom-view/",
            json={},
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()["views"]

    def update_custom_view(self, view_id, view_json, published=False):
        """
        Create or update a custom view definition.

        :param view_id: Unique identifier for the view
        :param view_json: JSON dict containing the view definition
        :param published: Whether the view is published (default False)
        :return: dict with 'result'
        """
        token = self.get_token()
        payload = {
            "view_id": view_id,
            "json": view_json,
            "published": published,
        }
        result = requests.put(
            self.url + "/custom-view/",
            json=payload,
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()

    def delete_custom_view(self, view_id):
        """
        Delete a custom view.

        :param view_id: The view_id to delete
        :return: dict with 'result'
        """
        token = self.get_token()
        result = requests.delete(
            self.url + "/custom-view/",
            json={"view_id": view_id},
            headers={"Authorization": "Bearer " + token, 'Content-Type': 'application/json'}
        )
        assert result.status_code == 200
        return result.json()
