"""The tests for the Home Assistant HTTP component."""

import asyncio
from collections.abc import Callable
from datetime import timedelta
from http import HTTPStatus
from ipaddress import ip_network
import logging
import os
from pathlib import Path
from unittest.mock import ANY, Mock, patch

import pytest

from homeassistant.auth.providers.homeassistant import HassAuthProvider
from homeassistant.components import cloud, http
from homeassistant.components.cloud import CloudNotAvailable
from homeassistant.const import HASSIO_USER_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.http import KEY_HASS
from homeassistant.helpers.network import NoURLAvailableError
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from homeassistant.util.ssl import server_context_intermediate, server_context_modern

from tests.common import async_call_logger_set_level, async_fire_time_changed
from tests.typing import ClientSessionGenerator


@pytest.fixture(autouse=True)
def disable_http_server(socket_enabled: None) -> None:
    """Override the global disable_http_server fixture with an empty fixture.

    This allows the HTTP server to start in tests that need it.
    """
    return


def _setup_broken_ssl_pem_files(tmp_path: Path) -> tuple[Path, Path]:
    test_dir = tmp_path / "test_broken_ssl"
    test_dir.mkdir()
    cert_path = test_dir / "cert.pem"
    cert_path.write_text("garbage")
    key_path = test_dir / "key.pem"
    key_path.write_text("garbage")
    return cert_path, key_path


def _setup_empty_ssl_pem_files(tmp_path: Path) -> tuple[Path, Path, Path]:
    test_dir = tmp_path / "test_empty_ssl"
    test_dir.mkdir()
    cert_path = test_dir / "cert.pem"
    cert_path.write_text("-")
    peer_cert_path = test_dir / "peer_cert.pem"
    peer_cert_path.write_text("-")
    key_path = test_dir / "key.pem"
    key_path.write_text("-")
    return cert_path, key_path, peer_cert_path


@pytest.fixture
def mock_stack():
    """Mock extract stack."""
    with patch(
        "homeassistant.components.http.extract_stack",
        return_value=[
            Mock(
                filename="/home/paulus/core/homeassistant/core.py",
                lineno="23",
                line="do_something()",
            ),
            Mock(
                filename="/home/paulus/core/homeassistant/components/hue/light.py",
                lineno="23",
                line="self.light.is_on",
            ),
            Mock(
                filename="/home/paulus/core/homeassistant/components/http/__init__.py",
                lineno="157",
                line="base_url",
            ),
        ],
    ):
        yield


class TestView(http.HomeAssistantView):
    """Test the HTTP views."""

    name = "test"
    url = "/hello"

    async def get(self, request):
        """Return a get request."""
        return "hello"


async def test_registering_view_while_running(
    hass: HomeAssistant,
    aiohttp_client: ClientSessionGenerator,
    unused_tcp_port_factory: Callable[[], int],
) -> None:
    """Test that we can register a view while the server is running."""
    await async_setup_component(
        hass,
        http.DOMAIN,
        {http.DOMAIN: {http.CONF_SERVER_PORT: unused_tcp_port_factory()}},
    )

    await hass.async_start()
    # This raises a RuntimeError if app is frozen
    hass.http.register_view(TestView)


async def test_homeassistant_assigned_to_app(hass: HomeAssistant) -> None:
    """Test HomeAssistant instance is assigned to HomeAssistantApp."""
    assert await async_setup_component(hass, "api", {"http": {}})
    await hass.async_start()
    assert hass.http.app[KEY_HASS] == hass
    assert hass.http.app["hass"] == hass  # For backwards compatibility
    await hass.async_stop()


async def test_not_log_password(
    hass: HomeAssistant,
    hass_client_no_auth: ClientSessionGenerator,
    caplog: pytest.LogCaptureFixture,
    local_auth: HassAuthProvider,
) -> None:
    """Test access with password doesn't get logged."""
    assert await async_setup_component(hass, "api", {"http": {}})
    client = await hass_client_no_auth()
    logging.getLogger("aiohttp.access").setLevel(logging.INFO)

    resp = await client.get("/api/", params={"api_password": "test-password"})

    assert resp.status == HTTPStatus.UNAUTHORIZED
    logs = caplog.text

    # Ensure we don't log API passwords
    assert "/api/" in logs
    assert "some-pass" not in logs


