import os

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")

import time

import tests.conftest  # noqa: F401

import django

django.setup = tests.conftest._original_setup
django.setup()

from asgiref.testing import ApplicationCommunicator
from channels.testing import WebsocketCommunicator
from channels.db import database_sync_to_async
from asgiref.sync import async_to_sync
from django.test import (
    Client,
    RequestFactory,
    TransactionTestCase,
    TestCase,
    override_settings,
)
from unittest import skip
from contextlib import suppress
from types import SimpleNamespace
from unittest.mock import patch, Mock, AsyncMock
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from django.utils.translation import override, gettext as _
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError
from pages.models import Application, Module
from nodes.models import Node, NodeRole

from config.asgi import application

from .models import (
    Transaction,
    Charger,
    Simulator,
    MeterReading,
    Location,
    DataTransferMessage,
)
from .consumers import CSMSConsumer
from .views import dispatch_action
from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
from . import store
from django.db.models.deletion import ProtectedError
from decimal import Decimal
import json
import websockets
import asyncio
from pathlib import Path
from .simulator import SimulatorConfig, ChargePointSimulator
import re
from datetime import datetime, timedelta
from .tasks import purge_meter_readings
from django.db import close_old_connections
from django.db.utils import OperationalError
from urllib.parse import unquote, urlparse


class ClientWebsocketCommunicator(WebsocketCommunicator):
    """WebsocketCommunicator that injects a client address into the scope."""

    def __init__(
        self,
        application,
        path,
        *,
        client=None,
        headers=None,
        subprotocols=None,
        spec_version=None,
    ):
        if not isinstance(path, str):
            raise TypeError(f"Expected str, got {type(path)}")
        parsed = urlparse(path)
        scope = {
            "type": "websocket",
            "path": unquote(parsed.path),
            "query_string": parsed.query.encode("utf-8"),
            "headers": headers or [],
            "subprotocols": subprotocols or [],
        }
        if client is not None:
            scope["client"] = client
        if spec_version:
            scope["spec_version"] = spec_version
        self.scope = scope
        ApplicationCommunicator.__init__(self, application, self.scope)
        self.response_headers = None


class DummyWebSocket:
    """Simple websocket stub that records payloads sent by the view."""

    def __init__(self):
        self.sent: list[str] = []

    async def send(self, message):
        self.sent.append(message)


class DispatchActionTests(TestCase):
    def setUp(self):
        self.factory = RequestFactory()

    def tearDown(self):  # pragma: no cover - cleanup guard
        store.pending_calls.clear()
        store.triggered_followups.clear()

    def test_trigger_message_registers_pending_call(self):
        charger = Charger.objects.create(charger_id="TRIGGER1")
        dummy = DummyWebSocket()
        key = store.set_connection("TRIGGER1", None, dummy)
        self.addCleanup(lambda: store.connections.pop(key, None))
        log_key = store.identity_key("TRIGGER1", None)
        store.clear_log(log_key, log_type="charger")
        self.addCleanup(lambda: store.clear_log(log_key, log_type="charger"))

        request = self.factory.post(
            "/chargers/TRIGGER1/action/",
            data=json.dumps({"action": "trigger_message", "target": "BootNotification"}),
            content_type="application/json",
        )
        request.user = SimpleNamespace(
            is_authenticated=True,
            is_superuser=True,
            is_staff=True,
        )

        response = dispatch_action(request, "TRIGGER1")
        self.assertEqual(response.status_code, 200)
        self.assertTrue(dummy.sent)
        frame = json.loads(dummy.sent[-1])
        self.assertEqual(frame[0], 2)
        self.assertEqual(frame[2], "TriggerMessage")
        message_id = frame[1]
        self.assertIn(message_id, store.pending_calls)
        metadata = store.pending_calls[message_id]
        self.assertEqual(metadata.get("action"), "TriggerMessage")
        self.assertEqual(metadata.get("trigger_target"), "BootNotification")
        self.assertEqual(metadata.get("log_key"), log_key)

class ChargerFixtureTests(TestCase):
    fixtures = [
        p.name
        for p in (Path(__file__).resolve().parent / "fixtures").glob(
            "initial_data__*.json"
        )
    ]

    @classmethod
    def setUpTestData(cls):
        location = Location.objects.create(name="Simulator")
        Charger.objects.create(
            charger_id="CP1",
            connector_id=1,
            location=location,
            require_rfid=False,
        )
        Charger.objects.create(
            charger_id="CP2",
            connector_id=2,
            location=location,
            require_rfid=True,
        )

    def test_cp2_requires_rfid(self):
        cp2 = Charger.objects.get(charger_id="CP2")
        self.assertTrue(cp2.require_rfid)

    def test_cp1_does_not_require_rfid(self):
        cp1 = Charger.objects.get(charger_id="CP1")
        self.assertFalse(cp1.require_rfid)

    def test_charger_connector_ids(self):
        cp1 = Charger.objects.get(charger_id="CP1")
        cp2 = Charger.objects.get(charger_id="CP2")
        self.assertEqual(cp1.connector_id, 1)
        self.assertEqual(cp2.connector_id, 2)
        self.assertEqual(cp1.name, "Simulator #1")
        self.assertEqual(cp2.name, "Simulator #2")


class ChargerUrlFallbackTests(TestCase):
    @override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
    def test_reference_created_when_site_missing(self):
        Site.objects.all().delete()
        Site.objects.clear_cache()

        charger = Charger.objects.create(charger_id="NO_SITE")
        charger.refresh_from_db()

        self.assertIsNotNone(charger.reference)
        self.assertTrue(charger.reference.value.startswith("http://fallback.example"))
        self.assertTrue(charger.reference.value.endswith("/c/NO_SITE/"))

    def test_reference_not_created_for_loopback_domain(self):
        site = Site.objects.get_current()
        site.domain = "127.0.0.1"
        site.save()
        Site.objects.clear_cache()

        charger = Charger.objects.create(charger_id="LOCAL_LOOP")
        charger.refresh_from_db()

        self.assertIsNone(charger.reference)


