import importlib
import json
import sys
import types
from pathlib import Path

import pytest

try:
    import requests  # type: ignore
except ImportError:
    requests_stub = types.ModuleType("requests")

    class _DummyResponse:
        def __init__(self, status_code: int = 200):
            self.status_code = status_code
            self.text = ""

        def json(self):
            return {}

    class _DummyRequestsException(Exception):
        pass

    def _dummy_response(*_, **__):
        return _DummyResponse()

    requests_stub.post = _dummy_response
    requests_stub.put = _dummy_response
    requests_stub.get = _dummy_response
    requests_stub.RequestException = _DummyRequestsException
    requests_stub.Timeout = _DummyRequestsException

    sys.modules["requests"] = requests_stub

ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from ocpi_utils import BackendProfile, BackendRegistry
from ocpi_payloads import map_incoming_payload


def _load_ocpi_app(monkeypatch, config_path, token: str):
    monkeypatch.setenv("PIPELET_CONFIG", str(config_path))
    monkeypatch.setenv("OCPI_API_TOKEN", token)
    sys.modules.pop("ocpi_api", None)
    ocpi_api = importlib.import_module("ocpi_api")
    ocpi_api.scheduler.stop()
    ocpi_api.backend_repo = _DummyBackendRepo(token)
    ocpi_api.location_repo = _DummyLocationRepo()
    ocpi_api.alert_notifier = _SilentNotifier()
    ocpi_api.data_repo = _DummyTokenRepo()
    ocpi_api.handshake_repo = _DummyHandshakeRepo()
    ocpi_api.backend_registry = BackendRegistry(
        [BackendProfile(backend_id="test-backend", token=token, modules={"locations", "sessions", "cdrs", "tariffs", "tokens"})],
        fallback_token=token,
    )
    return ocpi_api


class _DummyBackendRepo:
    def __init__(self, token: str):
        self.token = token
        self.peer_token = None
        self.peer_versions_url = None
        self.remote_versions_url = None
        self.active_version = None
        self.credentials_token = None
        self.update_calls: list[dict[str, object]] = []
        self.missing_backends: set[object] = set()
        self.url: str | None = None
        self.remote_backend_url: str | None = "http://msp.example"

    def get_backend(self, backend_id):
        if backend_id in self.missing_backends:
            return None
        return {
            "backend_id": backend_id or "default",
            "token": self.token,
            "peer_token": self.peer_token,
            "peer_versions_url": self.peer_versions_url,
            "remote_versions_url": self.remote_versions_url,
            "active_version": self.active_version,
            "credentials_token": self.credentials_token,
            "url": self.url,
            "modules": "locations,sessions,cdrs,tariffs,tokens,commands",
            "enabled": 1,
        }

    def find_by_token(self, token):
        if token in {self.token, self.peer_token}:
            return self.get_backend("default")
        if token:
            return {
                "backend_id": "dynamic",
                "token": token,
                "peer_token": None,
                "peer_versions_url": None,
                "remote_versions_url": None,
                "active_version": None,
                "credentials_token": None,
                "url": self.remote_backend_url,
                "modules": "locations,sessions,cdrs,tariffs,tokens,commands",
                "enabled": 1,
            }
        return None

    def list_backends(self, module=None):
        return []

    def update_credentials_exchange(self, *args, **kwargs):
        self.update_calls.append({"args": args, "kwargs": kwargs})
        peer_token = kwargs.get("peer_token")
        peer_url = kwargs.get("peer_url")
        remote_versions_url = kwargs.get("remote_versions_url")
        active_version = kwargs.get("active_version")
        clear_missing = kwargs.get("clear_missing", False)
        if clear_missing:
            self.peer_token = peer_token
            self.peer_versions_url = peer_url
            self.remote_versions_url = remote_versions_url
            self.active_version = active_version
        else:
            if peer_token is not None:
                self.peer_token = peer_token
            if peer_url is not None:
                self.peer_versions_url = peer_url
            if remote_versions_url is not None:
                self.remote_versions_url = remote_versions_url
            if active_version is not None:
                self.active_version = active_version
        return None

    def save_credentials_token(self, backend_id, token):
        self.credentials_token = token
        return None


