import json
import logging
import re
import secrets
import time
from contextlib import nullcontext as does_not_raise

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,
    KasaException,
    TimeoutError,
    _ConnectionError,
    _RetryableError,
)
from kasa.httpclient import HttpClient
from kasa.protocols import IotProtocol, SmartProtocol
from kasa.transports.aestransport import AesTransport
from kasa.transports.klaptransport import (
    KlapEncryptionSession,
    KlapTransport,
    KlapTransportV2,
    _sha256,
)

DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}}

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


class _mock_response:
    def __init__(self, status, content: bytes):
        self.status = status
        self.content = content

    async def __aenter__(self):
        return self

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

    async def read(self):
        return self.content


@pytest.mark.parametrize(
    ("error", "retry_expectation"),
    [
        (Exception("dummy exception"), False),
        (aiohttp.ServerTimeoutError("dummy exception"), True),
        (aiohttp.ServerDisconnectedError("dummy exception"), True),
        (aiohttp.ClientOSError("dummy exception"), True),
    ],
    ids=("Exception", "ServerTimeoutError", "ServerDisconnectedError", "ClientOSError"),
)
@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport])
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
@pytest.mark.parametrize("retry_count", [1, 3, 5])
async def test_protocol_retries_via_client_session(
    mocker, retry_count, protocol_class, transport_class, error, retry_expectation
):
    host = "127.0.0.1"
    conn = mocker.patch.object(aiohttp.ClientSession, "post", side_effect=error)

    config = DeviceConfig(host)
    with pytest.raises(KasaException):
        await protocol_class(transport=transport_class(config=config)).query(
            DUMMY_QUERY, retry_count=retry_count
        )

    expected_count = retry_count + 1 if retry_expectation else 1
    assert conn.call_count == expected_count


@pytest.mark.parametrize(
    ("error", "retry_expectation"),
    [
        (KasaException("dummy exception"), False),
        (_RetryableError("dummy exception"), True),
        (TimeoutError("dummy exception"), True),
    ],
    ids=("KasaException", "_RetryableError", "TimeoutError"),
)
@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport])
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
@pytest.mark.parametrize("retry_count", [1, 3, 5])
async def test_protocol_retries_via_httpclient(
    mocker, retry_count, protocol_class, transport_class, error, retry_expectation
):
    host = "127.0.0.1"
    conn = mocker.patch.object(HttpClient, "post", side_effect=error)
    mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0)

    config = DeviceConfig(host)
    with pytest.raises(KasaException):
        await protocol_class(transport=transport_class(config=config)).query(
            DUMMY_QUERY, retry_count=retry_count
        )

    expected_count = retry_count + 1 if retry_expectation else 1
    assert conn.call_count == expected_count


@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport])
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
async def test_protocol_no_retry_on_connection_error(
    mocker, protocol_class, transport_class
):
    host = "127.0.0.1"
    conn = mocker.patch.object(
        aiohttp.ClientSession,
        "post",
        side_effect=AuthenticationError("foo"),
    )
    mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0)
    config = DeviceConfig(host)
    with pytest.raises(KasaException):
        await protocol_class(transport=transport_class(config=config)).query(
            DUMMY_QUERY, retry_count=5
        )

    assert conn.call_count == 1


@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport])
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
async def test_protocol_retry_recoverable_error(
    mocker, protocol_class, transport_class
):
    host = "127.0.0.1"
    conn = mocker.patch.object(
        aiohttp.ClientSession,
        "post",
        side_effect=aiohttp.ClientOSError("foo"),
    )

    config = DeviceConfig(host)
    with pytest.raises(KasaException):
        await protocol_class(transport=transport_class(config=config)).query(
            DUMMY_QUERY, retry_count=5
        )

    assert conn.call_count == 6


@pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport])
@pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol])
@pytest.mark.parametrize("retry_count", [1, 3, 5])
async def test_protocol_reconnect(mocker, retry_count, protocol_class, transport_class):
    host = "127.0.0.1"
    remaining = retry_count
    mock_response = {"result": {"great": "success"}, "error_code": 0}

    def _fail_one_less_than_retry_count(*_, **__):
        nonlocal remaining
        remaining -= 1
        if remaining:
            raise _ConnectionError("Simulated connection failure")

        return mock_response

    mocker.patch.object(transport_class, "perform_handshake")
    if hasattr(transport_class, "perform_login"):
        mocker.patch.object(transport_class, "perform_login")

    send_mock = mocker.patch.object(
        transport_class,
        "send",
        side_effect=_fail_one_less_than_retry_count,
    )

    config = DeviceConfig(host)
    response = await protocol_class(transport=transport_class(config=config)).query(
        DUMMY_QUERY, retry_count=retry_count
    )
    assert "result" in response or "foobar" in response
    assert send_mock.call_count == retry_count


@pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG])
@pytest.mark.xdist_group(name="caplog")
async def test_protocol_logging(mocker, caplog, log_level):
    caplog.set_level(log_level)
    logging.getLogger("kasa").setLevel(log_level)

    def _return_encrypted(*_, **__):
        nonlocal encryption_session
        # Do the encrypt just before returning the value so the incrementing sequence number is correct
        encrypted, seq = encryption_session.encrypt('{"great":"success"}')
        return 200, encrypted

    seed = secrets.token_bytes(16)
    auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar"))
    encryption_session = KlapEncryptionSession(seed, seed, auth_hash)

    config = DeviceConfig("127.0.0.1")
    protocol = IotProtocol(transport=KlapTransport(config=config))

    protocol._transport._handshake_done = True
    protocol._transport._session_expire_at = time.time() + 86400
    protocol._transport._encryption_session = encryption_session
    mocker.patch.object(HttpClient, "post", side_effect=_return_encrypted)

    response = await protocol.query({})
    assert response == {"great": "success"}
    if log_level == logging.DEBUG:
        assert "success" in caplog.text
    else:
        assert "success" not in caplog.text


def test_encrypt():
    d = json.dumps({"foo": 1, "bar": 2})

    seed = secrets.token_bytes(16)
    auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar"))
    encryption_session = KlapEncryptionSession(seed, seed, auth_hash)

    encrypted, seq = encryption_session.encrypt(d)

    assert d == encryption_session.decrypt(encrypted)


def test_encrypt_unicode():
    d = "{'snowman': '\u2603'}"

    seed = secrets.token_bytes(16)
    auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar"))
    encryption_session = KlapEncryptionSession(seed, seed, auth_hash)

    encrypted, seq = encryption_session.encrypt(d)

    decrypted = encryption_session.decrypt(encrypted)

    assert d == decrypted


async def test_transport_decrypt(mocker):
    """Test transport decryption."""
    d = {"great": "success"}

    seed = secrets.token_bytes(16)
    auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar"))
    encryption_session = KlapEncryptionSession(seed, seed, auth_hash)

    transport = KlapTransport(config=DeviceConfig(host="127.0.0.1"))
    transport._handshake_done = True
    transport._session_expire_at = time.monotonic() + 60
    transport._encryption_session = encryption_session

    async def _return_response(url: URL, params=None, data=None, *_, **__):
        encryption_session = KlapEncryptionSession(
            transport._encryption_session.local_seed,
            transport._encryption_session.remote_seed,
            transport._encryption_session.user_hash,
        )
        seq = params.get("seq")
        encryption_session._seq = seq - 1
        encrypted, seq = encryption_session.encrypt(json.dumps(d))
        seq = seq
        return 200, encrypted

    mocker.patch.object(HttpClient, "post", side_effect=_return_response)

    resp = await transport.send(json.dumps({}))
    assert d == resp


async def test_transport_decrypt_error(mocker, caplog):
    """Test that a decryption error raises a kasa exception."""
    d = {"great": "success"}

    seed = secrets.token_bytes(16)
    auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar"))
    encryption_session = KlapEncryptionSession(seed, seed, auth_hash)

    transport = KlapTransport(config=DeviceConfig(host="127.0.0.1"))
    transport._handshake_done = True
    transport._session_expire_at = time.monotonic() + 60
    transport._encryption_session = encryption_session

    async def _return_response(url: URL, params=None, data=None, *_, **__):
        encryption_session = KlapEncryptionSession(
            secrets.token_bytes(16),
            transport._encryption_session.remote_seed,
            transport._encryption_session.user_hash,
        )
        seq = params.get("seq")
        encryption_session._seq = seq - 1
        encrypted, seq = encryption_session.encrypt(json.dumps(d))
        seq = seq
        return 200, encrypted

    mocker.patch.object(HttpClient, "post", side_effect=_return_response)

    with pytest.raises(
        KasaException,
        match=re.escape("Error trying to decrypt device 127.0.0.1 response:"),
    ):
        await transport.send(json.dumps({}))