class SinkConsumerTests(TransactionTestCase):
    async def test_sink_replies(self):
        communicator = WebsocketCommunicator(application, "/ws/sink/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to([2, "1", "Foo", {}])
        response = await communicator.receive_json_from()
        self.assertEqual(response, [3, "1", {}])

        await communicator.disconnect()


class CSMSConsumerTests(TransactionTestCase):
    async def _retry_db(self, func, attempts: int = 5, delay: float = 0.1):
        """Run a database function, retrying if the database is locked."""
        for _ in range(attempts):
            try:
                return await database_sync_to_async(func)()
            except OperationalError:
                await database_sync_to_async(close_old_connections)()
                await asyncio.sleep(delay)
        raise

    async def _send_status_notification(self, serial: str, payload: dict):
        communicator = WebsocketCommunicator(application, f"/{serial}/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to([2, "1", "StatusNotification", payload])
        response = await communicator.receive_json_from()
        self.assertEqual(response, [3, "1", {}])

        await communicator.disconnect()

    async def test_transaction_saved(self):
        communicator = WebsocketCommunicator(application, "/TEST/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [
                2,
                "1",
                "StartTransaction",
                {"meterStart": 10, "connectorId": 3},
            ]
        )
        response = await communicator.receive_json_from()
        tx_id = response[2]["transactionId"]

        tx = await database_sync_to_async(Transaction.objects.get)(
            pk=tx_id, charger__charger_id="TEST"
        )
        self.assertEqual(tx.meter_start, 10)
        self.assertEqual(tx.connector_id, 3)
        self.assertIsNone(tx.stop_time)

        await communicator.send_json_to(
            [
                2,
                "2",
                "StopTransaction",
                {"transactionId": tx_id, "meterStop": 20},
            ]
        )
        await communicator.receive_json_from()

        await database_sync_to_async(tx.refresh_from_db)()
        self.assertEqual(tx.meter_stop, 20)
        self.assertIsNotNone(tx.stop_time)

        await communicator.disconnect()

    async def test_rfid_recorded(self):
        await database_sync_to_async(Charger.objects.create)(charger_id="RFIDREC")
        communicator = WebsocketCommunicator(application, "/RFIDREC/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [2, "1", "StartTransaction", {"meterStart": 1, "idTag": "TAG123"}]
        )
        response = await communicator.receive_json_from()
        tx_id = response[2]["transactionId"]

        tx = await database_sync_to_async(Transaction.objects.get)(
            pk=tx_id, charger__charger_id="RFIDREC"
        )
        self.assertEqual(tx.rfid, "TAG123")

        await communicator.disconnect()

    async def test_start_transaction_sends_net_message(self):
        location = await database_sync_to_async(Location.objects.create)(
            name="Test Location"
        )
        await database_sync_to_async(Charger.objects.create)(
            charger_id="NETMSG", location=location
        )
        communicator = WebsocketCommunicator(application, "/NETMSG/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        with patch("nodes.models.NetMessage.broadcast") as mock_broadcast:
            await communicator.send_json_to(
                [
                    2,
                    "1",
                    "StartTransaction",
                    {"meterStart": 1, "connectorId": 1},
                ]
            )
            await communicator.receive_json_from()

        await communicator.disconnect()

        mock_broadcast.assert_called_once()
        _, kwargs = mock_broadcast.call_args
        self.assertEqual(kwargs["subject"], "NETMSG")
        body = kwargs["body"]
        self.assertRegex(body, r"^\d+\.\d kWh \d{2}:\d{2}$")

    async def test_consumption_message_updates_existing_entry(self):
        original_interval = CSMSConsumer.consumption_update_interval
        CSMSConsumer.consumption_update_interval = 0.01
        await database_sync_to_async(Charger.objects.create)(charger_id="UPDATEMSG")
        communicator = WebsocketCommunicator(application, "/UPDATEMSG/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        message_mock = Mock()
        message_mock.uuid = "mock-uuid"
        message_mock.save = Mock()
        message_mock.propagate = Mock()

        filter_mock = Mock()
        filter_mock.first.return_value = message_mock

        broadcast_result = SimpleNamespace(uuid="mock-uuid")

        try:
            with patch(
                "nodes.models.NetMessage.broadcast", return_value=broadcast_result
            ) as mock_broadcast, patch(
                "nodes.models.NetMessage.objects.filter", return_value=filter_mock
            ):
                await communicator.send_json_to(
                    [2, "1", "StartTransaction", {"meterStart": 1}]
                )
                await communicator.receive_json_from()
                mock_broadcast.assert_called_once()
                await asyncio.sleep(0.05)
                await communicator.disconnect()
        finally:
            CSMSConsumer.consumption_update_interval = original_interval
            with suppress(Exception):
                await communicator.disconnect()

        self.assertTrue(message_mock.save.called)
        self.assertTrue(message_mock.propagate.called)

    async def test_change_availability_result_updates_model(self):
        store.pending_calls.clear()
        communicator = WebsocketCommunicator(application, "/AVAILRES/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [
                2,
                "boot",
                "BootNotification",
                {"chargePointVendor": "Test", "chargePointModel": "Model"},
            ]
        )
        await communicator.receive_json_from()

        message_id = "ca-result"
        requested_at = timezone.now()
        store.register_pending_call(
            message_id,
            {
                "action": "ChangeAvailability",
                "charger_id": "AVAILRES",
                "connector_id": None,
                "availability_type": "Inoperative",
                "requested_at": requested_at,
            },
        )
        await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
        await asyncio.sleep(0.05)

        charger = await database_sync_to_async(Charger.objects.get)(
            charger_id="AVAILRES", connector_id=None
        )
        self.assertEqual(charger.availability_state, "Inoperative")
        self.assertEqual(charger.availability_request_status, "Accepted")
        self.assertEqual(charger.availability_requested_state, "Inoperative")
        await communicator.disconnect()

    async def test_get_configuration_result_logged(self):
        store.pending_calls.clear()
        pending_key = store.pending_key("CFGRES")
        store.clear_log(pending_key, log_type="charger")
        log_key = store.identity_key("CFGRES", None)
        store.clear_log(log_key, log_type="charger")
        communicator = WebsocketCommunicator(application, "/CFGRES/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        message_id = "cfg-result"
        payload = {
            "configurationKey": [
                {
                    "key": "AllowOfflineTxForUnknownId",
                    "readonly": True,
                    "value": "false",
                }
            ]
        }
        store.register_pending_call(
            message_id,
            {
                "action": "GetConfiguration",
                "charger_id": "CFGRES",
                "connector_id": None,
                "log_key": log_key,
                "requested_at": timezone.now(),
            },
        )

        await communicator.send_json_to([3, message_id, payload])
        await asyncio.sleep(0.05)

        log_entries = store.get_logs(log_key, log_type="charger")
        self.assertTrue(
            any("GetConfiguration result" in entry for entry in log_entries)
        )
        self.assertNotIn(message_id, store.pending_calls)

        await communicator.disconnect()
        store.clear_log(log_key, log_type="charger")
        store.clear_log(pending_key, log_type="charger")

    async def test_get_configuration_error_logged(self):
        store.pending_calls.clear()
        pending_key = store.pending_key("CFGERR")
        store.clear_log(pending_key, log_type="charger")
        log_key = store.identity_key("CFGERR", None)
        store.clear_log(log_key, log_type="charger")
        communicator = WebsocketCommunicator(application, "/CFGERR/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        message_id = "cfg-error"
        store.register_pending_call(
            message_id,
            {
                "action": "GetConfiguration",
                "charger_id": "CFGERR",
                "connector_id": None,
                "log_key": log_key,
                "requested_at": timezone.now(),
            },
        )

        await communicator.send_json_to(
            [4, message_id, "InternalError", "Boom", {"detail": "nope"}]
        )
        await asyncio.sleep(0.05)

        log_entries = store.get_logs(log_key, log_type="charger")
        self.assertTrue(
            any("GetConfiguration error" in entry for entry in log_entries)
        )
        self.assertNotIn(message_id, store.pending_calls)

        await communicator.disconnect()
        store.clear_log(log_key, log_type="charger")
        store.clear_log(pending_key, log_type="charger")

    async def test_trigger_message_follow_up_logged(self):
        store.pending_calls.clear()
        cid = "TRIGLOG"
        pending_key = store.pending_key(cid)
        log_key = store.identity_key(cid, None)
        store.clear_log(pending_key, log_type="charger")
        store.clear_log(log_key, log_type="charger")

        communicator = WebsocketCommunicator(application, f"/{cid}/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [
                2,
                "boot",
                "BootNotification",
                {"chargePointVendor": "Test", "chargePointModel": "Model"},
            ]
        )
        await communicator.receive_json_from()

        message_id = "trigger-result"
        store.register_pending_call(
            message_id,
            {
                "action": "TriggerMessage",
                "charger_id": cid,
                "connector_id": None,
                "log_key": log_key,
                "trigger_target": "BootNotification",
                "trigger_connector": None,
            },
        )

        await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
        await asyncio.sleep(0.05)
        self.assertNotIn(message_id, store.pending_calls)

        log_entries = store.get_logs(log_key, log_type="charger")
        self.assertTrue(
            any(
                "TriggerMessage BootNotification result" in entry
                or "TriggerMessage result" in entry
                for entry in log_entries
            )
        )

        await communicator.send_json_to(
            [
                2,
                "trigger-follow",
                "BootNotification",
                {"chargePointVendor": "Test", "chargePointModel": "Model"},
            ]
        )
        await communicator.receive_json_from()
        await asyncio.sleep(0.05)

        log_entries = store.get_logs(log_key, log_type="charger")
        self.assertTrue(
            any(
                "TriggerMessage follow-up received: BootNotification" in entry
                for entry in log_entries
            )
        )

        await communicator.disconnect()
        store.clear_log(log_key, log_type="charger")
        store.clear_log(pending_key, log_type="charger")

    async def test_status_notification_updates_availability_state(self):
        store.pending_calls.clear()
        communicator = WebsocketCommunicator(application, "/STATAVAIL/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [
                2,
                "boot",
                "BootNotification",
                {"chargePointVendor": "Test", "chargePointModel": "Model"},
            ]
        )
        await communicator.receive_json_from()

        await communicator.send_json_to(
            [
                2,
                "stat1",
                "StatusNotification",
                {"connectorId": 1, "errorCode": "NoError", "status": "Unavailable"},
            ]
        )
        await communicator.receive_json_from()

        charger = await database_sync_to_async(Charger.objects.get)(
            charger_id="STATAVAIL", connector_id=1
        )
        self.assertEqual(charger.availability_state, "Inoperative")

        await communicator.send_json_to(
            [
                2,
                "stat2",
                "StatusNotification",
                {"connectorId": 1, "errorCode": "NoError", "status": "Available"},
            ]
        )
        await communicator.receive_json_from()

        charger = await database_sync_to_async(Charger.objects.get)(
            charger_id="STATAVAIL", connector_id=1
        )
        self.assertEqual(charger.availability_state, "Operative")
        await communicator.disconnect()

    async def test_consumption_message_final_update_on_disconnect(self):
        await database_sync_to_async(Charger.objects.create)(charger_id="FINALMSG")
        communicator = WebsocketCommunicator(application, "/FINALMSG/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        message_mock = Mock()
        message_mock.uuid = "mock-uuid"
        message_mock.save = Mock()
        message_mock.propagate = Mock()

        filter_mock = Mock()
        filter_mock.first.return_value = message_mock

        broadcast_result = SimpleNamespace(uuid="mock-uuid")

        try:
            with patch(
                "nodes.models.NetMessage.broadcast", return_value=broadcast_result
            ) as mock_broadcast, patch(
                "nodes.models.NetMessage.objects.filter", return_value=filter_mock
            ):
                await communicator.send_json_to(
                    [2, "1", "StartTransaction", {"meterStart": 1}]
                )
                await communicator.receive_json_from()
                mock_broadcast.assert_called_once()
                await communicator.disconnect()
        finally:
            with suppress(Exception):
                await communicator.disconnect()

        self.assertTrue(message_mock.save.called)
        self.assertTrue(message_mock.propagate.called)

    async def test_rfid_unbound_instance_created(self):
        await database_sync_to_async(Charger.objects.create)(charger_id="NEWRFID")
        communicator = WebsocketCommunicator(application, "/NEWRFID/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [2, "1", "StartTransaction", {"meterStart": 1, "idTag": "TAG456"}]
        )
        await communicator.receive_json_from()

        tag = await database_sync_to_async(RFID.objects.get)(rfid="TAG456")
        count = await database_sync_to_async(tag.energy_accounts.count)()
        self.assertEqual(count, 0)

        await communicator.disconnect()

    async def test_firmware_status_notification_updates_database_and_views(self):
        communicator = WebsocketCommunicator(application, "/FWSTAT/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        ts = timezone.now().replace(microsecond=0)
        payload = {
            "status": "Installing",
            "statusInfo": "Applying patch",
            "timestamp": ts.isoformat(),
        }

        await communicator.send_json_to(
            [2, "1", "FirmwareStatusNotification", payload]
        )
        response = await communicator.receive_json_from()
        self.assertEqual(response, [3, "1", {}])

        def _fetch_status():
            charger = Charger.objects.get(charger_id="FWSTAT", connector_id=None)
            return (
                charger.firmware_status,
                charger.firmware_status_info,
                charger.firmware_timestamp,
            )

        status, info, recorded_ts = await database_sync_to_async(_fetch_status)()
        self.assertEqual(status, "Installing")
        self.assertEqual(info, "Applying patch")
        self.assertIsNotNone(recorded_ts)
        self.assertEqual(recorded_ts.replace(microsecond=0), ts)

        log_entries = store.get_logs(store.identity_key("FWSTAT", None), log_type="charger")
        self.assertTrue(
            any("FirmwareStatusNotification" in entry for entry in log_entries)
        )

        def _fetch_views():
            User = get_user_model()
            user = User.objects.create_user(username="fwstatus", password="pw")
            client = Client()
            client.force_login(user)
            detail = client.get(reverse("charger-detail", args=["FWSTAT"]))
            status_page = client.get(reverse("charger-status", args=["FWSTAT"]))
            list_response = client.get(reverse("charger-list"))
            return (
                detail.status_code,
                json.loads(detail.content.decode()),
                status_page.status_code,
                status_page.content.decode(),
                list_response.status_code,
                json.loads(list_response.content.decode()),
            )

        (
            detail_code,
            detail_payload,
            status_code,
            html,
            list_code,
            list_payload,
        ) = await database_sync_to_async(_fetch_views)()
        self.assertEqual(detail_code, 200)
        self.assertEqual(status_code, 200)
        self.assertEqual(list_code, 200)
        self.assertEqual(detail_payload["firmwareStatus"], "Installing")
        self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
        self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
        self.assertIn('id="firmware-status">Installing<', html)
        self.assertIn('id="firmware-status-info">Applying patch<', html)
        match = re.search(
            r'id="firmware-timestamp"[^>]*data-iso="([^"]+)"', html
        )
        self.assertIsNotNone(match)
        parsed_iso = datetime.fromisoformat(match.group(1))
        self.assertAlmostEqual(parsed_iso.timestamp(), ts.timestamp(), places=3)

        matching = [
            item
            for item in list_payload.get("chargers", [])
            if item["charger_id"] == "FWSTAT" and item["connector_id"] is None
        ]
        self.assertTrue(matching)
        self.assertEqual(matching[0]["firmwareStatus"], "Installing")
        self.assertEqual(matching[0]["firmwareStatusInfo"], "Applying patch")
        list_ts = datetime.fromisoformat(matching[0]["firmwareTimestamp"])
        self.assertAlmostEqual(list_ts.timestamp(), ts.timestamp(), places=3)

        store.clear_log(store.identity_key("FWSTAT", None), log_type="charger")

        await communicator.disconnect()

    async def test_firmware_status_notification_updates_connector_and_aggregate(
        self,
    ):
        communicator = WebsocketCommunicator(application, "/FWCONN/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [
                2,
                "1",
                "FirmwareStatusNotification",
                {"connectorId": 2, "status": "Downloaded"},
            ]
        )
        response = await communicator.receive_json_from()
        self.assertEqual(response, [3, "1", {}])

        def _fetch_chargers():
            aggregate = Charger.objects.get(charger_id="FWCONN", connector_id=None)
            connector = Charger.objects.get(charger_id="FWCONN", connector_id=2)
            return (
                aggregate.firmware_status,
                aggregate.firmware_status_info,
                aggregate.firmware_timestamp,
                connector.firmware_status,
                connector.firmware_status_info,
                connector.firmware_timestamp,
            )

        (
            aggregate_status,
            aggregate_info,
            aggregate_ts,
            connector_status,
            connector_info,
            connector_ts,
        ) = await database_sync_to_async(_fetch_chargers)()

        self.assertEqual(aggregate_status, "Downloaded")
        self.assertEqual(connector_status, "Downloaded")
        self.assertEqual(aggregate_info, "")
        self.assertEqual(connector_info, "")
        self.assertIsNotNone(aggregate_ts)
        self.assertIsNotNone(connector_ts)
        self.assertAlmostEqual(
            (connector_ts - aggregate_ts).total_seconds(), 0, delta=1.0
        )

        log_entries = store.get_logs(
            store.identity_key("FWCONN", 2), log_type="charger"
        )
        self.assertTrue(
            any("FirmwareStatusNotification" in entry for entry in log_entries)
        )
        log_entries_agg = store.get_logs(
            store.identity_key("FWCONN", None), log_type="charger"
        )
        self.assertTrue(
            any("FirmwareStatusNotification" in entry for entry in log_entries_agg)
        )

        store.clear_log(store.identity_key("FWCONN", 2), log_type="charger")
        store.clear_log(store.identity_key("FWCONN", None), log_type="charger")

        await communicator.disconnect()

    async def test_vin_recorded(self):
        await database_sync_to_async(Charger.objects.create)(charger_id="VINREC")
        communicator = WebsocketCommunicator(application, "/VINREC/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [2, "1", "StartTransaction", {"meterStart": 1, "vin": "WP0ZZZ11111111111"}]
        )
        response = await communicator.receive_json_from()
        tx_id = response[2]["transactionId"]

        tx = await database_sync_to_async(Transaction.objects.get)(
            pk=tx_id, charger__charger_id="VINREC"
        )
        self.assertEqual(tx.vin, "WP0ZZZ11111111111")

        await communicator.disconnect()

    async def test_connector_id_set_from_meter_values(self):
        communicator = WebsocketCommunicator(application, "/NEWCID/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        payload = {
            "connectorId": 7,
            "meterValue": [
                {
                    "timestamp": timezone.now().isoformat(),
                    "sampledValue": [{"value": "1"}],
                }
            ],
        }
        await communicator.send_json_to([2, "1", "MeterValues", payload])
        await communicator.receive_json_from()

        charger = await database_sync_to_async(Charger.objects.get)(
            charger_id="NEWCID", connector_id=7
        )
        self.assertEqual(charger.connector_id, 7)

        await communicator.disconnect()

    async def test_new_charger_created_for_different_connector(self):
        communicator = WebsocketCommunicator(application, "/DUPC/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        payload1 = {
            "connectorId": 1,
            "meterValue": [
                {
                    "timestamp": timezone.now().isoformat(),
                    "sampledValue": [{"value": "1"}],
                }
            ],
        }
        await communicator.send_json_to([2, "1", "MeterValues", payload1])
        await communicator.receive_json_from()
        await communicator.disconnect()
        await communicator.wait()
        await database_sync_to_async(close_old_connections)()

        communicator = WebsocketCommunicator(application, "/DUPC/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)
        payload2 = {
            "connectorId": 2,
            "meterValue": [
                {
                    "timestamp": timezone.now().isoformat(),
                    "sampledValue": [{"value": "1"}],
                }
            ],
        }
        await communicator.send_json_to([2, "1", "MeterValues", payload2])
        await communicator.receive_json_from()
        await communicator.disconnect()
        await communicator.wait()
        await database_sync_to_async(close_old_connections)()

        count = await self._retry_db(
            lambda: Charger.objects.filter(charger_id="DUPC").count()
        )
        self.assertEqual(count, 3)
        connectors = await self._retry_db(
            lambda: list(
                Charger.objects.filter(charger_id="DUPC").values_list(
                    "connector_id", flat=True
                )
            )
        )
        self.assertIn(1, connectors)
        self.assertIn(2, connectors)
        self.assertIn(None, connectors)

    async def test_console_reference_created_for_aggregate_connector(self):
        communicator = ClientWebsocketCommunicator(
            application,
            "/CONREF/",
            client=("203.0.113.5", 12345),
        )
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to([2, "1", "BootNotification", {}])
        await communicator.receive_json_from()

        reference = await database_sync_to_async(
            lambda: Reference.objects.get(alt_text="CONREF Console")
        )()
        self.assertEqual(reference.value, "http://203.0.113.5:8900")
        self.assertTrue(reference.show_in_header)

        await communicator.send_json_to(
            [
                2,
                "2",
                "StatusNotification",
                {"connectorId": 1, "status": "Available"},
            ]
        )
        await communicator.receive_json_from()

        count = await database_sync_to_async(
            lambda: Reference.objects.filter(alt_text="CONREF Console").count()
        )()
        self.assertEqual(count, 1)

        await communicator.disconnect()

    async def test_console_reference_uses_forwarded_for_header(self):
        communicator = ClientWebsocketCommunicator(
            application,
            "/FORWARDED/",
            client=("127.0.0.1", 23456),
            headers=[(b"x-forwarded-for", b"198.51.100.75, 127.0.0.1")],
        )
        connected, _ = await communicator.connect()
        self.assertTrue(connected)
        self.assertIn("198.51.100.75", store.ip_connections)

        await communicator.send_json_to([2, "1", "BootNotification", {}])
        await communicator.receive_json_from()

        reference = await database_sync_to_async(
            lambda: Reference.objects.get(alt_text="FORWARDED Console")
        )()
        self.assertEqual(reference.value, "http://198.51.100.75:8900")

        await communicator.disconnect()

    async def test_console_reference_skips_loopback_ip(self):
        communicator = ClientWebsocketCommunicator(
            application,
            "/LOCAL/",
            client=("127.0.0.1", 34567),
        )
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to([2, "1", "BootNotification", {}])
        await communicator.receive_json_from()

        exists = await database_sync_to_async(
            lambda: Reference.objects.filter(alt_text="LOCAL Console").exists()
        )()
        self.assertFalse(exists)

        await communicator.disconnect()

    async def test_transaction_created_from_meter_values(self):
        communicator = WebsocketCommunicator(application, "/NOSTART/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [
                2,
                "1",
                "MeterValues",
                {
                    "transactionId": 99,
                    "meterValue": [
                        {
                            "timestamp": "2025-01-01T00:00:00Z",
                            "sampledValue": [
                                {
                                    "value": "1000",
                                    "measurand": "Energy.Active.Import.Register",
                                    "unit": "W",
                                }
                            ],
                        }
                    ],
                },
            ]
        )
        await communicator.receive_json_from()

        tx = await database_sync_to_async(Transaction.objects.get)(
            pk=99, charger__charger_id="NOSTART"
        )
        self.assertEqual(tx.meter_start, 1000)
        self.assertIsNone(tx.meter_stop)

        await communicator.send_json_to(
            [
                2,
                "2",
                "StopTransaction",
                {"transactionId": 99, "meterStop": 1500},
            ]
        )
        await communicator.receive_json_from()
        await database_sync_to_async(tx.refresh_from_db)()
        self.assertEqual(tx.meter_stop, 1500)
        self.assertIsNotNone(tx.stop_time)

        await communicator.disconnect()

    async def test_diagnostics_status_notification_updates_records(self):
        communicator = WebsocketCommunicator(application, "/DIAGCP/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        reported_at = timezone.now().replace(microsecond=0)
        payload = {
            "status": "Uploaded",
            "connectorId": 5,
            "uploadLocation": "https://example.com/diag.tar",
            "timestamp": reported_at.isoformat(),
        }

        await communicator.send_json_to(
            [2, "1", "DiagnosticsStatusNotification", payload]
        )
        response = await communicator.receive_json_from()
        self.assertEqual(response[0], 3)
        self.assertEqual(response[2], {})

        def _fetch():
            aggregate = Charger.objects.get(charger_id="DIAGCP", connector_id=None)
            connector = Charger.objects.get(charger_id="DIAGCP", connector_id=5)
            return aggregate, connector

        aggregate, connector = await database_sync_to_async(_fetch)()
        self.assertEqual(aggregate.diagnostics_status, "Uploaded")
        self.assertEqual(connector.diagnostics_status, "Uploaded")
        self.assertEqual(
            aggregate.diagnostics_location, "https://example.com/diag.tar"
        )
        self.assertEqual(
            connector.diagnostics_location, "https://example.com/diag.tar"
        )
        self.assertEqual(aggregate.diagnostics_timestamp, reported_at)
        self.assertEqual(connector.diagnostics_timestamp, reported_at)

        connector_logs = store.get_logs(
            store.identity_key("DIAGCP", 5), log_type="charger"
        )
        aggregate_logs = store.get_logs(
            store.identity_key("DIAGCP", None), log_type="charger"
        )
        self.assertTrue(
            any("DiagnosticsStatusNotification" in entry for entry in connector_logs)
        )
        self.assertTrue(
            any("DiagnosticsStatusNotification" in entry for entry in aggregate_logs)
        )

        await communicator.disconnect()

    async def test_temperature_recorded(self):
        charger = await database_sync_to_async(Charger.objects.create)(
            charger_id="TEMP1"
        )
        communicator = WebsocketCommunicator(application, "/TEMP1/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [
                2,
                "1",
                "MeterValues",
                {
                    "meterValue": [
                        {
                            "timestamp": "2025-01-01T00:00:00Z",
                            "sampledValue": [
                                {
                                    "value": "42",
                                    "measurand": "Temperature",
                                    "unit": "Celsius",
                                }
                            ],
                        }
                    ]
                },
            ]
        )
        await communicator.receive_json_from()

        await database_sync_to_async(charger.refresh_from_db)()
        self.assertEqual(charger.temperature, Decimal("42"))
        self.assertEqual(charger.temperature_unit, "Celsius")

        await communicator.disconnect()

    def test_status_notification_updates_models_and_views(self):
        serial = "STATUS-CP"
        payload = {
            "connectorId": 1,
            "status": "Faulted",
            "errorCode": "GroundFailure",
            "info": "Relay malfunction",
            "vendorId": "ACME",
            "timestamp": "2024-01-01T12:34:56Z",
        }

        async_to_sync(self._send_status_notification)(serial, payload)

        expected_ts = parse_datetime(payload["timestamp"])
        aggregate = Charger.objects.get(charger_id=serial, connector_id=None)
        connector = Charger.objects.get(charger_id=serial, connector_id=1)

        vendor_data = {"info": payload["info"], "vendorId": payload["vendorId"]}
        self.assertEqual(aggregate.last_status, payload["status"])
        self.assertEqual(aggregate.last_error_code, payload["errorCode"])
        self.assertEqual(aggregate.last_status_vendor_info, vendor_data)
        self.assertEqual(aggregate.last_status_timestamp, expected_ts)
        self.assertEqual(connector.last_status, payload["status"])
        self.assertEqual(connector.last_error_code, payload["errorCode"])
        self.assertEqual(connector.last_status_vendor_info, vendor_data)
        self.assertEqual(connector.last_status_timestamp, expected_ts)

        connector_log = store.get_logs(
            store.identity_key(serial, 1), log_type="charger"
        )
        self.assertTrue(
            any("StatusNotification processed" in entry for entry in connector_log)
        )

        user = get_user_model().objects.create_user(
            username="status", email="status@example.com", password="pwd"
        )
        self.client.force_login(user)

        list_response = self.client.get(reverse("charger-list"))
        self.assertEqual(list_response.status_code, 200)
        chargers = list_response.json()["chargers"]
        aggregate_entry = next(
            item
            for item in chargers
            if item["charger_id"] == serial and item["connector_id"] is None
        )
        connector_entry = next(
            item
            for item in chargers
            if item["charger_id"] == serial and item["connector_id"] == 1
        )
        expected_iso = expected_ts.isoformat()
        self.assertEqual(aggregate_entry["lastStatus"], payload["status"])
        self.assertEqual(aggregate_entry["lastErrorCode"], payload["errorCode"])
        self.assertEqual(aggregate_entry["lastStatusVendorInfo"], vendor_data)
        self.assertEqual(aggregate_entry["lastStatusTimestamp"], expected_iso)
        self.assertEqual(aggregate_entry["status"], "Faulted (GroundFailure)")
        self.assertEqual(aggregate_entry["statusColor"], "#dc3545")
        self.assertEqual(connector_entry["lastStatus"], payload["status"])
        self.assertEqual(connector_entry["lastErrorCode"], payload["errorCode"])
        self.assertEqual(connector_entry["lastStatusVendorInfo"], vendor_data)
        self.assertEqual(connector_entry["lastStatusTimestamp"], expected_iso)
        self.assertEqual(connector_entry["status"], "Faulted (GroundFailure)")
        self.assertEqual(connector_entry["statusColor"], "#dc3545")

        detail_response = self.client.get(
            reverse("charger-detail-connector", args=[serial, 1])
        )
        self.assertEqual(detail_response.status_code, 200)
        detail_payload = detail_response.json()
        self.assertEqual(detail_payload["lastStatus"], payload["status"])
        self.assertEqual(detail_payload["lastErrorCode"], payload["errorCode"])
        self.assertEqual(detail_payload["lastStatusVendorInfo"], vendor_data)
        self.assertEqual(detail_payload["lastStatusTimestamp"], expected_iso)
        self.assertEqual(detail_payload["status"], "Faulted (GroundFailure)")
        self.assertEqual(detail_payload["statusColor"], "#dc3545")

        status_resp = self.client.get(
            reverse("charger-status-connector", args=[serial, "1"])
        )
        self.assertContains(status_resp, "Faulted (GroundFailure)")
        self.assertContains(status_resp, "Error code: GroundFailure")
        self.assertContains(status_resp, "Vendor: ACME")
        self.assertContains(status_resp, "Info: Relay malfunction")
        self.assertContains(status_resp, "background-color: #dc3545")

        aggregate_status = self.client.get(reverse("charger-status", args=[serial]))
        self.assertContains(aggregate_status, "Reported status")
        self.assertContains(aggregate_status, "Info: Relay malfunction")

        page_resp = self.client.get(reverse("charger-page", args=[serial]))
        self.assertContains(page_resp, "Faulted (GroundFailure)")
        self.assertContains(page_resp, "Vendor")
        self.assertContains(page_resp, "Relay malfunction")
        self.assertContains(page_resp, "background-color: #dc3545")

        store.clear_log(store.identity_key(serial, 1), log_type="charger")
        store.clear_log(store.identity_key(serial, None), log_type="charger")

    async def test_message_logged_and_session_file_created(self):
        cid = "LOGTEST1"
        log_path = Path("logs") / f"charger.{cid}.log"
        if log_path.exists():
            log_path.unlink()
        session_dir = Path("logs") / "sessions" / cid
        if session_dir.exists():
            for f in session_dir.glob("*.json"):
                f.unlink()
        communicator = WebsocketCommunicator(application, f"/{cid}/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [
                2,
                "1",
                "StartTransaction",
                {"meterStart": 1},
            ]
        )
        response = await communicator.receive_json_from()
        tx_id = response[2]["transactionId"]

        await communicator.send_json_to(
            [
                2,
                "2",
                "StopTransaction",
                {"transactionId": tx_id, "meterStop": 2},
            ]
        )
        await communicator.receive_json_from()
        await communicator.disconnect()

        content = log_path.read_text()
        self.assertIn("StartTransaction", content)
        self.assertNotIn(">", content)

        files = list(session_dir.glob(f"*_{tx_id}.json"))
        self.assertEqual(len(files), 1)
        data = json.loads(files[0].read_text())
        self.assertTrue(any("StartTransaction" in m["message"] for m in data))

    async def test_binary_message_logged(self):
        cid = "BINARY1"
        log_path = Path("logs") / f"charger.{cid}.log"
        if log_path.exists():
            log_path.unlink()
        communicator = WebsocketCommunicator(application, f"/{cid}/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_to(bytes_data=b"\x01\x02\x03")
        await communicator.disconnect()

        content = log_path.read_text()
        self.assertIn("AQID", content)

    async def test_session_file_written_on_disconnect(self):
        cid = "LOGTEST2"
        log_path = Path("logs") / f"charger.{cid}.log"
        if log_path.exists():
            log_path.unlink()
        session_dir = Path("logs") / "sessions" / cid
        if session_dir.exists():
            for f in session_dir.glob("*.json"):
                f.unlink()
        communicator = WebsocketCommunicator(application, f"/{cid}/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [
                2,
                "1",
                "StartTransaction",
                {"meterStart": 5},
            ]
        )
        await communicator.receive_json_from()

        await communicator.disconnect()

        session_dir = Path("logs") / "sessions" / cid
        files = list(session_dir.glob("*.json"))
        self.assertEqual(len(files), 1)
        data = json.loads(files[0].read_text())
        self.assertTrue(any("StartTransaction" in m["message"] for m in data))

    async def test_second_connection_closes_first(self):
        communicator1 = WebsocketCommunicator(application, "/DUPLICATE/")
        connected, _ = await communicator1.connect()
        self.assertTrue(connected)
        pending_key = store.pending_key("DUPLICATE")
        first_consumer = store.connections.get(pending_key)

        communicator2 = WebsocketCommunicator(application, "/DUPLICATE/")
        connected2, _ = await communicator2.connect()
        self.assertTrue(connected2)

        # The first communicator should be closed when the second connects.
        await communicator1.wait()
        self.assertIsNot(store.connections.get(pending_key), first_consumer)

        await communicator2.disconnect()

    async def test_connectors_share_serial_without_disconnecting(self):
        communicator1 = WebsocketCommunicator(application, "/MULTI/")
        connected1, _ = await communicator1.connect()
        self.assertTrue(connected1)
        await communicator1.send_json_to(
            [
                2,
                "1",
                "StartTransaction",
                {"connectorId": 1, "meterStart": 10},
            ]
        )
        await communicator1.receive_json_from()

        communicator2 = WebsocketCommunicator(application, "/MULTI/")
        connected2, _ = await communicator2.connect()
        self.assertTrue(connected2)
        await communicator2.send_json_to(
            [
                2,
                "2",
                "StartTransaction",
                {"connectorId": 2, "meterStart": 10},
            ]
        )
        await communicator2.receive_json_from()

        key1 = store.identity_key("MULTI", 1)
        key2 = store.identity_key("MULTI", 2)
        self.assertIn(key1, store.connections)
        self.assertIn(key2, store.connections)
        self.assertIsNot(store.connections[key1], store.connections[key2])

        await communicator1.disconnect()
        await communicator2.disconnect()
        store.transactions.pop(key1, None)
        store.transactions.pop(key2, None)


    async def test_rate_limit_blocks_third_connection(self):
        store.ip_connections.clear()
        ip = "203.0.113.10"
        communicator1 = ClientWebsocketCommunicator(
            application, "/IPLIMIT1/", client=(ip, 1001)
        )
        communicator2 = ClientWebsocketCommunicator(
            application, "/IPLIMIT2/", client=(ip, 1002)
        )
        communicator3 = ClientWebsocketCommunicator(
            application, "/IPLIMIT3/", client=(ip, 1003)
        )
        other = ClientWebsocketCommunicator(
            application, "/OTHERIP/", client=("198.51.100.5", 2001)
        )
        connected1 = connected2 = connected_other = False
        try:
            connected1, _ = await communicator1.connect()
            self.assertTrue(connected1)
            connected2, _ = await communicator2.connect()
            self.assertTrue(connected2)
            connected3, code = await communicator3.connect()
            self.assertFalse(connected3)
            self.assertEqual(code, 4003)
            connected_other, _ = await other.connect()
            self.assertTrue(connected_other)
        finally:
            if connected1:
                await communicator1.disconnect()
            if connected2:
                await communicator2.disconnect()
            if connected_other:
                await other.disconnect()

    async def test_rate_limit_allows_reconnect_after_disconnect(self):
        store.ip_connections.clear()
        ip = "203.0.113.20"
        communicator1 = ClientWebsocketCommunicator(
            application, "/LIMITRESET1/", client=(ip, 3001)
        )
        communicator2 = ClientWebsocketCommunicator(
            application, "/LIMITRESET2/", client=(ip, 3002)
        )
        communicator3 = ClientWebsocketCommunicator(
            application, "/LIMITRESET3/", client=(ip, 3003)
        )
        communicator3_retry = None
        connected1 = connected2 = connected3_retry = False
        try:
            connected1, _ = await communicator1.connect()
            self.assertTrue(connected1)
            connected2, _ = await communicator2.connect()
            self.assertTrue(connected2)
            connected3, code = await communicator3.connect()
            self.assertFalse(connected3)
            self.assertEqual(code, 4003)
            await communicator1.disconnect()
            connected1 = False
            communicator3_retry = ClientWebsocketCommunicator(
                application, "/LIMITRESET4/", client=(ip, 3004)
            )
            connected3_retry, _ = await communicator3_retry.connect()
            self.assertTrue(connected3_retry)
        finally:
            if connected1:
                await communicator1.disconnect()
            if connected2:
                await communicator2.disconnect()
            if connected3_retry and communicator3_retry is not None:
                await communicator3_retry.disconnect()

    async def test_data_transfer_inbound_persists_message(self):
        store.pending_calls.clear()
        communicator = WebsocketCommunicator(application, "/DTIN/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        payload = {"vendorId": "Acme", "messageId": "diag", "data": {"foo": "bar"}}
        await communicator.send_json_to([2, "dt-msg", "DataTransfer", payload])
        response = await communicator.receive_json_from()
        self.assertEqual(response, [3, "dt-msg", {"status": "UnknownVendorId"}])

        await communicator.disconnect()

        message = await database_sync_to_async(DataTransferMessage.objects.get)(
            ocpp_message_id="dt-msg"
        )
        self.assertEqual(
            message.direction, DataTransferMessage.DIRECTION_CP_TO_CSMS
        )
        self.assertEqual(message.vendor_id, "Acme")
        self.assertEqual(message.message_id, "diag")
        self.assertEqual(message.payload, payload)
        self.assertEqual(message.status, "UnknownVendorId")
        self.assertIsNotNone(message.responded_at)

    async def test_data_transfer_action_round_trip(self):
        store.pending_calls.clear()
        communicator = WebsocketCommunicator(application, "/DTOUT/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        User = get_user_model()
        user = await database_sync_to_async(User.objects.create_user)(
            username="dtuser", password="pw"
        )
        await database_sync_to_async(self.client.force_login)(user)

        url = reverse("charger-action", args=["DTOUT"])
        request_payload = {
            "action": "data_transfer",
            "vendorId": "AcmeCorp",
            "messageId": "ping",
            "data": {"echo": "value"},
        }
        response = await database_sync_to_async(self.client.post)(
            url,
            data=json.dumps(request_payload),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        response_body = json.loads(response.content.decode())
        sent_frame = json.loads(response_body["sent"])
        self.assertEqual(sent_frame[2], "DataTransfer")
        sent_payload = sent_frame[3]
        self.assertEqual(sent_payload["vendorId"], "AcmeCorp")
        self.assertEqual(sent_payload.get("messageId"), "ping")

        outbound = await communicator.receive_json_from()
        self.assertEqual(outbound, sent_frame)

        message_id = sent_frame[1]
        record = await database_sync_to_async(DataTransferMessage.objects.get)(
            ocpp_message_id=message_id
        )
        self.assertEqual(
            record.direction, DataTransferMessage.DIRECTION_CSMS_TO_CP
        )
        self.assertEqual(record.status, "Pending")
        self.assertIsNone(record.response_data)
        self.assertIn(message_id, store.pending_calls)
        self.assertEqual(store.pending_calls[message_id]["message_pk"], record.pk)

        reply_payload = {"status": "Accepted", "data": {"result": "ok"}}
        await communicator.send_json_to([3, message_id, reply_payload])
        await asyncio.sleep(0.05)

        updated = await database_sync_to_async(DataTransferMessage.objects.get)(
            pk=record.pk
        )
        self.assertEqual(updated.status, "Accepted")
        self.assertEqual(updated.response_data, {"result": "ok"})
        self.assertIsNotNone(updated.responded_at)
        self.assertNotIn(message_id, store.pending_calls)

        await communicator.disconnect()


class ChargerLandingTests(TestCase):
    def setUp(self):
        self.client = Client()
        User = get_user_model()
        self.user = User.objects.create_user(username="u", password="pwd")
        self.client.force_login(self.user)

    def test_reference_created_and_page_renders(self):
        charger = Charger.objects.create(charger_id="PAGE1")
        self.assertIsNotNone(charger.reference)

        response = self.client.get(reverse("charger-page", args=["PAGE1"]))
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.context["LANGUAGE_CODE"], "es")
        with override("es"):
            self.assertContains(
                response,
                _(
                    "Plug in your vehicle and slide your RFID card over the reader to begin charging."
                ),
            )
            self.assertContains(response, _("Advanced View"))
        status_url = reverse("charger-status-connector", args=["PAGE1", "all"])
        self.assertContains(response, status_url)

    def test_status_page_renders(self):
        charger = Charger.objects.create(charger_id="PAGE2")
        resp = self.client.get(reverse("charger-status", args=["PAGE2"]))
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, "PAGE2")

    def test_placeholder_serial_rejected(self):
        with self.assertRaises(ValidationError):
            Charger.objects.create(charger_id="<charger_id>")

    def test_placeholder_serial_not_created_from_status_view(self):
        existing = Charger.objects.count()
        response = self.client.get(reverse("charger-status", args=["<charger_id>"]))
        self.assertEqual(response.status_code, 404)
        self.assertEqual(Charger.objects.count(), existing)
        self.assertFalse(
            Location.objects.filter(
                name__startswith="<", name__endswith=">", chargers__isnull=True
            ).exists()
        )

    def test_charger_page_shows_progress(self):
        charger = Charger.objects.create(charger_id="STATS")
        tx = Transaction.objects.create(
            charger=charger,
            meter_start=1000,
            start_time=timezone.now(),
        )
        key = store.identity_key(charger.charger_id, charger.connector_id)
        store.transactions[key] = tx
        resp = self.client.get(reverse("charger-page", args=["STATS"]))
        self.assertContains(resp, "progress-bar")
        store.transactions.pop(key, None)

    def test_display_name_used_on_public_pages(self):
        charger = Charger.objects.create(
            charger_id="NAMED",
            display_name="Entrada",
        )
        landing = self.client.get(reverse("charger-page", args=["NAMED"]))
        self.assertContains(landing, "Entrada")
        status = self.client.get(
            reverse("charger-status-connector", args=["NAMED", "all"])
        )
        self.assertContains(status, "Entrada")

    def test_total_includes_ongoing_transaction(self):
        charger = Charger.objects.create(charger_id="ONGOING")
        tx = Transaction.objects.create(
            charger=charger,
            meter_start=1000,
            start_time=timezone.now(),
        )
        key = store.identity_key(charger.charger_id, charger.connector_id)
        store.transactions[key] = tx
        MeterReading.objects.create(
            charger=charger,
            transaction=tx,
            timestamp=timezone.now(),
            measurand="Energy.Active.Import.Register",
            value=Decimal("2500"),
            unit="W",
        )
        resp = self.client.get(reverse("charger-status", args=["ONGOING"]))
        self.assertContains(resp, 'Total Energy: <span id="total-kw">1.50</span> kW')
        store.transactions.pop(key, None)

    def test_connector_specific_routes_render(self):
        Charger.objects.create(charger_id="ROUTED")
        connector = Charger.objects.create(charger_id="ROUTED", connector_id=1)
        page = self.client.get(reverse("charger-page-connector", args=["ROUTED", "1"]))
        self.assertEqual(page.status_code, 200)
        status = self.client.get(
            reverse("charger-status-connector", args=["ROUTED", "1"])
        )
        self.assertEqual(status.status_code, 200)
        search = self.client.get(
            reverse("charger-session-search-connector", args=["ROUTED", "1"])
        )
        self.assertEqual(search.status_code, 200)
        log_id = store.identity_key("ROUTED", connector.connector_id)
        store.add_log(log_id, "entry", log_type="charger")
        log = self.client.get(
            reverse("charger-log-connector", args=["ROUTED", "1"]) + "?type=charger"
        )
        self.assertContains(log, "entry")
        store.clear_log(log_id, log_type="charger")

    def test_temperature_displayed(self):
        charger = Charger.objects.create(
            charger_id="TEMP2", temperature=Decimal("21.5"), temperature_unit="Celsius"
        )
        resp = self.client.get(reverse("charger-status", args=["TEMP2"]))
        self.assertContains(resp, "Temperature")
        self.assertContains(resp, "21.5")

    def test_log_page_renders_without_charger(self):
        log_id = store.identity_key("LOG1", None)
        store.add_log(log_id, "hello", log_type="charger")
        entry = store.get_logs(log_id, log_type="charger")[0]
        self.assertRegex(entry, r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} hello$")
        resp = self.client.get(reverse("charger-log", args=["LOG1"]) + "?type=charger")
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, "hello")
        store.clear_log(log_id, log_type="charger")

    def test_log_page_is_case_insensitive(self):
        log_id = store.identity_key("cp2", None)
        store.add_log(log_id, "entry", log_type="charger")
        resp = self.client.get(reverse("charger-log", args=["CP2"]) + "?type=charger")
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, "entry")
        store.clear_log(log_id, log_type="charger")


class SimulatorLandingTests(TestCase):
    def setUp(self):
        role, _ = NodeRole.objects.get_or_create(name="Terminal")
        Node.objects.update_or_create(
            mac_address=Node.get_current_mac(),
            defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
        )
        Site.objects.update_or_create(
            id=1, defaults={"domain": "testserver", "name": ""}
        )
        app = Application.objects.create(name="Ocpp")
        module = Module.objects.create(node_role=role, application=app, path="/ocpp/")
        module.create_landings()
        User = get_user_model()
        self.user = User.objects.create_user(username="nav", password="pwd")
        self.client = Client()

    @skip("Navigation links unavailable in test environment")
    def test_simulator_app_link_in_nav(self):
        resp = self.client.get(reverse("pages:index"))
        self.assertContains(resp, "/ocpp/")
        self.assertNotContains(resp, "/ocpp/simulator/")
        self.client.force_login(self.user)
        resp = self.client.get(reverse("pages:index"))
        self.assertContains(resp, "/ocpp/")
        self.assertContains(resp, "/ocpp/simulator/")

    def test_cp_simulator_redirects_to_login(self):
        response = self.client.get(reverse("cp-simulator"))
        login_url = reverse("pages:login")
        self.assertEqual(response.status_code, 302)
        self.assertIn(login_url, response.url)


class ChargerAdminTests(TestCase):
    def setUp(self):
        self.client = Client()
        User = get_user_model()
        self.admin = User.objects.create_superuser(
            username="ocpp-admin", password="secret", email="admin@example.com"
        )
        self.client.force_login(self.admin)

    def test_admin_lists_landing_link(self):
        charger = Charger.objects.create(charger_id="ADMIN1")
        url = reverse("admin:ocpp_charger_changelist")
        resp = self.client.get(url)
        self.assertContains(resp, charger.get_absolute_url())
        status_url = reverse("charger-status-connector", args=["ADMIN1", "all"])
        self.assertContains(resp, status_url)

    def test_admin_does_not_list_qr_link(self):
        charger = Charger.objects.create(charger_id="QR1")
        url = reverse("admin:ocpp_charger_changelist")
        resp = self.client.get(url)
        self.assertNotContains(resp, charger.reference.image.url)

    def test_admin_lists_log_link(self):
        charger = Charger.objects.create(charger_id="LOG1")
        url = reverse("admin:ocpp_charger_changelist")
        resp = self.client.get(url)
        log_url = reverse("admin:ocpp_charger_log", args=[charger.pk])
        self.assertContains(resp, log_url)

    def test_admin_log_view_displays_entries(self):
        charger = Charger.objects.create(charger_id="LOG2")
        log_id = store.identity_key(charger.charger_id, charger.connector_id)
        store.add_log(log_id, "entry", log_type="charger")
        url = reverse("admin:ocpp_charger_log", args=[charger.pk])
        resp = self.client.get(url)
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, "entry")
        store.clear_log(log_id, log_type="charger")

    def test_admin_change_links_landing_page(self):
        charger = Charger.objects.create(charger_id="CHANGE1")
        url = reverse("admin:ocpp_charger_change", args=[charger.pk])
        resp = self.client.get(url)
        self.assertContains(resp, charger.get_absolute_url())

    def test_admin_shows_location_name(self):
        loc = Location.objects.create(name="AdminLoc")
        Charger.objects.create(charger_id="ADMINLOC", location=loc)
        url = reverse("admin:ocpp_charger_changelist")
        resp = self.client.get(url)
        self.assertContains(resp, "AdminLoc")

    def test_last_fields_are_read_only(self):
        now = timezone.now()
        charger = Charger.objects.create(
            charger_id="ADMINRO",
            last_heartbeat=now,
            last_meter_values={"a": 1},
        )
        url = reverse("admin:ocpp_charger_change", args=[charger.pk])
        resp = self.client.get(url)
        self.assertContains(resp, "Last heartbeat")
        self.assertContains(resp, "Last meter values")
        self.assertNotContains(resp, 'name="last_heartbeat"')
        self.assertNotContains(resp, 'name="last_meter_values"')

    def test_admin_action_sets_availability_state(self):
        charger = Charger.objects.create(charger_id="AVAIL1")
        url = reverse("admin:ocpp_charger_changelist")
        response = self.client.post(
            url,
            {
                "action": "set_availability_state_inoperative",
                "_selected_action": [charger.pk],
            },
            follow=True,
        )
        self.assertEqual(response.status_code, 200)
        charger.refresh_from_db()
        self.assertEqual(charger.availability_state, "Inoperative")
        self.assertIsNotNone(charger.availability_state_updated_at)

        response = self.client.post(
            url,
            {
                "action": "set_availability_state_operative",
                "_selected_action": [charger.pk],
            },
            follow=True,
        )
        self.assertEqual(response.status_code, 200)
        charger.refresh_from_db()
        self.assertEqual(charger.availability_state, "Operative")
        self.assertIsNotNone(charger.availability_state_updated_at)

    def test_purge_action_removes_data(self):
        charger = Charger.objects.create(charger_id="PURGE1")
        Transaction.objects.create(
            charger=charger,
            start_time=timezone.now(),
        )
        MeterReading.objects.create(
            charger=charger,
            timestamp=timezone.now(),
            value=1,
        )
        store.add_log(store.identity_key("PURGE1", None), "entry", log_type="charger")
        url = reverse("admin:ocpp_charger_changelist")
        self.client.post(
            url, {"action": "purge_data", "_selected_action": [charger.pk]}
        )
        self.assertFalse(Transaction.objects.filter(charger=charger).exists())
        self.assertFalse(MeterReading.objects.filter(charger=charger).exists())
        self.assertNotIn(store.identity_key("PURGE1", None), store.logs["charger"])

    def test_delete_requires_purge(self):
        charger = Charger.objects.create(charger_id="DEL1")
        Transaction.objects.create(
            charger=charger,
            start_time=timezone.now(),
        )
        delete_url = reverse("admin:ocpp_charger_delete", args=[charger.pk])
        with self.assertRaises(ProtectedError):
            self.client.post(delete_url, {"post": "yes"})
        self.assertTrue(Charger.objects.filter(pk=charger.pk).exists())
        url = reverse("admin:ocpp_charger_changelist")
        self.client.post(
            url, {"action": "purge_data", "_selected_action": [charger.pk]}
        )
        self.client.post(delete_url, {"post": "yes"})
        self.assertFalse(Charger.objects.filter(pk=charger.pk).exists())

    def test_fetch_configuration_dispatches_request(self):
        charger = Charger.objects.create(charger_id="CFGADMIN", connector_id=1)
        ws = DummyWebSocket()
        log_key = store.identity_key(charger.charger_id, charger.connector_id)
        store.clear_log(log_key, log_type="charger")
        pending_key = store.pending_key(charger.charger_id)
        store.clear_log(pending_key, log_type="charger")
        store.set_connection(charger.charger_id, charger.connector_id, ws)
        store.pending_calls.clear()
        try:
            url = reverse("admin:ocpp_charger_changelist")
            response = self.client.post(
                url,
                {
                    "action": "fetch_cp_configuration",
                    "_selected_action": [charger.pk],
                },
                follow=True,
            )
            self.assertEqual(response.status_code, 200)
            self.assertEqual(len(ws.sent), 1)
            frame = json.loads(ws.sent[0])
            self.assertEqual(frame[0], 2)
            self.assertEqual(frame[2], "GetConfiguration")
            self.assertIn(frame[1], store.pending_calls)
            metadata = store.pending_calls[frame[1]]
            self.assertEqual(metadata.get("action"), "GetConfiguration")
            self.assertEqual(metadata.get("charger_id"), charger.charger_id)
            self.assertEqual(metadata.get("connector_id"), charger.connector_id)
            self.assertEqual(metadata.get("log_key"), log_key)
            log_entries = store.get_logs(log_key, log_type="charger")
            self.assertTrue(
                any("GetConfiguration" in entry for entry in log_entries)
            )
        finally:
            store.pop_connection(charger.charger_id, charger.connector_id)
            store.pending_calls.clear()
            store.clear_log(log_key, log_type="charger")
            store.clear_log(pending_key, log_type="charger")

    def test_fetch_configuration_timeout_logged(self):
        charger = Charger.objects.create(charger_id="CFGWAIT", connector_id=1)
        ws = DummyWebSocket()
        log_key = store.identity_key(charger.charger_id, charger.connector_id)
        pending_key = store.pending_key(charger.charger_id)
        store.clear_log(log_key, log_type="charger")
        store.clear_log(pending_key, log_type="charger")
        store.set_connection(charger.charger_id, charger.connector_id, ws)
        store.pending_calls.clear()
        original_schedule = store.schedule_call_timeout
        try:
            with patch("ocpp.admin.store.schedule_call_timeout") as mock_schedule:
                def _side_effect(message_id, *, timeout=5.0, **kwargs):
                    kwargs["timeout"] = 0.05
                    return original_schedule(message_id, **kwargs)

                mock_schedule.side_effect = _side_effect
                url = reverse("admin:ocpp_charger_changelist")
                response = self.client.post(
                    url,
                    {
                        "action": "fetch_cp_configuration",
                        "_selected_action": [charger.pk],
                    },
                    follow=True,
                )
                self.assertEqual(response.status_code, 200)
                mock_schedule.assert_called_once()
            time.sleep(0.1)
            log_entries = store.get_logs(log_key, log_type="charger")
            self.assertTrue(
                any("GetConfiguration timed out" in entry for entry in log_entries)
            )
        finally:
            store.pop_connection(charger.charger_id, charger.connector_id)
            store.pending_calls.clear()
            store.clear_log(log_key, log_type="charger")
            store.clear_log(pending_key, log_type="charger")


