from __future__ import annotations

import logging
from base64 import b64encode
from contextlib import nullcontext as does_not_raise
from typing import Any

import aiohttp
import pytest
from yarl import URL

from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
from kasa.deviceconfig import DeviceConfig
from kasa.exceptions import (
    AuthenticationError,
    DeviceError,
    KasaException,
    SmartErrorCode,
    _RetryableError,
)
from kasa.httpclient import HttpClient
from kasa.json import dumps as json_dumps
from kasa.json import loads as json_loads
from kasa.transports import SslTransport
from kasa.transports.ssltransport import TransportState, _md5_hash

# Transport tests are not designed for real devices
pytestmark = [pytest.mark.requires_dummy]

MOCK_PWD = "correct_pwd"  # noqa: S105
MOCK_USER = "mock@example.com"
MOCK_BAD_USER_OR_PWD = "foobar"  # noqa: S105
MOCK_TOKEN = "abcdefghijklmnopqrstuvwxyz1234)("  # noqa: S105

DEFAULT_CREDS = get_default_credentials(DEFAULT_CREDENTIALS["TAPO"])


_LOGGER = logging.getLogger(__name__)


@pytest.mark.parametrize(
    (
        "status_code",
        "error_code",
        "username",
        "password",
        "expectation",
    ),
    [
        pytest.param(
            200,
            SmartErrorCode.SUCCESS,
            MOCK_USER,
            MOCK_PWD,
            does_not_raise(),
            id="success",
        ),
        pytest.param(
            200,
            SmartErrorCode.UNSPECIFIC_ERROR,
            MOCK_USER,
            MOCK_PWD,
            pytest.raises(_RetryableError),
            id="test retry",
        ),
        pytest.param(
            200,
            SmartErrorCode.DEVICE_BLOCKED,
            MOCK_USER,
            MOCK_PWD,
            pytest.raises(DeviceError),
            id="test regular error",
        ),
        pytest.param(
            400,
            SmartErrorCode.INTERNAL_UNKNOWN_ERROR,
            MOCK_USER,
            MOCK_PWD,
            pytest.raises(KasaException),
            id="400 error",
        ),
        pytest.param(
            200,
            SmartErrorCode.LOGIN_ERROR,
            MOCK_BAD_USER_OR_PWD,
            MOCK_PWD,
            pytest.raises(AuthenticationError),
            id="bad-username",
        ),
        pytest.param(
            200,
            [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.SUCCESS],
            MOCK_BAD_USER_OR_PWD,
            "",
            does_not_raise(),
            id="working-fallback",
        ),
        pytest.param(
            200,
            [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.LOGIN_ERROR],
            MOCK_BAD_USER_OR_PWD,
            "",
            pytest.raises(AuthenticationError),
            id="fallback-fail",
        ),
        pytest.param(
            200,
            SmartErrorCode.LOGIN_ERROR,
            MOCK_USER,
            MOCK_BAD_USER_OR_PWD,
            pytest.raises(AuthenticationError),
            id="bad-password",
        ),
        pytest.param(
            200,
            SmartErrorCode.TRANSPORT_UNKNOWN_CREDENTIALS_ERROR,
            MOCK_USER,
            MOCK_PWD,
            pytest.raises(AuthenticationError),
            id="auth-error != login_error",
        ),
    ],
)
async def test_login(
    mocker,
    status_code,
    error_code,
    username,
    password,
    expectation,
):
    host = "127.0.0.1"
    mock_ssl_aes_device = MockSslDevice(
        host,
        status_code=status_code,
        send_error_code=error_code,
    )
    mocker.patch.object(
        aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
    )

    transport = SslTransport(
        config=DeviceConfig(host, credentials=Credentials(username, password))
    )

    assert transport._state is TransportState.LOGIN_REQUIRED
    with expectation:
        await transport.perform_login()
        assert transport._state is TransportState.ESTABLISHED

    await transport.close()


async def test_credentials_hash(mocker):
    host = "127.0.0.1"
    mock_ssl_aes_device = MockSslDevice(host)
    mocker.patch.object(
        aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
    )
    creds = Credentials(MOCK_USER, MOCK_PWD)

    data = {"password": _md5_hash(MOCK_PWD.encode()), "username": MOCK_USER}

    creds_hash = b64encode(json_dumps(data).encode()).decode()

    # Test with credentials input
    transport = SslTransport(config=DeviceConfig(host, credentials=creds))
    assert transport.credentials_hash == creds_hash

    # Test with credentials_hash input
    transport = SslTransport(config=DeviceConfig(host, credentials_hash=creds_hash))
    assert transport.credentials_hash == creds_hash

    await transport.close()


async def test_send(mocker):
    host = "127.0.0.1"
    mock_ssl_aes_device = MockSslDevice(host, send_error_code=SmartErrorCode.SUCCESS)
    mocker.patch.object(
        aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
    )

    transport = SslTransport(
        config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD))
    )
    try_login_spy = mocker.spy(transport, "try_login")
    request = {
        "method": "get_device_info",
        "params": None,
    }
    assert transport._state is TransportState.LOGIN_REQUIRED

    res = await transport.send(json_dumps(request))
    assert "result" in res
    try_login_spy.assert_called_once()
    assert transport._state is TransportState.ESTABLISHED

    # Second request does not
    res = await transport.send(json_dumps(request))
    try_login_spy.assert_called_once()

    await transport.close()


