"""The tests for generic camera component."""

import asyncio
from datetime import timedelta
from http import HTTPStatus
from typing import Any
from unittest.mock import AsyncMock, Mock, patch

import aiohttp
from freezegun.api import FrozenDateTimeFactory
import httpx
import pytest
import respx

from homeassistant.components.camera import (
    DEFAULT_CONTENT_TYPE,
    async_get_mjpeg_stream,
    async_get_stream_source,
)
from homeassistant.components.generic.const import (
    CONF_CONTENT_TYPE,
    CONF_FRAMERATE,
    CONF_LIMIT_REFETCH_TO_URL_CHANGE,
    CONF_STILL_IMAGE_URL,
    CONF_STREAM_SOURCE,
    DOMAIN,
)
from homeassistant.components.stream import CONF_RTSP_TRANSPORT
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.const import (
    CONF_AUTHENTICATION,
    CONF_NAME,
    CONF_PASSWORD,
    CONF_USERNAME,
    CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component

from tests.common import MockConfigEntry
from tests.typing import ClientSessionGenerator, WebSocketGenerator


async def help_setup_mock_config_entry(
    hass: HomeAssistant, options: dict[str, Any], unique_id: Any | None = None
) -> MockConfigEntry:
    """Help setting up a generic camera config entry."""
    entry_options = {
        CONF_STILL_IMAGE_URL: options.get(CONF_STILL_IMAGE_URL),
        CONF_STREAM_SOURCE: options.get(CONF_STREAM_SOURCE),
        CONF_AUTHENTICATION: options.get(CONF_AUTHENTICATION),
        CONF_USERNAME: options.get(CONF_USERNAME),
        CONF_PASSWORD: options.get(CONF_PASSWORD),
        CONF_LIMIT_REFETCH_TO_URL_CHANGE: options.get(
            CONF_LIMIT_REFETCH_TO_URL_CHANGE, False
        ),
        CONF_CONTENT_TYPE: options.get(CONF_CONTENT_TYPE, DEFAULT_CONTENT_TYPE),
        CONF_FRAMERATE: options.get(CONF_FRAMERATE, 2),
        CONF_VERIFY_SSL: options.get(CONF_VERIFY_SSL),
    }
    entry = MockConfigEntry(
        domain=DOMAIN,
        title=options[CONF_NAME],
        options=entry_options,
        unique_id=unique_id,
    )
    entry.add_to_hass(hass)
    assert await hass.config_entries.async_setup(entry.entry_id)
    return entry


@respx.mock
async def test_fetching_url(
    hass: HomeAssistant,
    hass_client: ClientSessionGenerator,
    fakeimgbytes_png: bytes,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test that it fetches the given url."""
    hass.states.async_set("sensor.temp", "http://example.com/0a")
    respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png)
    respx.get("http://example.com/1a").respond(stream=fakeimgbytes_png)

    options = {
        "name": "config_test",
        "platform": "generic",
        "still_image_url": "{{ states.sensor.temp.state }}",
        "username": "user",
        "password": "pass",
        "authentication": "basic",
        "framerate": 20,
    }
    await help_setup_mock_config_entry(hass, options)

    client = await hass_client()

    resp = await client.get("/api/camera_proxy/camera.config_test")

    assert resp.status == HTTPStatus.OK
    assert respx.calls.call_count == 1
    body = await resp.read()
    assert body == fakeimgbytes_png

    # sleep .1 seconds to make cached image expire
    await asyncio.sleep(0.1)

    resp = await client.get("/api/camera_proxy/camera.config_test")
    assert respx.calls.call_count == 2

    # If the template renders to an invalid URL we return the last image from cache
    hass.states.async_set("sensor.temp", "invalid url")

    # sleep another .1 seconds to make cached image expire
    await asyncio.sleep(0.1)
    resp = await client.get("/api/camera_proxy/camera.config_test")
    assert resp.status == HTTPStatus.OK
    assert respx.calls.call_count == 2
    assert (
        "Invalid URL 'invalid url': expected a URL, returning last image" in caplog.text
    )

    # Restore a valid URL
    hass.states.async_set("sensor.temp", "http://example.com/1a")
    await asyncio.sleep(0.1)
    resp = await client.get("/api/camera_proxy/camera.config_test")
    assert resp.status == HTTPStatus.OK
    assert respx.calls.call_count == 3


@respx.mock
async def test_image_caching(
    hass: HomeAssistant,
    hass_client: ClientSessionGenerator,
    freezer: FrozenDateTimeFactory,
    fakeimgbytes_png: bytes,
) -> None:
    """Test image is cached and not fetched more than framerate."""
    respx.get("http://example.com").respond(stream=fakeimgbytes_png)

    framerate = 5
    options = {
        "name": "config_test",
        "platform": "generic",
        "still_image_url": "http://example.com",
        "username": "user",
        "password": "pass",
        "authentication": "basic",
        "framerate": framerate,
    }
    await help_setup_mock_config_entry(hass, options)

    client = await hass_client()

    resp = await client.get("/api/camera_proxy/camera.config_test")
    assert resp.status == HTTPStatus.OK
    body = await resp.read()
    assert body == fakeimgbytes_png

    resp = await client.get("/api/camera_proxy/camera.config_test")
    assert resp.status == HTTPStatus.OK
    body = await resp.read()
    assert body == fakeimgbytes_png

    # time is frozen, image should have come from cache
    assert respx.calls.call_count == 1

    # advance time by 150ms
    freezer.tick(timedelta(seconds=0.150))

    resp = await client.get("/api/camera_proxy/camera.config_test")
    assert resp.status == HTTPStatus.OK
    body = await resp.read()
    assert body == fakeimgbytes_png

    # Only 150ms have passed, image should still have come from cache
    assert respx.calls.call_count == 1

    # advance time by another 150ms
    freezer.tick(timedelta(seconds=0.150))

    resp = await client.get("/api/camera_proxy/camera.config_test")
    assert resp.status == HTTPStatus.OK
    body = await resp.read()
    assert body == fakeimgbytes_png

    # 300ms have passed, now we should have fetched a new image
    assert respx.calls.call_count == 2

    resp = await client.get("/api/camera_proxy/camera.config_test")
    assert resp.status == HTTPStatus.OK
    body = await resp.read()
    assert body == fakeimgbytes_png

    # Still only 300ms have passed, should have returned the cached image
    assert respx.calls.call_count == 2


@respx.mock
async def test_fetching_without_verify_ssl(
    hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png: bytes
) -> None:
    """Test that it fetches the given url when ssl verify is off."""
    respx.get("https://example.com").respond(stream=fakeimgbytes_png)

    options = {
        "name": "config_test",
        "platform": "generic",
        "still_image_url": "https://example.com",
        "username": "user",
        "password": "pass",
        "verify_ssl": "false",
    }
    await help_setup_mock_config_entry(hass, options)

    client = await hass_client()

    resp = await client.get("/api/camera_proxy/camera.config_test")

    assert resp.status == HTTPStatus.OK


@respx.mock
async def test_fetching_url_with_verify_ssl(
    hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png: bytes
) -> None:
    """Test that it fetches the given url when ssl verify is explicitly on."""
    respx.get("https://example.com").respond(stream=fakeimgbytes_png)

    options = {
        "name": "config_test",
        "platform": "generic",
        "still_image_url": "https://example.com",
        "username": "user",
        "password": "pass",
        "verify_ssl": True,
    }
    await help_setup_mock_config_entry(hass, options)

    client = await hass_client()

    resp = await client.get("/api/camera_proxy/camera.config_test")

    assert resp.status == HTTPStatus.OK


@respx.mock
async def test_limit_refetch(
    hass: HomeAssistant,
    hass_client: ClientSessionGenerator,
    fakeimgbytes_png: bytes,
    fakeimgbytes_jpg: bytes,
) -> None:
    """Test that it fetches the given url."""
    respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png)
    respx.get("http://example.com/5a").respond(stream=fakeimgbytes_png)
    respx.get("http://example.com/10a").respond(stream=fakeimgbytes_png)
    respx.get("http://example.com/15a").respond(stream=fakeimgbytes_jpg)
    respx.get("http://example.com/20a").respond(status_code=HTTPStatus.NOT_FOUND)

    hass.states.async_set("sensor.temp", "0")

    options = {
        "name": "config_test",
        "platform": "generic",
        "still_image_url": 'http://example.com/{{ states.sensor.temp.state + "a" }}',
        "limit_refetch_to_url_change": True,
    }
    await help_setup_mock_config_entry(hass, options)

    client = await hass_client()

    resp = await client.get("/api/camera_proxy/camera.config_test")

    hass.states.async_set("sensor.temp", "5")

    with (
        pytest.raises(aiohttp.ServerTimeoutError),
        patch.object(
            client.session._connector, "connect", side_effect=asyncio.TimeoutError
        ),
    ):
        resp = await client.get("/api/camera_proxy/camera.config_test")

    assert respx.calls.call_count == 1
    assert resp.status == HTTPStatus.OK

    hass.states.async_set("sensor.temp", "10")

    resp = await client.get("/api/camera_proxy/camera.config_test")
    assert respx.calls.call_count == 2
    assert resp.status == HTTPStatus.OK
    body = await resp.read()
    assert body == fakeimgbytes_png

    resp = await client.get("/api/camera_proxy/camera.config_test")
    assert respx.calls.call_count == 2
    assert resp.status == HTTPStatus.OK
    body = await resp.read()
    assert body == fakeimgbytes_png

    hass.states.async_set("sensor.temp", "15")

    # Url change = fetch new image
    resp = await client.get("/api/camera_proxy/camera.config_test")
    assert respx.calls.call_count == 3
    assert resp.status == HTTPStatus.OK
    body = await resp.read()
    assert body == fakeimgbytes_jpg

    # Cause a template render error
    hass.states.async_remove("sensor.temp")
    resp = await client.get("/api/camera_proxy/camera.config_test")
    assert respx.calls.call_count == 3
    assert resp.status == HTTPStatus.OK
    body = await resp.read()
    assert body == fakeimgbytes_jpg


@respx.mock
async def test_stream_source(
    hass: HomeAssistant,
    hass_client: ClientSessionGenerator,
    hass_ws_client: WebSocketGenerator,
    fakeimgbytes_png: bytes,
) -> None:
    """Test that the stream source is rendered."""
    respx.get("http://example.com").respond(stream=fakeimgbytes_png)
    respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png)

    hass.states.async_set("sensor.temp", "0")
    mock_entry = MockConfigEntry(
        title="config_test",
        domain=DOMAIN,
        data={},
        options={
            CONF_STILL_IMAGE_URL: "http://example.com",
            CONF_STREAM_SOURCE: (
                'http://example.com/{{ states.sensor.temp.state + "a" }}'
            ),
            CONF_LIMIT_REFETCH_TO_URL_CHANGE: True,
            CONF_FRAMERATE: 2,
            CONF_CONTENT_TYPE: "image/png",
            CONF_VERIFY_SSL: False,
            CONF_USERNAME: "barney",
            CONF_PASSWORD: "betty",
            CONF_RTSP_TRANSPORT: "http",
        },
    )
    mock_entry.add_to_hass(hass)
    await hass.config_entries.async_setup(mock_entry.entry_id)
    assert await async_setup_component(hass, "stream", {})
    await hass.async_block_till_done()

    hass.states.async_set("sensor.temp", "5")
    stream_source = await async_get_stream_source(hass, "camera.config_test")
    assert stream_source == "http://barney:betty@example.com/5a"

    # Create a mock stream that doesn't actually try to connect
    mock_stream = Mock()
    mock_stream.add_provider = Mock()
    mock_stream.start = AsyncMock()
    mock_stream.endpoint_url = Mock(return_value="http://home.assistant/playlist.m3u8")

    with patch(
        "homeassistant.components.camera.create_stream",
        return_value=mock_stream,
    ):
        # Request playlist through WebSocket
        client = await hass_ws_client(hass)

        await client.send_json(
            {"id": 1, "type": "camera/stream", "entity_id": "camera.config_test"}
        )
        msg = await client.receive_json()

        # Assert WebSocket response
        assert mock_stream.endpoint_url.call_count == 1
        assert msg["id"] == 1
        assert msg["type"] == TYPE_RESULT
        assert msg["success"]
        assert msg["result"]["url"][-13:] == "playlist.m3u8"


@respx.mock
async def test_stream_source_error(
    hass: HomeAssistant,
    hass_client: ClientSessionGenerator,
    hass_ws_client: WebSocketGenerator,
    fakeimgbytes_png: bytes,
) -> None:
    """Test that the stream source has an error."""
    respx.get("http://example.com").respond(stream=fakeimgbytes_png)

    options = {
        "name": "config_test",
        "platform": "generic",
        "still_image_url": "http://example.com",
        # Does not exist
        "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}',
        "limit_refetch_to_url_change": True,
    }
    await help_setup_mock_config_entry(hass, options)
    assert await async_setup_component(hass, "stream", {})
    await hass.async_block_till_done()

    with patch(
        "homeassistant.components.camera.Stream.endpoint_url",
        return_value="http://home.assistant/playlist.m3u8",
    ) as mock_stream_url:
        # Request playlist through WebSocket
        client = await hass_ws_client(hass)

        await client.send_json(
            {"id": 1, "type": "camera/stream", "entity_id": "camera.config_test"}
        )
        msg = await client.receive_json()

        # Assert WebSocket response
        assert mock_stream_url.call_count == 0
        assert msg["id"] == 1
        assert msg["type"] == TYPE_RESULT
        assert msg["success"] is False
        assert msg["error"] == {
            "code": "start_stream_failed",
            "message": "camera.config_test does not support play stream service",
        }


@respx.mock
async def test_setup_alternative_options(
    hass: HomeAssistant, hass_ws_client: WebSocketGenerator, fakeimgbytes_png: bytes
) -> None:
    """Test that the stream source is setup with different config options."""
    respx.get("https://example.com").respond(stream=fakeimgbytes_png)

    options = {
        "name": "config_test",
        "platform": "generic",
        "still_image_url": "https://example.com",
        "authentication": "digest",
        "username": "user",
        "password": "pass",
        "stream_source": "rtsp://example.com:554/rtsp/",
        "rtsp_transport": "udp",
    }
    await help_setup_mock_config_entry(hass, options)
    assert hass.states.get("camera.config_test")


@respx.mock
async def test_no_stream_source(
    hass: HomeAssistant,
    hass_client: ClientSessionGenerator,
    hass_ws_client: WebSocketGenerator,
    fakeimgbytes_png: bytes,
) -> None:
    """Test a stream request without stream source option set."""
    respx.get("https://example.com").respond(stream=fakeimgbytes_png)

    options = {
        "name": "config_test",
        "platform": "generic",
        "still_image_url": "https://example.com",
        "limit_refetch_to_url_change": True,
    }
    await help_setup_mock_config_entry(hass, options)

    with patch(
        "homeassistant.components.camera.Stream.endpoint_url",
        return_value="http://home.assistant/playlist.m3u8",
    ) as mock_request_stream:
        # Request playlist through WebSocket
        client = await hass_ws_client(hass)

        await client.send_json(
            {"id": 3, "type": "camera/stream", "entity_id": "camera.config_test"}
        )
        msg = await client.receive_json()

        # Assert the websocket error message
        assert mock_request_stream.call_count == 0
        assert msg["id"] == 3
        assert msg["type"] == TYPE_RESULT
        assert msg["success"] is False
        assert msg["error"] == {
            "code": "start_stream_failed",
            "message": "camera.config_test does not support play stream service",
        }


@respx.mock
async def test_camera_content_type(
    hass: HomeAssistant,
    hass_client: ClientSessionGenerator,
    fakeimgbytes_svg: bytes,
    fakeimgbytes_jpg: bytes,
) -> None:
    """Test generic camera with custom content_type."""
    urlsvg = "https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg"
    respx.get(urlsvg).respond(stream=fakeimgbytes_svg)
    urljpg = "https://upload.wikimedia.org/wikipedia/commons/0/0e/Felis_silvestris_silvestris.jpg"
    respx.get(urljpg).respond(stream=fakeimgbytes_jpg)
    cam_config_svg = {
        "name": "config_test_svg",
        "platform": "generic",
        "still_image_url": urlsvg,
        "content_type": "image/svg+xml",
        "limit_refetch_to_url_change": False,
        "framerate": 2,
        "verify_ssl": True,
    }
    cam_config_jpg = {
        "name": "config_test_jpg",
        "platform": "generic",
        "still_image_url": urljpg,
        "content_type": "image/jpeg",
        "limit_refetch_to_url_change": False,
        "framerate": 2,
        "verify_ssl": True,
    }
    await help_setup_mock_config_entry(hass, cam_config_jpg, unique_id=12345)
    await help_setup_mock_config_entry(hass, cam_config_svg, unique_id=54321)

    client = await hass_client()

    resp_1 = await client.get("/api/camera_proxy/camera.config_test_svg")
    assert respx.calls.call_count == 1
    assert resp_1.status == HTTPStatus.OK
    assert resp_1.content_type == "image/svg+xml"
    body = await resp_1.read()
    assert body == fakeimgbytes_svg

    resp_2 = await client.get("/api/camera_proxy/camera.config_test_jpg")
    assert respx.calls.call_count == 2
    assert resp_2.status == HTTPStatus.OK
    assert resp_2.content_type == "image/jpeg"
    body = await resp_2.read()
    assert body == fakeimgbytes_jpg


@respx.mock
async def test_timeout_cancelled(
    hass: HomeAssistant,
    hass_client: ClientSessionGenerator,
    fakeimgbytes_png: bytes,
    fakeimgbytes_jpg: bytes,
) -> None:
    """Test that timeouts and cancellations return last image."""

    respx.get("http://example.com").respond(stream=fakeimgbytes_png)

    options = {
        "name": "config_test",
        "platform": "generic",
        "still_image_url": "http://example.com",
        "username": "user",
        "password": "pass",
        "framerate": 20,
    }
    await help_setup_mock_config_entry(hass, options)

    client = await hass_client()

    resp = await client.get("/api/camera_proxy/camera.config_test")

    assert resp.status == HTTPStatus.OK
    assert respx.calls.call_count == 1
    assert await resp.read() == fakeimgbytes_png

    respx.get("http://example.com").respond(stream=fakeimgbytes_jpg)

    with patch(
        "homeassistant.components.generic.camera.GenericCamera.async_camera_image",
        side_effect=asyncio.CancelledError(),
    ):
        resp = await client.get("/api/camera_proxy/camera.config_test")
        assert respx.calls.call_count == 1
        assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR

    respx.get("http://example.com").side_effect = [
        httpx.RequestError,
        httpx.TimeoutException,
    ]

    for total_calls in range(2, 4):
        # sleep .1 seconds to make cached image expire
        await asyncio.sleep(0.1)
        resp = await client.get("/api/camera_proxy/camera.config_test")
        assert respx.calls.call_count == total_calls
        assert resp.status == HTTPStatus.OK
        assert await resp.read() == fakeimgbytes_png


async def test_frame_interval_property(hass: HomeAssistant) -> None:
    """Test that the frame interval is calculated and returned correctly."""

    options = {
        "name": "config_test",
        "platform": "generic",
        "stream_source": "rtsp://example.com:554/rtsp/",
        "framerate": 5,
    }
    await help_setup_mock_config_entry(hass, options)

    request = Mock()
    with patch(
        "homeassistant.components.camera.async_get_still_stream"
    ) as mock_get_stream:
        await async_get_mjpeg_stream(hass, request, "camera.config_test")

    assert mock_get_stream.call_args_list[0][0][3] == pytest.approx(0.2)