class LocationAdminTests(TestCase):
    def setUp(self):
        self.client = Client()
        User = get_user_model()
        self.admin = User.objects.create_superuser(
            username="loc-admin", password="secret", email="loc@example.com"
        )
        self.client.force_login(self.admin)

    def test_change_form_lists_related_chargers(self):
        location = Location.objects.create(name="LocAdmin")
        base = Charger.objects.create(charger_id="LOCBASE", location=location)
        connector = Charger.objects.create(
            charger_id="LOCALTWO",
            connector_id=1,
            location=location,
        )

        url = reverse("admin:ocpp_location_change", args=[location.pk])
        resp = self.client.get(url)
        self.assertEqual(resp.status_code, 200)

        base_change_url = reverse("admin:ocpp_charger_change", args=[base.pk])
        connector_change_url = reverse("admin:ocpp_charger_change", args=[connector.pk])

        self.assertContains(resp, base_change_url)
        self.assertContains(resp, connector_change_url)
        self.assertContains(resp, f"Charge Point: {base.charger_id}")
        self.assertContains(resp, f"Charge Point: {connector.charger_id} #1")


class TransactionAdminTests(TestCase):
    def setUp(self):
        self.client = Client()
        User = get_user_model()
        self.admin = User.objects.create_superuser(
            username="tx-admin", password="secret", email="tx@example.com"
        )
        self.client.force_login(self.admin)

    def test_meter_readings_inline_displayed(self):
        charger = Charger.objects.create(charger_id="T1")
        tx = Transaction.objects.create(charger=charger, start_time=timezone.now())
        reading = MeterReading.objects.create(
            charger=charger,
            transaction=tx,
            timestamp=timezone.now(),
            value=Decimal("2.123"),
            unit="kW",
        )
        url = reverse("admin:ocpp_transaction_change", args=[tx.pk])
        resp = self.client.get(url)
        self.assertContains(resp, str(reading.value))