@pytest.mark.parametrize(
    ("device_credentials", "expectation"),
    [
        (Credentials("foo", "bar"), does_not_raise()),
        (Credentials(), does_not_raise()),
        (
            get_default_credentials(DEFAULT_CREDENTIALS["KASA"]),
            does_not_raise(),
        ),
        (
            Credentials("shouldfail", "shouldfail"),
            pytest.raises(AuthenticationError),
        ),
    ],
    ids=("client", "blank", "kasa_setup", "shouldfail"),
)
@pytest.mark.parametrize(
    ("transport_class", "seed_auth_hash_calc"),
    [
        pytest.param(KlapTransport, lambda c, s, a: c + a, id="KLAP"),
        pytest.param(KlapTransportV2, lambda c, s, a: c + s + a, id="KLAPV2"),
    ],
)
async def test_handshake1(
    mocker, device_credentials, expectation, transport_class, seed_auth_hash_calc
):
    async def _return_handshake1_response(url, params=None, data=None, *_, **__):
        nonlocal client_seed, server_seed, device_auth_hash

        client_seed = data
        seed_auth_hash = _sha256(
            seed_auth_hash_calc(client_seed, server_seed, device_auth_hash)
        )
        return _mock_response(200, server_seed + seed_auth_hash)

    client_seed = None
    server_seed = secrets.token_bytes(16)
    client_credentials = Credentials("foo", "bar")
    device_auth_hash = transport_class.generate_auth_hash(device_credentials)

    mocker.patch.object(
        aiohttp.ClientSession, "post", side_effect=_return_handshake1_response
    )

    config = DeviceConfig("127.0.0.1", credentials=client_credentials)
    protocol = IotProtocol(transport=transport_class(config=config))

    with expectation:
        (
            local_seed,
            device_remote_seed,
            auth_hash,
        ) = await protocol._transport.perform_handshake1()

        assert local_seed == client_seed
        assert device_remote_seed == server_seed
        assert device_auth_hash == auth_hash
    await protocol.close()


@pytest.mark.parametrize(
    ("transport_class", "seed_auth_hash_calc1", "seed_auth_hash_calc2"),
    [
        pytest.param(
            KlapTransport, lambda c, s, a: c + a, lambda c, s, a: s + a, id="KLAP"
        ),
        pytest.param(
            KlapTransportV2,
            lambda c, s, a: c + s + a,
            lambda c, s, a: s + c + a,
            id="KLAPV2",
        ),
    ],
)
async def test_handshake(
    mocker, transport_class, seed_auth_hash_calc1, seed_auth_hash_calc2
):
    client_seed = None
    server_seed = secrets.token_bytes(16)
    client_credentials = Credentials("foo", "bar")
    device_auth_hash = transport_class.generate_auth_hash(client_credentials)

    async def _return_handshake_response(url: URL, params=None, data=None, *_, **__):
        nonlocal client_seed, server_seed, device_auth_hash

        if url == URL("http://127.0.0.1:80/app/handshake1"):
            client_seed = data
            seed_auth_hash = _sha256(
                seed_auth_hash_calc1(client_seed, server_seed, device_auth_hash)
            )

            return _mock_response(200, server_seed + seed_auth_hash)
        elif url == URL("http://127.0.0.1:80/app/handshake2"):
            seed_auth_hash = _sha256(
                seed_auth_hash_calc2(client_seed, server_seed, device_auth_hash)
            )
            assert data == seed_auth_hash
            return _mock_response(response_status, b"")

    mocker.patch.object(
        aiohttp.ClientSession, "post", side_effect=_return_handshake_response
    )

    config = DeviceConfig("127.0.0.1", credentials=client_credentials)
    protocol = IotProtocol(transport=transport_class(config=config))
    protocol._transport.http_client = aiohttp.ClientSession()

    response_status = 200
    await protocol._transport.perform_handshake()
    assert protocol._transport._handshake_done is True

    response_status = 403
    with pytest.raises(KasaException):
        await protocol._transport.perform_handshake()
    assert protocol._transport._handshake_done is False
    await protocol.close()