class _DummyHandshakeRepo:
    def __init__(self):
        self.records: list[dict[str, object]] = []

    def record(self, backend_id, *, state, status, detail=None, token=None, peer_url=None):
        self.records.append(
            {
                "backend_id": backend_id,
                "state": state,
                "status": status,
                "detail": detail,
                "token": token,
                "peer_url": peer_url,
            }
        )

    def list(self, backend_id=None, *, limit=200):
        return list(self.records)


class _DummyLocationRepo:
    def __init__(self):
        self.locations = {
            "LOC-1": {
                "id": "LOC-1",
                "evses": [
                    {"uid": "EVSE-1", "evse_id": "EVSE-1", "connectors": []}
                ],
            }
        }

    def list_locations(self):
        return list(self.locations.values())

    def get_location(self, location_id):
        return self.locations.get(location_id)

    def upsert_location_override(self, location_id, payload, evse_uid=None):
        if evse_uid:
            location = self.locations.setdefault(
                location_id, {"id": location_id, "evses": []}
            )
            updated_evse = {**payload, "uid": evse_uid}
            evses = list(location.get("evses") or [])
            for idx, evse in enumerate(evses):
                if evse.get("uid") == evse_uid:
                    evses[idx] = updated_evse
                    break
            else:
                evses.append(updated_evse)
            location["evses"] = evses
            self.locations[location_id] = location
        else:
            self.locations[location_id] = payload

    def merge_location_override(self, location_id, payload, evse_uid=None):
        if evse_uid:
            location = self.locations.setdefault(
                location_id, {"id": location_id, "evses": []}
            )
            merged_location = dict(location)
            evses = list(merged_location.get("evses") or [])
            merged_evse = {**payload, "uid": evse_uid}
            for idx, evse in enumerate(evses):
                if evse.get("uid") == evse_uid:
                    merged_evse = {**evse, **payload, "uid": evse_uid}
                    evses[idx] = merged_evse
                    break
            else:
                evses.append(merged_evse)
            merged_location["evses"] = evses
            self.locations[location_id] = merged_location
            return merged_evse

        existing = self.locations.get(location_id, {"id": location_id})
        merged = {**existing, **payload}
        self.locations[location_id] = merged
        return merged


class _DummyTokenRepo:
    def __init__(self):
        self.tokens: dict[str, dict] = {}

    def list_tokens(self, *_, **__):
        tokens = list(self.tokens.values())
        return tokens, len(tokens)

    def get_token(self, uid):
        token = self.tokens.get(uid)
        return dict(token) if token else None

    def upsert_token(self, payload):
        normalized = map_incoming_payload("tokens", "2.2", payload, validate_required=False).payload
        token_uid = normalized.get("uid") or payload.get("uid")
        token = {
            "uid": token_uid,
            "type": normalized.get("type", "RFID"),
            "auth_id": normalized.get("auth_id") or token_uid,
            "issuer": normalized.get("issuer", "TestIssuer"),
            "valid": normalized.get("valid", True),
            "whitelist": normalized.get("whitelist", "ALLOWED"),
            "contract_id": normalized.get("contract_id") or normalized.get("auth_id") or token_uid,
            "last_updated": normalized.get("last_updated") or "2024-01-01T00:00:00Z",
            "status": normalized.get("status", "ACTIVE"),
            "visual_number": normalized.get("visual_number"),
        }
        self.tokens[str(token_uid)] = token
        return token

    def authorize_token(self, uid):
        token = self.get_token(uid)
        if not token:
            return False, None
        return bool(token.get("valid", True)), token


class _SilentNotifier:
    def record_failure(self, *_, **__):
        return None

    def record_success(self, *_, **__):
        return None