class SimulatorAdminTests(TransactionTestCase):
    def setUp(self):
        self.client = Client()
        User = get_user_model()
        self.admin = User.objects.create_superuser(
            username="admin2", password="secret", email="admin2@example.com"
        )
        self.client.force_login(self.admin)
        store.simulators.clear()
        store.logs["simulator"].clear()
        store.log_names["simulator"].clear()

    def test_admin_lists_log_link(self):
        sim = Simulator.objects.create(name="SIM", cp_path="SIMX")
        url = reverse("admin:ocpp_simulator_changelist")
        resp = self.client.get(url)
        log_url = reverse("admin:ocpp_simulator_log", args=[sim.pk])
        self.assertContains(resp, log_url)

    def test_admin_log_view_displays_entries(self):
        sim = Simulator.objects.create(name="SIMLOG", cp_path="SIMLOG")
        store.add_log("SIMLOG", "entry", log_type="simulator")
        url = reverse("admin:ocpp_simulator_log", args=[sim.pk])
        resp = self.client.get(url)
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, "entry")
        store.clear_log("SIMLOG", log_type="simulator")

    @patch("ocpp.admin.ChargePointSimulator.start")
    def test_start_simulator_message_includes_log_link(self, mock_start):
        sim = Simulator.objects.create(name="SIMMSG", cp_path="SIMMSG")
        mock_start.return_value = (True, "Connection accepted", "/tmp/sim.log")
        url = reverse("admin:ocpp_simulator_changelist")
        resp = self.client.post(
            url,
            {"action": "start_simulator", "_selected_action": [sim.pk]},
            follow=True,
        )
        self.assertEqual(resp.status_code, 200)
        log_url = reverse("admin:ocpp_simulator_log", args=[sim.pk])
        self.assertContains(resp, "View Log")
        self.assertContains(resp, log_url)
        self.assertContains(resp, "/tmp/sim.log")
        mock_start.assert_called_once()
        store.simulators.clear()

    def test_admin_shows_ws_url(self):
        sim = Simulator.objects.create(
            name="SIM2", cp_path="SIMY", host="h", ws_port=1111
        )
        url = reverse("admin:ocpp_simulator_changelist")
        resp = self.client.get(url)
        self.assertContains(resp, "ws://h:1111/SIMY/")

    def test_admin_ws_url_without_port(self):
        sim = Simulator.objects.create(
            name="SIMNP", cp_path="SIMNP", host="h", ws_port=None
        )
        url = reverse("admin:ocpp_simulator_changelist")
        resp = self.client.get(url)
        self.assertContains(resp, "ws://h/SIMNP/")

    def test_send_open_door_action_requires_running_simulator(self):
        sim = Simulator.objects.create(name="SIMDO", cp_path="SIMDO")
        url = reverse("admin:ocpp_simulator_changelist")
        resp = self.client.post(
            url,
            {"action": "send_open_door", "_selected_action": [sim.pk]},
            follow=True,
        )
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, "simulator is not running")
        self.assertFalse(Simulator.objects.get(pk=sim.pk).door_open)

    def test_send_open_door_action_triggers_simulator(self):
        sim = Simulator.objects.create(name="SIMTRIG", cp_path="SIMTRIG")
        stub = SimpleNamespace(trigger_door_open=Mock())
        store.simulators[sim.pk] = stub
        url = reverse("admin:ocpp_simulator_changelist")
        resp = self.client.post(
            url,
            {"action": "send_open_door", "_selected_action": [sim.pk]},
            follow=True,
        )
        self.assertEqual(resp.status_code, 200)
        stub.trigger_door_open.assert_called_once()
        self.assertContains(resp, "DoorOpen status notification sent")
        self.assertFalse(Simulator.objects.get(pk=sim.pk).door_open)
        store.simulators.pop(sim.pk, None)

    @patch("ocpp.admin.asyncio.get_running_loop", side_effect=RuntimeError)
    def test_stop_simulator_runs_without_event_loop(self, mock_get_loop):
        sim = Simulator.objects.create(name="SIMSTOP", cp_path="SIMSTOP")
        stopper = SimpleNamespace(stop=AsyncMock())
        store.simulators[sim.pk] = stopper
        url = reverse("admin:ocpp_simulator_changelist")
        resp = self.client.post(
            url,
            {"action": "stop_simulator", "_selected_action": [sim.pk]},
            follow=True,
        )
        self.assertEqual(resp.status_code, 200)
        stopper.stop.assert_awaited_once()
        self.assertTrue(mock_get_loop.called)
        self.assertNotIn(sim.pk, store.simulators)

    def test_as_config_includes_custom_fields(self):
        sim = Simulator.objects.create(
            name="SIM3",
            cp_path="S3",
            interval=3.5,
            kw_max=70,
            duration=500,
            pre_charge_delay=5,
            vin="WP0ZZZ99999999999",
            configuration_keys=[
                {"key": "HeartbeatInterval", "value": "300", "readonly": True}
            ],
            configuration_unknown_keys=["Bogus"],
        )
        cfg = sim.as_config()
        self.assertEqual(cfg.interval, 3.5)
        self.assertEqual(cfg.kw_max, 70)
        self.assertEqual(cfg.duration, 500)
        self.assertEqual(cfg.pre_charge_delay, 5)
        self.assertEqual(cfg.vin, "WP0ZZZ99999999999")
        self.assertEqual(
            cfg.configuration_keys,
            [{"key": "HeartbeatInterval", "value": "300", "readonly": True}],
        )
        self.assertEqual(cfg.configuration_unknown_keys, ["Bogus"])

    def _post_simulator_change(self, sim: Simulator, **overrides):
        url = reverse("admin:ocpp_simulator_change", args=[sim.pk])
        data = {
            "name": sim.name,
            "cp_path": sim.cp_path,
            "host": sim.host,
            "ws_port": sim.ws_port or "",
            "rfid": sim.rfid,
            "duration": sim.duration,
            "interval": sim.interval,
            "pre_charge_delay": sim.pre_charge_delay,
            "kw_max": sim.kw_max,
            "repeat": "on" if sim.repeat else "",
            "username": sim.username,
            "password": sim.password,
            "door_open": "on" if overrides.get("door_open", False) else "",
            "configuration_keys": json.dumps(sim.configuration_keys or []),
            "configuration_unknown_keys": json.dumps(
                sim.configuration_unknown_keys or []
            ),
            "_save": "Save",
        }
        data.update(overrides)
        return self.client.post(url, data, follow=True)

    def test_save_model_triggers_door_open(self):
        sim = Simulator.objects.create(name="SIMSAVE", cp_path="SIMSAVE")
        stub = SimpleNamespace(trigger_door_open=Mock())
        store.simulators[sim.pk] = stub
        resp = self._post_simulator_change(sim, door_open="on")
        self.assertEqual(resp.status_code, 200)
        stub.trigger_door_open.assert_called_once()
        self.assertContains(resp, "DoorOpen status notification sent")
        self.assertFalse(Simulator.objects.get(pk=sim.pk).door_open)
        store.simulators.pop(sim.pk, None)

    def test_save_model_reports_error_when_not_running(self):
        sim = Simulator.objects.create(name="SIMERR", cp_path="SIMERR")
        resp = self._post_simulator_change(sim, door_open="on")
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, "simulator is not running")
        self.assertFalse(Simulator.objects.get(pk=sim.pk).door_open)

    async def test_unknown_charger_auto_registered(self):
        communicator = WebsocketCommunicator(application, "/NEWCHG/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        exists = await database_sync_to_async(
            Charger.objects.filter(charger_id="NEWCHG").exists
        )()
        self.assertTrue(exists)

        charger = await database_sync_to_async(Charger.objects.get)(charger_id="NEWCHG")
        self.assertEqual(charger.last_path, "/NEWCHG/")

        await communicator.disconnect()

    async def test_nested_path_accepted_and_recorded(self):
        communicator = WebsocketCommunicator(application, "/foo/NEST/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.disconnect()

        charger = await database_sync_to_async(Charger.objects.get)(charger_id="NEST")
        self.assertEqual(charger.last_path, "/foo/NEST/")

    async def test_rfid_required_rejects_invalid(self):
        await database_sync_to_async(Charger.objects.create)(
            charger_id="RFID", require_rfid=True
        )
        communicator = WebsocketCommunicator(application, "/RFID/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [
                2,
                "1",
                "StartTransaction",
                {"meterStart": 0},
            ]
        )
        response = await communicator.receive_json_from()
        self.assertEqual(response[2]["idTagInfo"]["status"], "Invalid")

        exists = await database_sync_to_async(
            Transaction.objects.filter(charger__charger_id="RFID").exists
        )()
        self.assertFalse(exists)

        await communicator.disconnect()

    async def test_rfid_required_accepts_known_tag(self):
        User = get_user_model()
        user = await database_sync_to_async(User.objects.create_user)(
            username="bob", password="pwd"
        )
        acc = await database_sync_to_async(EnergyAccount.objects.create)(
            user=user, name="BOB"
        )
        await database_sync_to_async(EnergyCredit.objects.create)(
            account=acc, amount_kw=10
        )
        tag = await database_sync_to_async(RFID.objects.create)(rfid="CARDX")
        await database_sync_to_async(acc.rfids.add)(tag)
        await database_sync_to_async(Charger.objects.create)(
            charger_id="RFIDOK", require_rfid=True
        )
        communicator = WebsocketCommunicator(application, "/RFIDOK/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to(
            [
                2,
                "1",
                "StartTransaction",
                {"meterStart": 5, "idTag": "CARDX"},
            ]
        )
        response = await communicator.receive_json_from()
        self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
        tx_id = response[2]["transactionId"]

        tx = await database_sync_to_async(Transaction.objects.get)(
            pk=tx_id, charger__charger_id="RFIDOK"
        )
        self.assertEqual(tx.account_id, user.energy_account.id)

    async def test_status_fields_updated(self):
        communicator = WebsocketCommunicator(application, "/STAT/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        await communicator.send_json_to([2, "1", "Heartbeat", {}])
        await communicator.receive_json_from()

        charger = await database_sync_to_async(Charger.objects.get)(charger_id="STAT")
        self.assertIsNotNone(charger.last_heartbeat)

        payload = {
            "meterValue": [
                {
                    "timestamp": "2025-01-01T00:00:00Z",
                    "sampledValue": [{"value": "42"}],
                }
            ]
        }
        await communicator.send_json_to([2, "2", "MeterValues", payload])
        await communicator.receive_json_from()

        await database_sync_to_async(charger.refresh_from_db)()
        self.assertEqual(
            charger.last_meter_values.get("meterValue")[0]["sampledValue"][0]["value"],
            "42",
        )

        await communicator.disconnect()


class ChargerLocationTests(TestCase):
    def test_lat_lon_fields_saved(self):
        loc = Location.objects.create(
            name="Loc1", latitude=10.123456, longitude=-20.654321
        )
        charger = Charger.objects.create(charger_id="LOC1", location=loc)
        self.assertAlmostEqual(float(charger.latitude), 10.123456)
        self.assertAlmostEqual(float(charger.longitude), -20.654321)
        self.assertEqual(charger.name, "Loc1")

    def test_location_created_when_missing(self):
        charger = Charger.objects.create(charger_id="AUTOLOC")
        self.assertIsNotNone(charger.location)
        self.assertEqual(charger.location.name, "AUTOLOC")

    def test_location_reused_for_matching_serial(self):
        first = Charger.objects.create(charger_id="SHARE", connector_id=1)
        first.location.name = "Custom"
        first.location.save()
        second = Charger.objects.create(charger_id="SHARE", connector_id=2)
        self.assertEqual(second.location, first.location)


class MeterReadingTests(TransactionTestCase):
    async def test_meter_values_saved_as_readings(self):
        communicator = WebsocketCommunicator(application, "/MR1/")
        connected, _ = await communicator.connect()
        self.assertTrue(connected)

        payload = {
            "connectorId": 1,
            "transactionId": 100,
            "meterValue": [
                {
                    "timestamp": "2025-07-29T10:01:51Z",
                    "sampledValue": [
                        {
                            "value": "2.749",
                            "measurand": "Energy.Active.Import.Register",
                            "unit": "kW",
                        }
                    ],
                }
            ],
        }
        await communicator.send_json_to([2, "1", "MeterValues", payload])
        await communicator.receive_json_from()

        reading = await database_sync_to_async(MeterReading.objects.get)(
            charger__charger_id="MR1"
        )
        self.assertEqual(reading.transaction_id, 100)
        self.assertEqual(str(reading.value), "2.749")
        tx = await database_sync_to_async(Transaction.objects.get)(
            pk=100, charger__charger_id="MR1"
        )
        self.assertEqual(tx.meter_start, 2749)

        await communicator.disconnect()


class ChargePointSimulatorTests(TransactionTestCase):
    async def test_simulator_sends_messages(self):
        received = []

        async def handler(ws):
            async for msg in ws:
                data = json.loads(msg)
                received.append(data)
                action = data[2]
                if action == "BootNotification":
                    await ws.send(
                        json.dumps(
                            [
                                3,
                                data[1],
                                {
                                    "status": "Accepted",
                                    "currentTime": "2024-01-01T00:00:00Z",
                                    "interval": 300,
                                },
                            ]
                        )
                    )
                elif action == "Authorize":
                    await ws.send(
                        json.dumps(
                            [
                                3,
                                data[1],
                                {"idTagInfo": {"status": "Accepted"}},
                            ]
                        )
                    )
                elif action == "StartTransaction":
                    await ws.send(
                        json.dumps(
                            [
                                3,
                                data[1],
                                {
                                    "transactionId": 1,
                                    "idTagInfo": {"status": "Accepted"},
                                },
                            ]
                        )
                    )
                elif action == "MeterValues":
                    await ws.send(json.dumps([3, data[1], {}]))
                elif action == "StopTransaction":
                    await ws.send(
                        json.dumps(
                            [
                                3,
                                data[1],
                                {"idTagInfo": {"status": "Accepted"}},
                            ]
                        )
                    )
                    break

        server = await websockets.serve(
            handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
        )
        port = server.sockets[0].getsockname()[1]

        try:
            cfg = SimulatorConfig(
                host="127.0.0.1",
                ws_port=port,
                cp_path="SIM1/",
                vin="WP0ZZZ12345678901",
                duration=0.2,
                interval=0.05,
                kw_min=0.1,
                kw_max=0.2,
                pre_charge_delay=0.0,
                serial_number="SN123",
                connector_id=7,
            )
            sim = ChargePointSimulator(cfg)
            await sim._run_session()
        finally:
            server.close()
            await server.wait_closed()

        actions = [msg[2] for msg in received]
        self.assertIn("BootNotification", actions)
        self.assertIn("StartTransaction", actions)
        boot_msg = next(msg for msg in received if msg[2] == "BootNotification")
        self.assertEqual(boot_msg[3].get("serialNumber"), "SN123")
        start_msg = next(msg for msg in received if msg[2] == "StartTransaction")
        self.assertEqual(start_msg[3].get("vin"), "WP0ZZZ12345678901")
        self.assertEqual(start_msg[3].get("connectorId"), 7)

    async def test_start_returns_status_and_log(self):
        async def handler(ws):
            async for msg in ws:
                data = json.loads(msg)
                action = data[2]
                if action == "BootNotification":
                    await ws.send(
                        json.dumps(
                            [
                                3,
                                data[1],
                                {
                                    "status": "Accepted",
                                    "currentTime": "2024-01-01T00:00:00Z",
                                    "interval": 300,
                                },
                            ]
                        )
                    )
                elif action == "Authorize":
                    await ws.send(
                        json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
                    )
                elif action == "StartTransaction":
                    await ws.send(
                        json.dumps(
                            [
                                3,
                                data[1],
                                {
                                    "transactionId": 1,
                                    "idTagInfo": {"status": "Accepted"},
                                },
                            ]
                        )
                    )
                elif action == "StopTransaction":
                    await ws.send(
                        json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
                    )
                    break
                else:
                    await ws.send(json.dumps([3, data[1], {}]))

        server = await websockets.serve(
            handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
        )
        port = server.sockets[0].getsockname()[1]

        cfg = SimulatorConfig(
            host="127.0.0.1",
            ws_port=port,
            cp_path="SIMSTART/",
            duration=0.1,
            interval=0.05,
            kw_min=0.1,
            kw_max=0.2,
            pre_charge_delay=0.0,
        )
        store.register_log_name(cfg.cp_path, "SimStart", log_type="simulator")
        try:
            sim = ChargePointSimulator(cfg)
            started, status, log_file = await asyncio.to_thread(sim.start)
            self.assertTrue(started)
            self.assertEqual(status, "Connection accepted")
            self.assertEqual(sim.status, "running")
            self.assertTrue(Path(log_file).exists())
        finally:
            await sim.stop()
            store.clear_log(cfg.cp_path, log_type="simulator")
            server.close()
            await server.wait_closed()

    async def test_simulator_stops_when_charger_closes(self):
        async def handler(ws):
            async for msg in ws:
                data = json.loads(msg)
                action = data[2]
                if action == "BootNotification":
                    await ws.send(json.dumps([3, data[1], {"status": "Accepted"}]))
                elif action == "Authorize":
                    await ws.send(
                        json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
                    )
                    await ws.close()
                    break

        server = await websockets.serve(
            handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
        )
        port = server.sockets[0].getsockname()[1]

        cfg = SimulatorConfig(
            host="127.0.0.1",
            ws_port=port,
            cp_path="SIMTERM/",
            duration=0.1,
            interval=0.05,
            kw_min=0.1,
            kw_max=0.2,
            pre_charge_delay=0.0,
        )
        sim = ChargePointSimulator(cfg)
        try:
            started, _, _ = await asyncio.to_thread(sim.start)
            self.assertTrue(started)
            # Allow time for the server to close the connection
            await asyncio.sleep(0.1)
            self.assertEqual(sim.status, "stopped")
            self.assertFalse(sim._thread.is_alive())
        finally:
            await sim.stop()
            server.close()
            await server.wait_closed()

    async def test_pre_charge_sends_heartbeat_and_meter(self):
        received = []

        async def handler(ws):
            async for msg in ws:
                data = json.loads(msg)
                received.append(data)
                action = data[2]
                if action == "BootNotification":
                    await ws.send(json.dumps([3, data[1], {"status": "Accepted"}]))
                elif action in {
                    "Authorize",
                    "StatusNotification",
                    "Heartbeat",
                    "MeterValues",
                }:
                    await ws.send(json.dumps([3, data[1], {}]))
                elif action == "StartTransaction":
                    await ws.send(
                        json.dumps(
                            [
                                3,
                                data[1],
                                {
                                    "transactionId": 1,
                                    "idTagInfo": {"status": "Accepted"},
                                },
                            ]
                        )
                    )
                elif action == "StopTransaction":
                    await ws.send(
                        json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
                    )
                    break

        server = await websockets.serve(
            handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
        )
        port = server.sockets[0].getsockname()[1]

        try:
            cfg = SimulatorConfig(
                host="127.0.0.1",
                ws_port=port,
                cp_path="SIMPRE/",
                duration=0.1,
                interval=0.05,
                kw_min=0.1,
                kw_max=0.2,
                pre_charge_delay=0.1,
            )
            sim = ChargePointSimulator(cfg)
            await sim._run_session()
        finally:
            server.close()
            await server.wait_closed()

        actions = [msg[2] for msg in received]
        start_idx = actions.index("StartTransaction")
        pre_actions = actions[:start_idx]
        self.assertIn("Heartbeat", pre_actions)
        self.assertIn("MeterValues", pre_actions)

    async def test_simulator_times_out_without_response(self):
        async def handler(ws):
            async for _ in ws:
                pass

        server = await websockets.serve(
            handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
        )
        port = server.sockets[0].getsockname()[1]

        cfg = SimulatorConfig(host="127.0.0.1", ws_port=port, cp_path="SIMTO/")
        sim = ChargePointSimulator(cfg)
        store.simulators[99] = sim
        try:

            async def fake_wait_for(coro, timeout):
                coro.close()
                raise asyncio.TimeoutError

            with patch("ocpp.simulator.asyncio.wait_for", fake_wait_for):
                started, status, _ = await asyncio.to_thread(sim.start)
            await asyncio.to_thread(sim._thread.join)
            self.assertFalse(started)
            self.assertIn("Timeout", status)
            self.assertNotIn(99, store.simulators)
        finally:
            await sim.stop()
            server.close()
            await server.wait_closed()

    async def test_door_open_event_sends_notifications(self):
        status_payloads = []

        async def handler(ws):
            async for msg in ws:
                data = json.loads(msg)
                action = data[2]
                if action == "BootNotification":
                    await ws.send(
                        json.dumps(
                            [
                                3,
                                data[1],
                                {"status": "Accepted", "currentTime": "2024-01-01T00:00:00Z"},
                            ]
                        )
                    )
                elif action == "Authorize":
                    await ws.send(json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}]))
                elif action == "StatusNotification":
                    status_payloads.append(data[3])
                    await ws.send(json.dumps([3, data[1], {}]))
                elif action == "StartTransaction":
                    await ws.send(
                        json.dumps(
                            [
                                3,
                                data[1],
                                {"transactionId": 1, "idTagInfo": {"status": "Accepted"}},
                            ]
                        )
                    )
                elif action == "MeterValues":
                    await ws.send(json.dumps([3, data[1], {}]))
                elif action == "StopTransaction":
                    await ws.send(json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}]))
                    break

        server = await websockets.serve(
            handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
        )
        port = server.sockets[0].getsockname()[1]

        cfg = SimulatorConfig(
            host="127.0.0.1",
            ws_port=port,
            cp_path="SIMDOOR/",
            duration=0.2,
            interval=0.05,
            pre_charge_delay=0.0,
        )
        sim = ChargePointSimulator(cfg)
        sim.trigger_door_open()
        try:
            await sim._run_session()
        finally:
            server.close()
            await server.wait_closed()
            store.clear_log(cfg.cp_path, log_type="simulator")

        door_open_messages = [p for p in status_payloads if p.get("errorCode") == "DoorOpen"]
        door_closed_messages = [p for p in status_payloads if p.get("errorCode") == "NoError"]
        self.assertTrue(door_open_messages)
        self.assertTrue(door_closed_messages)
        first_open = next(
            idx for idx, payload in enumerate(status_payloads) if payload.get("errorCode") == "DoorOpen"
        )
        first_close = next(
            idx for idx, payload in enumerate(status_payloads) if payload.get("errorCode") == "NoError"
        )
        self.assertLess(first_open, first_close)

    async def test_get_configuration_uses_configured_keys(self):
        cfg = SimulatorConfig(
            configuration_keys=[
                {"key": "HeartbeatInterval", "value": "300", "readonly": True},
                {"key": "MeterValueSampleInterval", "value": 900},
            ],
            configuration_unknown_keys=["UnknownX"],
        )
        sim = ChargePointSimulator(cfg)
        sent: list[list[object]] = []

        async def send(msg: str):
            sent.append(json.loads(msg))

        async def recv():  # pragma: no cover - should not be called
            raise AssertionError("recv should not be called for GetConfiguration")

        handled = await sim._handle_csms_call(
            [
                2,
                "cfg-1",
                "GetConfiguration",
                {"key": ["HeartbeatInterval", "UnknownX", "MissingKey"]},
            ],
            send,
            recv,
        )
        self.assertTrue(handled)
        self.assertEqual(len(sent), 1)
        frame = sent[0]
        self.assertEqual(frame[0], 3)
        self.assertEqual(frame[1], "cfg-1")
        payload = frame[2]
        self.assertIn("configurationKey", payload)
        self.assertEqual(
            payload["configurationKey"],
            [{"key": "HeartbeatInterval", "value": "300", "readonly": True}],
        )
        self.assertIn("unknownKey", payload)
        self.assertCountEqual(payload["unknownKey"], ["UnknownX", "MissingKey"])

    async def test_get_configuration_without_filter_returns_all(self):
        cfg = SimulatorConfig(
            configuration_keys=[
                {"key": "AuthorizeRemoteTxRequests", "value": True},
                {"key": "ConnectorPhaseRotation", "value": "ABC"},
            ],
            configuration_unknown_keys=["GhostKey"],
        )
        sim = ChargePointSimulator(cfg)
        sent: list[list[object]] = []

        async def send(msg: str):
            sent.append(json.loads(msg))

        async def recv():  # pragma: no cover - should not be called
            raise AssertionError("recv should not be called for GetConfiguration")

        handled = await sim._handle_csms_call(
            [2, "cfg-2", "GetConfiguration", {}],
            send,
            recv,
        )
        self.assertTrue(handled)
        frame = sent[0]
        payload = frame[2]
        keys = payload.get("configurationKey")
        self.assertEqual(len(keys), 2)
        returned_keys = {item["key"] for item in keys}
        self.assertEqual(
            returned_keys,
            {"AuthorizeRemoteTxRequests", "ConnectorPhaseRotation"},
        )
        values = {item["key"]: item.get("value") for item in keys}
        self.assertEqual(values["AuthorizeRemoteTxRequests"], "True")
        self.assertEqual(values["ConnectorPhaseRotation"], "ABC")
        self.assertIn("unknownKey", payload)
        self.assertEqual(payload["unknownKey"], ["GhostKey"])

    async def test_trigger_message_heartbeat_follow_up(self):
        cfg = SimulatorConfig()
        sim = ChargePointSimulator(cfg)
        sent: list[list[object]] = []
        recv_count = 0

        async def send(msg: str):
            sent.append(json.loads(msg))

        async def recv():
            nonlocal recv_count
            recv_count += 1
            return json.dumps([3, f"ack-{recv_count}", {}])

        handled = await sim._handle_csms_call(
            [
                2,
                "trigger-req",
                "TriggerMessage",
                {"requestedMessage": "Heartbeat"},
            ],
            send,
            recv,
        )

        self.assertTrue(handled)
        self.assertGreaterEqual(len(sent), 2)
        result_frame = sent[0]
        follow_up_frame = sent[1]
        self.assertEqual(result_frame[0], 3)
        self.assertEqual(result_frame[1], "trigger-req")
        self.assertEqual(result_frame[2].get("status"), "Accepted")
        self.assertEqual(follow_up_frame[0], 2)
        self.assertEqual(follow_up_frame[2], "Heartbeat")
        self.assertEqual(recv_count, 1)

    async def test_trigger_message_rejected_for_invalid_connector(self):
        cfg = SimulatorConfig(connector_id=5)
        sim = ChargePointSimulator(cfg)
        sent: list[list[object]] = []

        async def send(msg: str):
            sent.append(json.loads(msg))

        async def recv():  # pragma: no cover - should not be called
            raise AssertionError("recv should not be called for rejected TriggerMessage")

        handled = await sim._handle_csms_call(
            [
                2,
                "trigger-invalid",
                "TriggerMessage",
                {"requestedMessage": "StatusNotification", "connectorId": 1},
            ],
            send,
            recv,
        )

        self.assertTrue(handled)
        self.assertEqual(len(sent), 1)
        self.assertEqual(sent[0][0], 3)
        self.assertEqual(sent[0][1], "trigger-invalid")
        self.assertEqual(sent[0][2].get("status"), "Rejected")