async def test_query(mocker):
    client_seed = None
    last_seq = None
    seq = None
    server_seed = secrets.token_bytes(16)
    client_credentials = Credentials("foo", "bar")
    device_auth_hash = KlapTransport.generate_auth_hash(client_credentials)

    async def _return_response(url: URL, params=None, data=None, *_, **__):
        nonlocal client_seed, server_seed, device_auth_hash, seq

        if url == URL("http://127.0.0.1:80/app/handshake1"):
            client_seed = data
            client_seed_auth_hash = _sha256(data + device_auth_hash)

            return _mock_response(200, server_seed + client_seed_auth_hash)
        elif url == URL("http://127.0.0.1:80/app/handshake2"):
            return _mock_response(200, b"")
        elif url == URL("http://127.0.0.1:80/app/request"):
            encryption_session = KlapEncryptionSession(
                protocol._transport._encryption_session.local_seed,
                protocol._transport._encryption_session.remote_seed,
                protocol._transport._encryption_session.user_hash,
            )
            seq = params.get("seq")
            encryption_session._seq = seq - 1
            encrypted, seq = encryption_session.encrypt('{"great": "success"}')
            seq = seq
            return _mock_response(200, encrypted)

    mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response)

    config = DeviceConfig("127.0.0.1", credentials=client_credentials)
    protocol = IotProtocol(transport=KlapTransport(config=config))

    for _ in range(10):
        resp = await protocol.query({})
        assert resp == {"great": "success"}
        # Check the protocol is incrementing the sequence number
        assert last_seq is None or last_seq + 1 == seq
        last_seq = seq


@pytest.mark.parametrize(
    ("response_status", "credentials_match", "expectation"),
    [
        pytest.param(
            (403, 403, 403),
            True,
            pytest.raises(KasaException),
            id="handshake1-403-status",
        ),
        pytest.param(
            (200, 403, 403),
            True,
            pytest.raises(KasaException),
            id="handshake2-403-status",
        ),
        pytest.param(
            (200, 200, 403),
            True,
            pytest.raises(_RetryableError),
            id="request-403-status",
        ),
        pytest.param(
            (200, 200, 400),
            True,
            pytest.raises(KasaException),
            id="request-400-status",
        ),
        pytest.param(
            (200, 200, 200),
            False,
            pytest.raises(AuthenticationError),
            id="handshake1-wrong-auth",
        ),
        pytest.param(
            (200, 200, 200),
            secrets.token_bytes(16),
            pytest.raises(KasaException),
            id="handshake1-bad-auth-length",
        ),
    ],
)
async def test_authentication_failures(
    mocker, response_status, credentials_match, expectation
):
    client_seed = None

    server_seed = secrets.token_bytes(16)
    client_credentials = Credentials("foo", "bar")
    device_credentials = (
        client_credentials if credentials_match else Credentials("bar", "foo")
    )
    device_auth_hash = KlapTransport.generate_auth_hash(device_credentials)

    async def _return_response(url: URL, params=None, data=None, *_, **__):
        nonlocal \
            client_seed, \
            server_seed, \
            device_auth_hash, \
            response_status, \
            credentials_match

        if url == URL("http://127.0.0.1:80/app/handshake1"):
            client_seed = data
            client_seed_auth_hash = _sha256(data + device_auth_hash)
            if credentials_match is not False and credentials_match is not True:
                client_seed_auth_hash += credentials_match
            return _mock_response(
                response_status[0], server_seed + client_seed_auth_hash
            )
        elif url == URL("http://127.0.0.1:80/app/handshake2"):
            client_seed = data
            client_seed_auth_hash = _sha256(data + device_auth_hash)
            return _mock_response(
                response_status[1], server_seed + client_seed_auth_hash
            )
        elif url == URL("http://127.0.0.1:80/app/request"):
            return _mock_response(response_status[2], b"")

    mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response)

    config = DeviceConfig("127.0.0.1", credentials=client_credentials)
    protocol = IotProtocol(transport=KlapTransport(config=config))

    with expectation:
        await protocol.query({})


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

    assert str(transport._app_url) == "http://127.0.0.1:12345/app"