@pytest.fixture(scope="module")
def ocpi_app(tmp_path_factory):
    monkeypatch = pytest.MonkeyPatch()
    config_path = tmp_path_factory.mktemp("ocpi") / "config.json"
    config_path.write_text(
        json.dumps(
            {
                "ocpi_api": {
                    "token": "test-token",
                    "base_url": None,
                },
                "mysql": {},
                "scheduler": {
                    "location_interval_seconds": 0,
                    "tariff_interval_seconds": 0,
                    "retry_interval_seconds": 0,
                },
            }
        ),
        encoding="utf-8",
    )
    ocpi_api = _load_ocpi_app(monkeypatch, config_path, "test-token")
    yield ocpi_api
    ocpi_api.scheduler.stop()
    monkeypatch.undo()


@pytest.fixture(params=["2.1.1", "2.2", "2.3"])
def ocpi_client(request, ocpi_app):
    return request.param, ocpi_app.app.test_client()


def test_list_versions_includes_all_supported(ocpi_app):
    client = ocpi_app.app.test_client()

    response = client.get("/ocpi/versions", headers={"Authorization": "Token test-token"})

    payload = response.get_json()
    versions = {entry["version"] for entry in payload["data"]}
    assert response.status_code == 200
    assert versions == {"2.1.1", "2.2", "2.3"}
    assert all(entry["url"].endswith(f"/ocpi/{entry['version']}") for entry in payload["data"])


def test_version_details_exposes_endpoints(ocpi_client):
    version, client = ocpi_client

    response = client.get(f"/ocpi/{version}", headers={"Authorization": "Token test-token"})

    body = response.get_json()
    identifiers = {endpoint["identifier"] for endpoint in body["data"]["endpoints"]}
    assert response.status_code == 200
    assert body["data"]["version"] == version
    assert "credentials" in identifiers
    assert "locations" in identifiers
    if version == "2.1.1":
        assert "chargingprofiles" not in identifiers
    else:
        assert "chargingprofiles" in identifiers


def test_locations_get_returns_response_structure(ocpi_client):
    version, client = ocpi_client

    response = client.get(
        f"/ocpi/{version}/locations/",
        headers={"Authorization": "Token test-token"},
    )

    body = response.get_json()
    assert response.status_code == 200
    assert body["status_code"] == 1000
    assert isinstance(body["data"], list)
    assert body["data"][0]["id"] == "LOC-1"


def test_locations_get_single_location(ocpi_client):
    version, client = ocpi_client

    response = client.get(
        f"/ocpi/{version}/locations/LOC-1",
        headers={"Authorization": "Token test-token"},
    )

    body = response.get_json()
    assert response.status_code == 200
    assert body["status_code"] == 1000
    assert body["data"]["id"] == "LOC-1"


def test_locations_get_single_location_not_found(ocpi_client):
    version, client = ocpi_client

    response = client.get(
        f"/ocpi/{version}/locations/UNKNOWN",
        headers={"Authorization": "Token test-token"},
    )

    body = response.get_json()
    assert response.status_code == 404
    assert body["error_code"] == "NOT_FOUND"
    assert body["status_code"] == 2001


def test_locations_patch_merges_payload(ocpi_client, ocpi_app):
    ocpi_app.location_repo.locations = {
        "LOC-1": {
            "id": "LOC-1",
            "evses": [{"uid": "EVSE-1", "evse_id": "EVSE-1", "connectors": []}],
        }
    }
    version, client = ocpi_client

    patch_response = client.patch(
        f"/ocpi/{version}/locations/",
        json={"id": "LOC-1", "name": "Updated Location"},
        headers={"Authorization": "Token test-token"},
    )
    patch_body = patch_response.get_json()

    get_response = client.get(
        f"/ocpi/{version}/locations/LOC-1",
        headers={"Authorization": "Token test-token"},
    )
    get_body = get_response.get_json()

    assert patch_response.status_code == 200
    assert patch_body["data"]["name"] == "Updated Location"
    assert get_response.status_code == 200
    assert get_body["data"]["id"] == "LOC-1"
    assert get_body["data"]["evses"][0]["uid"] == "EVSE-1"