class PurgeMeterReadingsTaskTests(TestCase):
    def test_purge_old_meter_readings(self):
        charger = Charger.objects.create(charger_id="PURGER")
        tx = Transaction.objects.create(
            charger=charger,
            meter_start=0,
            meter_stop=1000,
            start_time=timezone.now(),
            stop_time=timezone.now(),
        )
        old = timezone.now() - timedelta(days=8)
        recent = timezone.now() - timedelta(days=2)
        MeterReading.objects.create(
            charger=charger, transaction=tx, timestamp=old, value=1
        )
        MeterReading.objects.create(
            charger=charger, transaction=tx, timestamp=recent, value=2
        )

        purge_meter_readings()

        self.assertEqual(MeterReading.objects.count(), 1)
        self.assertTrue(
            MeterReading.objects.filter(
                timestamp__gte=recent - timedelta(minutes=1)
            ).exists()
        )
        self.assertTrue(Transaction.objects.filter(pk=tx.pk).exists())

    def test_purge_skips_open_transactions(self):
        charger = Charger.objects.create(charger_id="PURGER2")
        tx = Transaction.objects.create(
            charger=charger,
            meter_start=0,
            start_time=timezone.now() - timedelta(days=9),
        )
        old = timezone.now() - timedelta(days=8)
        reading = MeterReading.objects.create(
            charger=charger, transaction=tx, timestamp=old, value=1
        )

        purge_meter_readings()

        self.assertTrue(MeterReading.objects.filter(pk=reading.pk).exists())