async def test_proxy_config(hass: HomeAssistant) -> None:
    """Test use_x_forwarded_for must config together with trusted_proxies."""
    assert (
        await async_setup_component(
            hass,
            "http",
            {
                "http": {
                    http.CONF_USE_X_FORWARDED_FOR: True,
                    http.CONF_TRUSTED_PROXIES: ["127.0.0.1"],
                }
            },
        )
        is True
    )


async def test_proxy_config_only_use_xff(hass: HomeAssistant) -> None:
    """Test use_x_forwarded_for must config together with trusted_proxies."""
    assert (
        await async_setup_component(
            hass, "http", {"http": {http.CONF_USE_X_FORWARDED_FOR: True}}
        )
        is not True
    )


async def test_proxy_config_only_trust_proxies(hass: HomeAssistant) -> None:
    """Test use_x_forwarded_for must config together with trusted_proxies."""
    assert (
        await async_setup_component(
            hass, "http", {"http": {http.CONF_TRUSTED_PROXIES: ["127.0.0.1"]}}
        )
        is not True
    )


async def test_ssl_profile_defaults_modern(hass: HomeAssistant, tmp_path: Path) -> None:
    """Test default ssl profile."""

    cert_path, key_path, _ = await hass.async_add_executor_job(
        _setup_empty_ssl_pem_files, tmp_path
    )

    with (
        patch("ssl.SSLContext.load_cert_chain"),
        patch(
            "homeassistant.util.ssl.server_context_modern",
            side_effect=server_context_modern,
        ) as mock_context,
    ):
        assert (
            await async_setup_component(
                hass,
                "http",
                {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}},
            )
            is True
        )
        await hass.async_start()
        await hass.async_block_till_done()

    assert len(mock_context.mock_calls) == 1


async def test_ssl_profile_change_intermediate(
    hass: HomeAssistant, tmp_path: Path
) -> None:
    """Test setting ssl profile to intermediate."""

    cert_path, key_path, _ = await hass.async_add_executor_job(
        _setup_empty_ssl_pem_files, tmp_path
    )

    with (
        patch("ssl.SSLContext.load_cert_chain"),
        patch(
            "homeassistant.util.ssl.server_context_intermediate",
            side_effect=server_context_intermediate,
        ) as mock_context,
    ):
        assert (
            await async_setup_component(
                hass,
                "http",
                {
                    "http": {
                        "ssl_profile": "intermediate",
                        "ssl_certificate": cert_path,
                        "ssl_key": key_path,
                    }
                },
            )
            is True
        )
        await hass.async_start()
        await hass.async_block_till_done()

    assert len(mock_context.mock_calls) == 1


async def test_ssl_profile_change_modern(hass: HomeAssistant, tmp_path: Path) -> None:
    """Test setting ssl profile to modern."""

    cert_path, key_path, _ = await hass.async_add_executor_job(
        _setup_empty_ssl_pem_files, tmp_path
    )

    with (
        patch("ssl.SSLContext.load_cert_chain"),
        patch(
            "homeassistant.util.ssl.server_context_modern",
            side_effect=server_context_modern,
        ) as mock_context,
    ):
        assert (
            await async_setup_component(
                hass,
                "http",
                {
                    "http": {
                        "ssl_profile": "modern",
                        "ssl_certificate": cert_path,
                        "ssl_key": key_path,
                    }
                },
            )
            is True
        )
        await hass.async_start()
        await hass.async_block_till_done()

    assert len(mock_context.mock_calls) == 1


async def test_peer_cert(hass: HomeAssistant, tmp_path: Path) -> None:
    """Test required peer cert."""
    cert_path, key_path, peer_cert_path = await hass.async_add_executor_job(
        _setup_empty_ssl_pem_files, tmp_path
    )

    with (
        patch("ssl.SSLContext.load_cert_chain"),
        patch("ssl.SSLContext.load_verify_locations") as mock_load_verify_locations,
        patch(
            "homeassistant.util.ssl.server_context_modern",
            side_effect=server_context_modern,
        ) as mock_context,
    ):
        assert (
            await async_setup_component(
                hass,
                "http",
                {
                    "http": {
                        "ssl_peer_certificate": peer_cert_path,
                        "ssl_profile": "modern",
                        "ssl_certificate": cert_path,
                        "ssl_key": key_path,
                    }
                },
            )
            is True
        )
        await hass.async_start()
        await hass.async_block_till_done()

    assert len(mock_context.mock_calls) == 1
    assert len(mock_load_verify_locations.mock_calls) == 1