def test_locations_patch_evse_merges_payload(ocpi_client, ocpi_app):
    ocpi_app.location_repo.locations = {
        "LOC-1": {
            "id": "LOC-1",
            "evses": [{"uid": "EVSE-1", "evse_id": "EVSE-1", "connectors": []}],
        }
    }
    version, client = ocpi_client

    response = client.patch(
        f"/ocpi/{version}/locations/LOC-1/EVSE-1",
        json={"status": "BLOCKED", "connectors": [{"id": "1"}]},
        headers={"Authorization": "Token test-token"},
    )
    body = response.get_json()

    location_response = client.get(
        f"/ocpi/{version}/locations/LOC-1",
        headers={"Authorization": "Token test-token"},
    )
    location_body = location_response.get_json()
    evse = location_body["data"]["evses"][0]

    assert response.status_code == 200
    assert body["data"]["uid"] == "EVSE-1"
    assert body["data"]["status"] == "BLOCKED"
    assert evse["status"] == "BLOCKED"
    assert evse["connectors"][0]["id"] == "1"


def test_locations_patch_requires_location_id(ocpi_client):
    version, client = ocpi_client

    response = client.patch(
        f"/ocpi/{version}/locations/",
        json={},
        headers={"Authorization": "Token test-token"},
    )
    body = response.get_json()

    assert response.status_code == 400
    assert body["error_code"] == "MISSING_FIELD"
    assert body["status_code"] == 2001


def test_locations_patch_evse_requires_body(ocpi_client):
    version, client = ocpi_client

    response = client.patch(
        f"/ocpi/{version}/locations/LOC-1/EVSE-1",
        data="",
        headers={"Authorization": "Token test-token"},
    )
    body = response.get_json()

    assert response.status_code == 400
    assert body["error_code"] == "FORMAT_ERROR"
    assert body["status_code"] == 2001


def test_tokens_put_and_get(ocpi_client):
    version, client = ocpi_client
    payload = {
        "uid": "ABC-123",
        "type": "app_user",
        "auth_id": "A-1",
        "issuer": "TestIssuer",
        "whitelist": "ALLOWED",
        "contract_id": "C-1",
        "last_updated": "2024-01-01T00:00:00Z",
    }

    put_resp = client.put(
        f"/ocpi/{version}/tokens/{payload['uid']}",
        json=payload,
        headers={"Authorization": "Token test-token"},
    )
    put_body = put_resp.get_json()
    assert put_resp.status_code == 200
    assert put_body["status_code"] == 1000
    assert put_body["data"]["type"] == "APP_USER"
    assert put_body["data"]["contract_id"] == "C-1"

    get_resp = client.get(
        f"/ocpi/{version}/tokens/{payload['uid']}",
        headers={"Authorization": "Token test-token"},
    )
    get_body = get_resp.get_json()
    assert get_resp.status_code == 200
    assert get_body["data"]["uid"] == payload["uid"]
    assert get_body["data"]["whitelist"] == "ALLOWED"


def test_token_authorize_flow(ocpi_client):
    version, client = ocpi_client
    payload = {
        "uid": "DEF-456",
        "type": "RFID",
        "auth_id": "D-1",
        "issuer": "Pipelet",
        "whitelist": "ALLOWED",
        "last_updated": "2024-01-01T00:00:00Z",
    }
    client.put(
        f"/ocpi/{version}/tokens/{payload['uid']}",
        json=payload,
        headers={"Authorization": "Token test-token"},
    )

    auth_resp = client.post(
        f"/ocpi/{version}/tokens/{payload['uid']}/authorize",
        json={"location_id": "LOC-1"},
        headers={"Authorization": "Token test-token"},
    )
    auth_body = auth_resp.get_json()
    assert auth_resp.status_code == 200
    assert auth_body["status_code"] == 1000
    assert auth_body["data"]["allowed"] == "ALLOWED"
    assert auth_body["data"]["token"]["uid"] == payload["uid"]