class TransactionKwTests(TestCase):
    def test_kw_sums_meter_readings(self):
        charger = Charger.objects.create(charger_id="SUM1")
        tx = Transaction.objects.create(
            charger=charger, start_time=timezone.now(), meter_start=0
        )
        MeterReading.objects.create(
            charger=charger,
            transaction=tx,
            timestamp=timezone.now(),
            value=Decimal("1000"),
            unit="W",
        )
        MeterReading.objects.create(
            charger=charger,
            transaction=tx,
            timestamp=timezone.now(),
            value=Decimal("1500"),
            unit="W",
        )
        self.assertAlmostEqual(tx.kw, 1.5)

    def test_kw_defaults_to_zero(self):
        charger = Charger.objects.create(charger_id="SUM2")
        tx = Transaction.objects.create(charger=charger, start_time=timezone.now())
        self.assertEqual(tx.kw, 0.0)


class DispatchActionViewTests(TestCase):
    def setUp(self):
        self.client = Client()
        User = get_user_model()
        self.user = User.objects.create_user(username="dispatch", password="pw")
        self.client.force_login(self.user)
        try:
            self.previous_loop = asyncio.get_event_loop()
        except RuntimeError:
            self.previous_loop = None
        self.loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self.loop)
        self.addCleanup(self._close_loop)
        self.charger = Charger.objects.create(
            charger_id="DISPATCH", connector_id=1
        )
        self.ws = DummyWebSocket()
        store.set_connection(
            self.charger.charger_id, self.charger.connector_id, self.ws
        )
        self.addCleanup(
            store.pop_connection,
            self.charger.charger_id,
            self.charger.connector_id,
        )
        self.log_key = store.identity_key(
            self.charger.charger_id, self.charger.connector_id
        )
        store.clear_log(self.log_key, log_type="charger")
        self.addCleanup(store.clear_log, self.log_key, "charger")
        store.pending_calls.clear()
        self.addCleanup(store.pending_calls.clear)
        self.url = reverse(
            "charger-action-connector",
            args=[self.charger.charger_id, self.charger.connector_slug],
        )

    def _close_loop(self):
        try:
            if not self.loop.is_closed():
                self.loop.run_until_complete(asyncio.sleep(0))
        except RuntimeError:
            pass
        finally:
            if not self.loop.is_closed():
                self.loop.close()
            asyncio.set_event_loop(self.previous_loop)

    def test_remote_start_requires_id_tag(self):
        response = self.client.post(
            self.url,
            data=json.dumps({"action": "remote_start"}),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 400)
        self.assertEqual(response.json().get("detail"), "idTag required")
        self.loop.run_until_complete(asyncio.sleep(0))
        self.assertEqual(self.ws.sent, [])

    def test_remote_start_dispatches_frame(self):
        response = self.client.post(
            self.url,
            data=json.dumps({"action": "remote_start", "idTag": "RF1234"}),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        self.loop.run_until_complete(asyncio.sleep(0))
        self.assertEqual(len(self.ws.sent), 1)
        frame = json.loads(self.ws.sent[0])
        self.assertEqual(frame[0], 2)
        self.assertEqual(frame[2], "RemoteStartTransaction")
        self.assertEqual(frame[3]["idTag"], "RF1234")
        self.assertEqual(frame[3]["connectorId"], 1)
        log_entries = store.logs["charger"].get(self.log_key, [])
        self.assertTrue(
            any("RemoteStartTransaction" in entry for entry in log_entries)
        )

    def test_change_availability_dispatches_frame(self):
        response = self.client.post(
            self.url,
            data=json.dumps({"action": "change_availability", "type": "Inoperative"}),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        self.loop.run_until_complete(asyncio.sleep(0))
        self.assertEqual(len(self.ws.sent), 1)
        frame = json.loads(self.ws.sent[0])
        self.assertEqual(frame[0], 2)
        self.assertEqual(frame[2], "ChangeAvailability")
        self.assertEqual(frame[3]["type"], "Inoperative")
        self.assertEqual(frame[3]["connectorId"], 1)
        self.assertIn(frame[1], store.pending_calls)
        self.charger.refresh_from_db()
        self.assertEqual(self.charger.availability_requested_state, "Inoperative")
        self.assertIsNotNone(self.charger.availability_requested_at)
        self.assertEqual(self.charger.availability_request_status, "")

    def test_change_availability_requires_valid_type(self):
        response = self.client.post(
            self.url,
            data=json.dumps({"action": "change_availability"}),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 400)
        self.loop.run_until_complete(asyncio.sleep(0))
        self.assertEqual(self.ws.sent, [])
        self.assertFalse(store.pending_calls)


class ChargerStatusViewTests(TestCase):
    def setUp(self):
        self.client = Client()
        User = get_user_model()
        self.user = User.objects.create_user(username="status", password="pwd")
        self.client.force_login(self.user)

    def test_chart_data_populated_from_existing_readings(self):
        charger = Charger.objects.create(charger_id="VIEW1", connector_id=1)
        tx = Transaction.objects.create(
            charger=charger, start_time=timezone.now(), meter_start=0
        )
        t0 = timezone.now()
        MeterReading.objects.create(
            charger=charger,
            transaction=tx,
            timestamp=t0,
            value=Decimal("1000"),
            unit="W",
        )
        MeterReading.objects.create(
            charger=charger,
            transaction=tx,
            timestamp=t0 + timedelta(seconds=10),
            value=Decimal("1500"),
            unit="W",
        )
        key = store.identity_key(charger.charger_id, charger.connector_id)
        store.transactions[key] = tx
        resp = self.client.get(
            reverse(
                "charger-status-connector",
                args=[charger.charger_id, charger.connector_slug],
            )
        )
        self.assertEqual(resp.status_code, 200)
        chart = resp.context["chart_data"]
        self.assertEqual(len(chart["labels"]), 2)
        self.assertEqual(len(chart["datasets"]), 1)
        values = chart["datasets"][0]["values"]
        self.assertEqual(chart["datasets"][0]["connector_id"], 1)
        self.assertAlmostEqual(values[0], 1.0)
        self.assertAlmostEqual(values[1], 1.5)
        store.transactions.pop(key, None)

    def test_chart_data_uses_meter_start_for_register_values(self):
        charger = Charger.objects.create(charger_id="VIEWREG", connector_id=1)
        tx = Transaction.objects.create(
            charger=charger, start_time=timezone.now(), meter_start=746060
        )
        t0 = timezone.now()
        MeterReading.objects.create(
            charger=charger,
            transaction=tx,
            timestamp=t0,
            measurand="Energy.Active.Import.Register",
            value=Decimal("746.060"),
            unit="kWh",
        )
        MeterReading.objects.create(
            charger=charger,
            transaction=tx,
            timestamp=t0 + timedelta(seconds=10),
            measurand="Energy.Active.Import.Register",
            value=Decimal("746.080"),
            unit="kWh",
        )
        key = store.identity_key(charger.charger_id, charger.connector_id)
        store.transactions[key] = tx
        resp = self.client.get(
            reverse(
                "charger-status-connector",
                args=[charger.charger_id, charger.connector_slug],
            )
        )
        chart = resp.context["chart_data"]
        self.assertEqual(len(chart["labels"]), 2)
        self.assertEqual(len(chart["datasets"]), 1)
        values = chart["datasets"][0]["values"]
        self.assertEqual(chart["datasets"][0]["connector_id"], 1)
        self.assertAlmostEqual(values[0], 0.0)
        self.assertAlmostEqual(values[1], 0.02)
        self.assertAlmostEqual(resp.context["tx"].kw, 0.02)
        store.transactions.pop(key, None)

    def test_diagnostics_status_displayed(self):
        reported_at = timezone.now().replace(microsecond=0)
        charger = Charger.objects.create(
            charger_id="DIAGPAGE",
            diagnostics_status="Uploaded",
            diagnostics_location="https://example.com/report.tar",
            diagnostics_timestamp=reported_at,
        )

        resp = self.client.get(reverse("charger-status", args=[charger.charger_id]))
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, "Diagnostics")
        self.assertContains(resp, "id=\"diagnostics-status\"")
        self.assertContains(resp, "Uploaded")
        self.assertContains(resp, "id=\"diagnostics-timestamp\"")
        self.assertContains(resp, "id=\"diagnostics-location\"")
        self.assertContains(resp, "https://example.com/report.tar")

    def test_connector_status_prefers_connector_diagnostics(self):
        aggregate = Charger.objects.create(
            charger_id="DIAGCONN",
            diagnostics_status="Uploaded",
        )
        connector = Charger.objects.create(
            charger_id="DIAGCONN",
            connector_id=1,
            diagnostics_status="Uploading",
        )

        aggregate_resp = self.client.get(
            reverse("charger-status", args=[aggregate.charger_id])
        )
        self.assertContains(aggregate_resp, "Uploaded")
        self.assertNotContains(aggregate_resp, "Uploading")

        connector_resp = self.client.get(
            reverse(
                "charger-status-connector",
                args=[connector.charger_id, connector.connector_slug],
            )
        )
        self.assertContains(connector_resp, "Uploading")

    def test_sessions_are_linked(self):
        charger = Charger.objects.create(charger_id="LINK1")
        tx = Transaction.objects.create(charger=charger, start_time=timezone.now())
        resp = self.client.get(reverse("charger-status", args=[charger.charger_id]))
        self.assertContains(resp, f"?session={tx.id}")

    def test_status_links_landing_page(self):
        charger = Charger.objects.create(charger_id="LAND1")
        resp = self.client.get(reverse("charger-status", args=[charger.charger_id]))
        self.assertContains(resp, reverse("charger-page", args=[charger.charger_id]))

    def test_configuration_link_hidden_for_non_staff(self):
        charger = Charger.objects.create(charger_id="CFG-HIDE")
        response = self.client.get(reverse("charger-status", args=[charger.charger_id]))
        admin_url = reverse("admin:ocpp_charger_change", args=[charger.pk])
        self.assertNotContains(response, admin_url)
        self.assertNotContains(response, _("Configuration"))

    def test_configuration_link_visible_for_staff(self):
        charger = Charger.objects.create(charger_id="CFG-SHOW")
        self.user.is_staff = True
        self.user.save(update_fields=["is_staff"])
        response = self.client.get(reverse("charger-status", args=[charger.charger_id]))
        admin_url = reverse("admin:ocpp_charger_change", args=[charger.pk])
        self.assertContains(response, admin_url)
        self.assertContains(response, _("Configuration"))

    def test_past_session_chart(self):
        charger = Charger.objects.create(charger_id="PAST1", connector_id=1)
        tx = Transaction.objects.create(
            charger=charger, start_time=timezone.now(), meter_start=0
        )
        t0 = timezone.now()
        MeterReading.objects.create(
            charger=charger,
            transaction=tx,
            timestamp=t0,
            value=Decimal("1000"),
            unit="W",
        )
        MeterReading.objects.create(
            charger=charger,
            transaction=tx,
            timestamp=t0 + timedelta(seconds=10),
            value=Decimal("1500"),
            unit="W",
        )
        resp = self.client.get(
            reverse(
                "charger-status-connector",
                args=[charger.charger_id, charger.connector_slug],
            )
            + f"?session={tx.id}"
        )
        self.assertContains(resp, "Back to live")
        chart = resp.context["chart_data"]
        self.assertEqual(len(chart["labels"]), 2)
        self.assertEqual(len(chart["datasets"]), 1)
        self.assertEqual(chart["datasets"][0]["connector_id"], 1)
        self.assertTrue(resp.context["past_session"])

    def test_aggregate_chart_includes_multiple_connectors(self):
        aggregate = Charger.objects.create(charger_id="VIEWAGG")
        connector_one = Charger.objects.create(charger_id="VIEWAGG", connector_id=1)
        connector_two = Charger.objects.create(charger_id="VIEWAGG", connector_id=2)
        base_time = timezone.now()
        tx_one = Transaction.objects.create(
            charger=connector_one, start_time=base_time, meter_start=0
        )
        tx_two = Transaction.objects.create(
            charger=connector_two, start_time=base_time, meter_start=0
        )
        MeterReading.objects.create(
            charger=connector_one,
            transaction=tx_one,
            timestamp=base_time,
            value=Decimal("1000"),
            unit="W",
        )
        MeterReading.objects.create(
            charger=connector_one,
            transaction=tx_one,
            timestamp=base_time + timedelta(seconds=15),
            value=Decimal("1500"),
            unit="W",
        )
        MeterReading.objects.create(
            charger=connector_two,
            transaction=tx_two,
            timestamp=base_time + timedelta(seconds=5),
            value=Decimal("2000"),
            unit="W",
        )
        MeterReading.objects.create(
            charger=connector_two,
            transaction=tx_two,
            timestamp=base_time + timedelta(seconds=20),
            value=Decimal("2600"),
            unit="W",
        )
        key_one = store.identity_key(
            connector_one.charger_id, connector_one.connector_id
        )
        key_two = store.identity_key(
            connector_two.charger_id, connector_two.connector_id
        )
        store.transactions[key_one] = tx_one
        store.transactions[key_two] = tx_two
        try:
            resp = self.client.get(
                reverse("charger-status", args=[aggregate.charger_id])
            )
            chart = resp.context["chart_data"]
            self.assertTrue(resp.context["show_chart"])
            self.assertEqual(len(chart["datasets"]), 2)
            data_map = {
                dataset["label"]: dataset["values"] for dataset in chart["datasets"]
            }
            connector_id_map = {
                dataset["label"]: dataset.get("connector_id")
                for dataset in chart["datasets"]
            }
            label_one = str(connector_one.connector_label)
            label_two = str(connector_two.connector_label)
            self.assertEqual(set(data_map), {label_one, label_two})
            self.assertEqual(len(data_map[label_one]), len(chart["labels"]))
            self.assertEqual(len(data_map[label_two]), len(chart["labels"]))
            self.assertTrue(any(value is not None for value in data_map[label_one]))
            self.assertTrue(any(value is not None for value in data_map[label_two]))
            self.assertEqual(connector_id_map[label_one], connector_one.connector_id)
            self.assertEqual(connector_id_map[label_two], connector_two.connector_id)
        finally:
            store.transactions.pop(key_one, None)
            store.transactions.pop(key_two, None)


class ChargerApiDiagnosticsTests(TestCase):
    def setUp(self):
        self.client = Client()
        User = get_user_model()
        self.user = User.objects.create_user(username="diagapi", password="pwd")
        self.client.force_login(self.user)

    def test_detail_includes_diagnostics_fields(self):
        reported_at = timezone.now().replace(microsecond=0)
        charger = Charger.objects.create(
            charger_id="APIDIAG",
            diagnostics_status="Uploaded",
            diagnostics_timestamp=reported_at,
            diagnostics_location="https://example.com/diag.tar",
        )

        resp = self.client.get(reverse("charger-detail", args=[charger.charger_id]))
        self.assertEqual(resp.status_code, 200)
        payload = resp.json()
        self.assertEqual(payload["diagnosticsStatus"], "Uploaded")
        self.assertEqual(
            payload["diagnosticsTimestamp"], reported_at.isoformat()
        )
        self.assertEqual(
            payload["diagnosticsLocation"], "https://example.com/diag.tar"
        )

    def test_list_includes_diagnostics_fields(self):
        reported_at = timezone.now().replace(microsecond=0)
        Charger.objects.create(
            charger_id="APILIST",
            diagnostics_status="Idle",
            diagnostics_timestamp=reported_at,
            diagnostics_location="s3://bucket/diag.zip",
        )

        resp = self.client.get(reverse("charger-list"))
        self.assertEqual(resp.status_code, 200)
        payload = resp.json()
        self.assertIn("chargers", payload)
        target = next(
            item
            for item in payload["chargers"]
            if item["charger_id"] == "APILIST" and item["connector_id"] is None
        )
        self.assertEqual(target["diagnosticsStatus"], "Idle")
        self.assertEqual(target["diagnosticsLocation"], "s3://bucket/diag.zip")
        self.assertEqual(target["diagnosticsTimestamp"], reported_at.isoformat())


class ChargerSessionPaginationTests(TestCase):
    def setUp(self):
        self.client = Client()
        User = get_user_model()
        self.user = User.objects.create_user(username="page", password="pwd")
        self.client.force_login(self.user)
        self.charger = Charger.objects.create(charger_id="PAGETEST")
        for i in range(15):
            Transaction.objects.create(
                charger=self.charger,
                start_time=timezone.now() - timedelta(minutes=i),
                meter_start=0,
            )

    def test_only_ten_transactions_shown(self):
        resp = self.client.get(
            reverse("charger-status", args=[self.charger.charger_id])
        )
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(len(resp.context["transactions"]), 10)
        self.assertTrue(resp.context["page_obj"].has_next())

    def test_session_search_by_date(self):
        date_str = timezone.now().date().isoformat()
        resp = self.client.get(
            reverse("charger-session-search", args=[self.charger.charger_id]),
            {"date": date_str},
        )
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(len(resp.context["transactions"]), 15)


class LiveUpdateViewTests(TestCase):
    def setUp(self):
        User = get_user_model()
        self.user = User.objects.create_user(username="lu", password="pw")
        self.client.force_login(self.user)

    def test_dashboard_includes_interval(self):
        resp = self.client.get(reverse("ocpp-dashboard"))
        self.assertEqual(resp.context["request"].live_update_interval, 5)
        self.assertContains(resp, "setInterval(() => location.reload()")

    def test_cp_simulator_includes_interval(self):
        resp = self.client.get(reverse("cp-simulator"))
        self.assertEqual(resp.context["request"].live_update_interval, 5)
        self.assertContains(resp, "setInterval(() => location.reload()")

    def test_dashboard_hides_private_chargers(self):
        public = Charger.objects.create(charger_id="PUBLICCP")
        private = Charger.objects.create(
            charger_id="PRIVATECP", public_display=False
        )

        resp = self.client.get(reverse("ocpp-dashboard"))
        chargers = [item["charger"] for item in resp.context["chargers"]]
        self.assertIn(public, chargers)
        self.assertNotIn(private, chargers)

        list_response = self.client.get(reverse("charger-list"))
        payload = list_response.json()
        ids = [item["charger_id"] for item in payload["chargers"]]
        self.assertIn(public.charger_id, ids)
        self.assertNotIn(private.charger_id, ids)

    def test_dashboard_restricts_to_owner_users(self):
        User = get_user_model()
        owner = User.objects.create_user(username="owner", password="pw")
        other = User.objects.create_user(username="outsider", password="pw")
        unrestricted = Charger.objects.create(charger_id="UNRESTRICTED")
        restricted = Charger.objects.create(charger_id="RESTRICTED")
        restricted.owner_users.add(owner)

        self.client.force_login(owner)
        owner_resp = self.client.get(reverse("ocpp-dashboard"))
        owner_chargers = [item["charger"] for item in owner_resp.context["chargers"]]
        self.assertIn(unrestricted, owner_chargers)
        self.assertIn(restricted, owner_chargers)

        self.client.force_login(other)
        other_resp = self.client.get(reverse("ocpp-dashboard"))
        other_chargers = [item["charger"] for item in other_resp.context["chargers"]]
        self.assertIn(unrestricted, other_chargers)
        self.assertNotIn(restricted, other_chargers)

        self.client.force_login(owner)
        detail_resp = self.client.get(
            reverse("charger-page", args=[restricted.charger_id])
        )
        self.assertEqual(detail_resp.status_code, 200)

        self.client.force_login(other)
        denied_resp = self.client.get(
            reverse("charger-page", args=[restricted.charger_id])
        )
        self.assertEqual(denied_resp.status_code, 404)

    def test_dashboard_restricts_to_owner_groups(self):
        User = get_user_model()
        group = SecurityGroup.objects.create(name="Operations")
        member = User.objects.create_user(username="member", password="pw")
        outsider = User.objects.create_user(username="visitor", password="pw")
        member.groups.add(group)
        unrestricted = Charger.objects.create(charger_id="GROUPFREE")
        restricted = Charger.objects.create(charger_id="GROUPLOCKED")
        restricted.owner_groups.add(group)

        self.client.force_login(member)
        member_resp = self.client.get(reverse("ocpp-dashboard"))
        member_chargers = [item["charger"] for item in member_resp.context["chargers"]]
        self.assertIn(unrestricted, member_chargers)
        self.assertIn(restricted, member_chargers)

        self.client.force_login(outsider)
        outsider_resp = self.client.get(reverse("ocpp-dashboard"))
        outsider_chargers = [item["charger"] for item in outsider_resp.context["chargers"]]
        self.assertIn(unrestricted, outsider_chargers)
        self.assertNotIn(restricted, outsider_chargers)

        self.client.force_login(member)
        status_resp = self.client.get(
            reverse("charger-status", args=[restricted.charger_id])
        )
        self.assertEqual(status_resp.status_code, 200)

        self.client.force_login(outsider)
        group_denied = self.client.get(
            reverse("charger-status", args=[restricted.charger_id])
        )
        self.assertEqual(group_denied.status_code, 404)