async def test_no_credentials(mocker):
    """Test transport without credentials."""
    host = "127.0.0.1"
    mock_ssl_aes_device = MockSslDevice(
        host, send_error_code=SmartErrorCode.LOGIN_ERROR
    )
    mocker.patch.object(
        aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
    )

    transport = SslTransport(config=DeviceConfig(host))
    try_login_spy = mocker.spy(transport, "try_login")

    with pytest.raises(AuthenticationError):
        await transport.send('{"method": "dummy"}')

    # We get called twice
    assert try_login_spy.call_count == 2

    await transport.close()


async def test_reset(mocker):
    """Test that transport state adjusts correctly for reset."""
    host = "127.0.0.1"
    mock_ssl_aes_device = MockSslDevice(host, send_error_code=SmartErrorCode.SUCCESS)
    mocker.patch.object(
        aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
    )

    transport = SslTransport(
        config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD))
    )

    assert transport._state is TransportState.LOGIN_REQUIRED
    assert str(transport._app_url) == "https://127.0.0.1:4433/app"

    await transport.perform_login()
    assert transport._state is TransportState.ESTABLISHED
    assert str(transport._app_url).startswith("https://127.0.0.1:4433/app?token=")

    await transport.close()
    assert transport._state is TransportState.LOGIN_REQUIRED
    assert str(transport._app_url) == "https://127.0.0.1:4433/app"


async def test_port_override():
    """Test that port override sets the app_url."""
    host = "127.0.0.1"
    port_override = 12345
    config = DeviceConfig(
        host, credentials=Credentials("foo", "bar"), port_override=port_override
    )
    transport = SslTransport(config=config)

    assert str(transport._app_url) == f"https://127.0.0.1:{port_override}/app"

    await transport.close()


class MockSslDevice:
    """Based on MockAesSslDevice."""

    class _mock_response:
        def __init__(self, status, request: dict):
            self.status = status
            self._json = request

        async def __aenter__(self):
            return self

        async def __aexit__(self, exc_t, exc_v, exc_tb):
            pass

        async def read(self):
            if isinstance(self._json, dict):
                return json_dumps(self._json).encode()
            return self._json

    def __init__(
        self,
        host,
        *,
        status_code=200,
        send_error_code=SmartErrorCode.INTERNAL_UNKNOWN_ERROR,
    ):
        self.host = host
        self.http_client = HttpClient(DeviceConfig(self.host))

        self._state = TransportState.LOGIN_REQUIRED

        # test behaviour attributes
        self.status_code = status_code
        self.send_error_code = send_error_code

    async def post(self, url: URL, params=None, json=None, data=None, *_, **__):
        if data:
            json = json_loads(data)
        _LOGGER.debug("Request %s: %s", url, json)
        res = self._post(url, json)
        _LOGGER.debug("Response %s, data: %s", res, await res.read())
        return res

    def _post(self, url: URL, json: dict[str, Any]):
        method = json["method"]

        if method == "login":
            if self._state is TransportState.LOGIN_REQUIRED:
                assert json.get("token") is None
                assert url == URL(f"https://{self.host}:4433/app")
                return self._return_login_response(url, json)
            else:
                _LOGGER.warning("Received login although already logged in")
                pytest.fail("non-handled re-login logic")

        assert url == URL(f"https://{self.host}:4433/app?token={MOCK_TOKEN}")
        return self._return_send_response(url, json)

    def _return_login_response(self, url: URL, request: dict[str, Any]):
        request_username = request["params"].get("username")
        request_password = request["params"].get("password")

        # Handle multiple error codes
        if isinstance(self.send_error_code, list):
            error_code = self.send_error_code.pop(0)
        else:
            error_code = self.send_error_code

        _LOGGER.debug("Using error code %s", error_code)

        def _return_login_error():
            resp = {
                "error_code": error_code.value,
                "result": {"unknown": "payload"},
            }

            _LOGGER.debug("Returning login error with status %s", self.status_code)
            return self._mock_response(self.status_code, resp)

        if error_code is not SmartErrorCode.SUCCESS:
            # Bad username
            if request_username == MOCK_BAD_USER_OR_PWD:
                return _return_login_error()

            # Bad password
            if request_password == _md5_hash(MOCK_BAD_USER_OR_PWD.encode()):
                return _return_login_error()

            # Empty password
            if request_password == _md5_hash(b""):
                return _return_login_error()

        self._state = TransportState.ESTABLISHED
        resp = {
            "error_code": error_code.value,
            "result": {
                "token": MOCK_TOKEN,
            },
        }
        _LOGGER.debug("Returning login success with status %s", self.status_code)
        return self._mock_response(self.status_code, resp)

    def _return_send_response(self, url: URL, json: dict[str, Any]):
        method = json["method"]
        result = {
            "result": {method: {"dummy": "response"}},
            "error_code": self.send_error_code.value,
        }
        return self._mock_response(self.status_code, result)