def test_token_authorize_msp_success(monkeypatch, ocpi_app):
    client = ocpi_app.app.test_client()
    version = "2.2"
    original_registry = ocpi_app.backend_registry
    ocpi_app.backend_registry = BackendRegistry(
        [
            BackendProfile(
                backend_id="msp-backend",
                token="peer-token",
                url="http://msp.example",
                modules={"tokens"},
            )
        ],
        fallback_token="peer-token",
    )
    calls: dict[str, object] = {}

    class _Response:
        status_code = 200
        text = "ok"

        def json(self):
            return {
                "data": {
                    "allowed": "ALLOWED",
                    "token": {
                        "uid": "MSP-1",
                        "type": "RFID",
                        "auth_id": "MSP-1",
                        "issuer": "MSP",
                    },
                }
            }

    def _fake_post(url, json=None, headers=None, timeout=None):
        calls["url"] = url
        calls["json"] = json
        calls["headers"] = headers
        return _Response()

    try:
        monkeypatch.setattr(ocpi_app.requests, "post", _fake_post)

        resp = client.post(
            f"/ocpi/{version}/tokens/MSP-1/authorize",
            json={"location_id": "LOC-1"},
            headers={"Authorization": "Token peer-token"},
        )
    finally:
        ocpi_app.backend_registry = original_registry

    body = resp.get_json()
    assert resp.status_code == 200
    assert body["data"]["allowed"] == "ALLOWED"
    assert calls["url"].endswith(f"/ocpi/{version}/tokens/MSP-1/authorize")
    assert calls["headers"]["Authorization"] == "Token peer-token"


def test_token_authorize_msp_rejected(monkeypatch, ocpi_app):
    client = ocpi_app.app.test_client()
    version = "2.2"
    original_registry = ocpi_app.backend_registry
    ocpi_app.backend_registry = BackendRegistry(
        [
            BackendProfile(
                backend_id="msp-backend",
                token="peer-token",
                url="http://msp.example",
                modules={"tokens"},
            )
        ],
        fallback_token="peer-token",
    )

    class _Response:
        status_code = 200
        text = "denied"

        def json(self):
            return {
                "data": {
                    "allowed": "REJECTED",
                    "token": {
                        "uid": "MSP-2",
                        "type": "APP_USER",
                        "auth_id": "MSP-2",
                        "issuer": "MSP",
                    },
                }
            }

    monkeypatch.setattr(ocpi_app.requests, "post", lambda *_, **__: _Response())

    try:
        resp = client.post(
            f"/ocpi/{version}/tokens/MSP-2/authorize",
            json={"location_id": "LOC-2"},
            headers={"Authorization": "Token peer-token"},
        )
    finally:
        ocpi_app.backend_registry = original_registry

    body = resp.get_json()
    assert resp.status_code == 200
    assert body["data"]["allowed"] == "REJECTED"
    assert body["data"]["token"]["uid"] == "MSP-2"


def test_token_authorize_msp_timeout(monkeypatch, ocpi_app):
    client = ocpi_app.app.test_client()
    version = "2.2"
    original_registry = ocpi_app.backend_registry
    ocpi_app.backend_registry = BackendRegistry(
        [
            BackendProfile(
                backend_id="msp-backend",
                token="peer-token",
                url="http://msp.example",
                modules={"tokens"},
            )
        ],
        fallback_token="peer-token",
    )

    def _timeout(*_, **__):
        raise ocpi_app.requests.Timeout("timeout")

    monkeypatch.setattr(ocpi_app.requests, "post", _timeout)

    try:
        resp = client.post(
            f"/ocpi/{version}/tokens/MSP-3/authorize",
            json={"location_id": "LOC-3"},
            headers={"Authorization": "Token peer-token"},
        )
    finally:
        ocpi_app.backend_registry = original_registry

    body = resp.get_json()
    assert resp.status_code == 504
    assert body["error_code"] == "TIMEOUT"
    assert body["status_code"] == 2001