async def test_emergency_ssl_certificate_when_invalid(
    hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
    """Test http can startup with an emergency self signed cert when the current one is broken."""

    cert_path, key_path = await hass.async_add_executor_job(
        _setup_broken_ssl_pem_files, tmp_path
    )

    hass.config.recovery_mode = True
    assert (
        await async_setup_component(
            hass,
            "http",
            {
                "http": {"ssl_certificate": cert_path, "ssl_key": key_path},
            },
        )
        is True
    )

    await hass.async_start()
    await hass.async_block_till_done()
    assert (
        "Home Assistant is running in recovery mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable"
        in caplog.text
    )

    assert hass.http.site is not None


async def test_emergency_ssl_certificate_not_used_when_not_recovery_mode(
    hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
    """Test an emergency cert is only used in recovery mode."""

    cert_path, key_path = await hass.async_add_executor_job(
        _setup_broken_ssl_pem_files, tmp_path
    )

    assert (
        await async_setup_component(
            hass, "http", {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}
        )
        is False
    )


async def test_emergency_ssl_certificate_when_invalid_get_url_fails(
    hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
    """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken.

    Ensure we can still start of we cannot determine the external url as well.
    """
    cert_path, key_path = await hass.async_add_executor_job(
        _setup_broken_ssl_pem_files, tmp_path
    )
    hass.config.recovery_mode = True

    with patch(
        "homeassistant.components.http.get_url", side_effect=NoURLAvailableError
    ) as mock_get_url:
        assert (
            await async_setup_component(
                hass,
                "http",
                {
                    "http": {"ssl_certificate": cert_path, "ssl_key": key_path},
                },
            )
            is True
        )
        await hass.async_start()
        await hass.async_block_till_done()

    assert len(mock_get_url.mock_calls) == 1
    assert (
        "Home Assistant is running in recovery mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable"
        in caplog.text
    )

    assert hass.http.site is not None


async def test_invalid_ssl_and_cannot_create_emergency_cert(
    hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
    """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken."""

    cert_path, key_path = await hass.async_add_executor_job(
        _setup_broken_ssl_pem_files, tmp_path
    )
    hass.config.recovery_mode = True

    with patch(
        "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError
    ) as mock_builder:
        assert (
            await async_setup_component(
                hass,
                "http",
                {
                    "http": {"ssl_certificate": cert_path, "ssl_key": key_path},
                },
            )
            is True
        )
        await hass.async_start()
        await hass.async_block_till_done()
    assert "Could not create an emergency self signed ssl certificate" in caplog.text
    assert len(mock_builder.mock_calls) == 1

    assert hass.http.site is not None


async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert(
    hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
    """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken.

    When there is a peer cert verification and we cannot create
    an emergency cert (probably will never happen since this means
    the system is very broken), we do not want to startup http
    as it would allow connections that are not verified by the cert.
    """

    cert_path, key_path = await hass.async_add_executor_job(
        _setup_broken_ssl_pem_files, tmp_path
    )
    hass.config.recovery_mode = True

    with patch(
        "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError
    ) as mock_builder:
        assert (
            await async_setup_component(
                hass,
                "http",
                {
                    "http": {
                        "ssl_certificate": cert_path,
                        "ssl_key": key_path,
                        "ssl_peer_certificate": cert_path,
                    },
                },
            )
            is False
        )
        await hass.async_start()
        await hass.async_block_till_done()
    assert "Could not create an emergency self signed ssl certificate" in caplog.text
    assert len(mock_builder.mock_calls) == 1


async def test_cors_defaults(hass: HomeAssistant) -> None:
    """Test the CORS default settings."""
    with patch("homeassistant.components.http.setup_cors") as mock_setup:
        assert await async_setup_component(hass, "http", {})

    assert len(mock_setup.mock_calls) == 1
    assert mock_setup.mock_calls[0][1][1] == ["https://cast.home-assistant.io"]


async def test_storing_config(
    hass: HomeAssistant,
    aiohttp_client: ClientSessionGenerator,
    unused_tcp_port_factory: Callable[[], int],
) -> None:
    """Test that we store last working config."""
    config = {
        http.CONF_SERVER_PORT: unused_tcp_port_factory(),
        "use_x_forwarded_for": True,
        "trusted_proxies": ["192.168.1.100"],
    }

    assert await async_setup_component(hass, http.DOMAIN, {http.DOMAIN: config})

    await hass.async_start()

    async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200))
    await hass.async_block_till_done()

    restored = await http.async_get_last_config(hass)
    restored["trusted_proxies"][0] = ip_network(restored["trusted_proxies"][0])

    assert restored == http.HTTP_SCHEMA(config)


async def test_logging(
    hass: HomeAssistant,
    hass_client: ClientSessionGenerator,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Testing the access log works."""
    await asyncio.gather(
        *(
            async_setup_component(hass, component, {})
            for component in ("http", "logger", "api")
        )
    )
    hass.states.async_set("logging.entity", "hello")
    async with async_call_logger_set_level(
        "aiohttp.access", "INFO", hass=hass, caplog=caplog
    ):
        client = await hass_client()
        response = await client.get("/api/states/logging.entity")
        assert response.status == HTTPStatus.OK

        assert "GET /api/states/logging.entity" in caplog.text
        caplog.clear()
    async with async_call_logger_set_level(
        "aiohttp.access", "WARNING", hass=hass, caplog=caplog
    ):
        response = await client.get("/api/states/logging.entity")
        assert response.status == HTTPStatus.OK
        assert "GET /api/states/logging.entity" not in caplog.text


async def test_ssl_issue_if_no_urls_configured(
    hass: HomeAssistant,
    tmp_path: Path,
    issue_registry: ir.IssueRegistry,
) -> None:
    """Test raising SSL issue if no external or internal URL is configured."""

    assert hass.config.external_url is None
    assert hass.config.internal_url is None

    cert_path, key_path, _ = await hass.async_add_executor_job(
        _setup_empty_ssl_pem_files, tmp_path
    )

    with (
        patch("ssl.SSLContext.load_cert_chain"),
        patch(
            "homeassistant.util.ssl.server_context_modern",
            side_effect=server_context_modern,
        ),
    ):
        assert await async_setup_component(
            hass,
            "http",
            {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}},
        )
        await hass.async_start()
        await hass.async_block_till_done()

    assert ("http", "ssl_configured_without_configured_urls") in issue_registry.issues


async def test_ssl_issue_if_using_cloud(
    hass: HomeAssistant,
    tmp_path: Path,
    issue_registry: ir.IssueRegistry,
) -> None:
    """Test raising no SSL issue if not right configured but using cloud."""
    assert hass.config.external_url is None
    assert hass.config.internal_url is None

    cert_path, key_path, _ = await hass.async_add_executor_job(
        _setup_empty_ssl_pem_files, tmp_path
    )

    with (
        patch("ssl.SSLContext.load_cert_chain"),
        patch.object(cloud, "async_remote_ui_url", return_value="https://example.com"),
        patch(
            "homeassistant.util.ssl.server_context_modern",
            side_effect=server_context_modern,
        ),
    ):
        assert await async_setup_component(
            hass,
            "http",
            {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}},
        )
        await hass.async_start()
        await hass.async_block_till_done()

    assert (
        "http",
        "ssl_configured_without_configured_urls",
    ) not in issue_registry.issues


async def test_ssl_issue_if_not_connected_to_cloud(
    hass: HomeAssistant,
    tmp_path: Path,
    issue_registry: ir.IssueRegistry,
) -> None:
    """Test raising no SSL issue if not right configured and not connected to cloud."""
    assert hass.config.external_url is None
    assert hass.config.internal_url is None

    cert_path, key_path, _ = await hass.async_add_executor_job(
        _setup_empty_ssl_pem_files, tmp_path
    )

    with (
        patch("ssl.SSLContext.load_cert_chain"),
        patch(
            "homeassistant.util.ssl.server_context_modern",
            side_effect=server_context_modern,
        ),
        patch(
            "homeassistant.components.cloud.async_remote_ui_url",
            side_effect=CloudNotAvailable,
        ),
    ):
        assert await async_setup_component(
            hass,
            "http",
            {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}},
        )
        await hass.async_start()
        await hass.async_block_till_done()

    assert ("http", "ssl_configured_without_configured_urls") in issue_registry.issues


@pytest.mark.parametrize(
    ("external_url", "internal_url"),
    [
        ("https://example.com", "https://example.local"),
        (None, "http://example.local"),
        ("https://example.com", None),
    ],
)
async def test_ssl_issue_urls_configured(
    hass: HomeAssistant,
    tmp_path: Path,
    issue_registry: ir.IssueRegistry,
    external_url: str | None,
    internal_url: str | None,
) -> None:
    """Test raising SSL issue if no external or internal URL is configured."""

    cert_path, key_path, _ = await hass.async_add_executor_job(
        _setup_empty_ssl_pem_files, tmp_path
    )

    hass.config.external_url = external_url
    hass.config.internal_url = internal_url

    with (
        patch("ssl.SSLContext.load_cert_chain"),
        patch(
            "homeassistant.util.ssl.server_context_modern",
            side_effect=server_context_modern,
        ),
    ):
        assert await async_setup_component(
            hass,
            "http",
            {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}},
        )
        await hass.async_start()
        await hass.async_block_till_done()

    assert (
        "http",
        "ssl_configured_without_configured_urls",
    ) not in issue_registry.issues


@pytest.mark.parametrize(
    (
        "hassio",
        "http_config",
        "expected_serverhost",
        "expected_issues",
    ),
    [
        (False, {}, ["0.0.0.0", "::"], set()),
        (False, {"server_host": "0.0.0.0"}, ["0.0.0.0"], set()),
        (True, {}, ["0.0.0.0", "::"], set()),
        (
            True,
            {"server_host": "0.0.0.0"},
            [
                "0.0.0.0",
            ],
            {("http", "server_host_deprecated_hassio")},
        ),
    ],
)
async def test_server_host(
    hass: HomeAssistant,
    hassio: bool,
    issue_registry: ir.IssueRegistry,
    http_config: dict,
    expected_serverhost: list,
    expected_issues: set[tuple[str, str]],
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test server_host behavior."""
    mock_server = Mock()
    with (
        patch("homeassistant.components.http.is_hassio", return_value=hassio),
        patch(
            "asyncio.BaseEventLoop.create_server", return_value=mock_server
        ) as mock_create_server,
    ):
        assert await async_setup_component(
            hass,
            "http",
            {"http": http_config},
        )
        await hass.async_start()
        await hass.async_block_till_done()

    mock_create_server.assert_called_once_with(
        ANY,
        expected_serverhost,
        8123,
        ssl=None,
        backlog=128,
        reuse_address=None,
        reuse_port=None,
    )

    assert set(issue_registry.issues) == expected_issues


async def test_unix_socket_started_with_supervisor(
    hass: HomeAssistant,
    tmp_path: Path,
) -> None:
    """Test unix socket is started when running under Supervisor."""
    await hass.auth.async_create_system_user(
        HASSIO_USER_NAME, group_ids=["system-admin"]
    )
    socket_path = tmp_path / "core.sock"
    loop = asyncio.get_running_loop()
    mock_sock = Mock()
    with (
        patch.dict(
            os.environ, {"SUPERVISOR_CORE_API_SOCKET": str(socket_path)}, clear=False
        ),
        patch("asyncio.BaseEventLoop.create_server", return_value=Mock()),
        patch(
            "homeassistant.components.http.web_runner.HomeAssistantUnixSite"
            "._create_unix_socket",
            return_value=mock_sock,
        ) as mock_create_sock,
        patch.object(
            loop, "create_unix_server", return_value=Mock()
        ) as mock_create_unix,
    ):
        assert await async_setup_component(hass, "http", {"http": {}})
        await hass.async_start()
        await hass.async_block_till_done()

    mock_create_sock.assert_called_once()
    mock_create_unix.assert_called_once_with(ANY, sock=mock_sock, backlog=128)
    assert hass.http.supervisor_site is not None


async def test_unix_socket_not_started_without_supervisor(
    hass: HomeAssistant,
) -> None:
    """Test unix socket is not started when not running under Supervisor."""
    with (
        patch.dict(os.environ, {}, clear=False),
        patch("asyncio.BaseEventLoop.create_server", return_value=Mock()),
    ):
        os.environ.pop("SUPERVISOR_CORE_API_SOCKET", None)
        assert await async_setup_component(hass, "http", {"http": {}})
        await hass.async_start()
        await hass.async_block_till_done()

    assert hass.http.supervisor_site is None


async def test_unix_socket_rejected_relative_path(
    hass: HomeAssistant,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test unix socket is rejected when path is relative."""
    with (
        patch.dict(
            os.environ,
            {"SUPERVISOR_CORE_API_SOCKET": "relative/path.sock"},
            clear=False,
        ),
        patch("asyncio.BaseEventLoop.create_server", return_value=Mock()),
    ):
        assert await async_setup_component(hass, "http", {"http": {}})
        await hass.async_start()
        await hass.async_block_till_done()

    assert hass.http.supervisor_site is None
    assert "path must be absolute" in caplog.text