def test_start_session_rejected_without_authorization(monkeypatch, ocpi_app):
    client = ocpi_app.app.test_client()
    version = "2.2"
    original_registry = ocpi_app.backend_registry
    ocpi_app.backend_registry = BackendRegistry(
        [
            BackendProfile(
                backend_id="msp-backend",
                token="peer-token",
                url="http://msp.example",
                modules={"tokens", "commands"},
            )
        ],
        fallback_token="peer-token",
    )
    called_remote_start = False

    class _DeniedResponse:
        status_code = 200
        text = "denied"

        def json(self):
            return {"data": {"allowed": "REJECTED", "token": {"uid": "XYZ"}}}

    def _fake_remote_start(_payload):
        nonlocal called_remote_start
        called_remote_start = True
        return {"status": "ACCEPTED"}

    monkeypatch.setattr(ocpi_app.requests, "post", lambda *_, **__: _DeniedResponse())
    monkeypatch.setattr(ocpi_app.ocpp_client, "remote_start", _fake_remote_start)

    try:
        resp = client.post(
            f"/ocpi/{version}/commands/START_SESSION",
            json={"token": "XYZ", "location_id": "LOC-9"},
            headers={"Authorization": "Token peer-token"},
        )
    finally:
        ocpi_app.backend_registry = original_registry

    body = resp.get_json()
    assert resp.status_code == 200
    assert body["data"]["result"] == "REJECTED"
    assert called_remote_start is False


def test_not_supported_module_returns_ocpi_error(ocpi_client):
    version, client = ocpi_client
    if version != "2.1.1":
        pytest.skip("Route intentionally missing only for 2.1.1")

    resp = client.get(
        "/ocpi/2.1.1/chargingprofiles",
        headers={"Authorization": "Token test-token"},
    )
    body = resp.get_json()
    assert resp.status_code == 404
    assert body["error_code"] == "NOT_SUPPORTED"
    assert body["status_code"] == 2001


def test_credentials_delete_revokes_peer_and_logs_handshake(ocpi_app):
    client = ocpi_app.app.test_client()
    ocpi_app.backend_repo.peer_token = "peer-token"
    ocpi_app.backend_repo.peer_versions_url = "http://peer.example/versions"
    ocpi_app.backend_repo.remote_versions_url = "http://peer.example/versions"
    ocpi_app.backend_repo.active_version = "2.2"
    ocpi_app.backend_repo.update_calls.clear()
    ocpi_app.handshake_repo.records.clear()

    response = client.delete("/ocpi/2.2/credentials", headers={"Authorization": "Token test-token"})

    body = response.get_json()
    assert response.status_code == 200
    assert body["status_code"] == 1000
    assert ocpi_app.backend_repo.peer_token is None
    assert ocpi_app.backend_repo.peer_versions_url is None
    assert ocpi_app.backend_repo.remote_versions_url is None
    assert ocpi_app.backend_repo.active_version is None
    assert ocpi_app.backend_repo.update_calls[-1]["kwargs"]["clear_missing"] is True
    assert ocpi_app.handshake_repo.records[-1]["state"] == "open"
    assert ocpi_app.handshake_repo.records[-1]["status"] == "credentials revoked"
    assert ocpi_app.handshake_repo.records[-1]["detail"] == "version=2.2"


def test_credentials_delete_returns_not_found_for_missing_backend(ocpi_app):
    client = ocpi_app.app.test_client()
    ocpi_app.backend_repo.missing_backends.add("missing")
    existing_update_calls = len(ocpi_app.backend_repo.update_calls)
    existing_handshakes = len(ocpi_app.handshake_repo.records)

    response = client.delete("/b/missing/ocpi/2.2/credentials", headers={"Authorization": "Token test-token"})

    body = response.get_json()
    assert response.status_code == 404
    assert body["status_code"] == 2001
    assert body["error_code"] == "NOT_FOUND"
    assert len(ocpi_app.backend_repo.update_calls) == existing_update_calls
    assert len(ocpi_app.handshake_repo.records) == existing_handshakes
    ocpi_app.backend_repo.missing_backends.discard("missing")
