"""Config flow for generic (IP Camera)."""

from __future__ import annotations

import asyncio
from collections.abc import Mapping
import contextlib
from datetime import datetime
from errno import EHOSTUNREACH, EIO
import io
import logging
from typing import Any, cast

from aiohttp import web
from httpx import HTTPStatusError, RequestError, TimeoutException
import PIL.Image
import voluptuous as vol
import yarl

from homeassistant.components import websocket_api
from homeassistant.components.camera import (
    CAMERA_IMAGE_TIMEOUT,
    DOMAIN as CAMERA_DOMAIN,
    DynamicStreamSettings,
    _async_get_image,
)
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.components.stream import (
    CONF_RTSP_TRANSPORT,
    CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
    HLS_PROVIDER,
    RTSP_TRANSPORTS,
    SOURCE_TIMEOUT,
    Stream,
    create_stream,
)
from homeassistant.config_entries import (
    ConfigEntry,
    ConfigFlow,
    ConfigFlowResult,
    OptionsFlow,
)
from homeassistant.const import (
    CONF_AUTHENTICATION,
    CONF_NAME,
    CONF_PASSWORD,
    CONF_USERNAME,
    CONF_VERIFY_SSL,
    HTTP_BASIC_AUTHENTICATION,
    HTTP_DIGEST_AUTHENTICATION,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import section
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import config_validation as cv, template as template_helper
from homeassistant.helpers.entity_platform import PlatformData
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.network import get_url
from homeassistant.helpers.selector import (
    SelectOptionDict,
    SelectSelector,
    SelectSelectorConfig,
    SelectSelectorMode,
)
from homeassistant.util import slugify

from .camera import GenericCamera, generate_auth
from .const import (
    CONF_CONFIRMED_OK,
    CONF_CONTENT_TYPE,
    CONF_FRAMERATE,
    CONF_LIMIT_REFETCH_TO_URL_CHANGE,
    CONF_STILL_IMAGE_URL,
    CONF_STREAM_SOURCE,
    DEFAULT_NAME,
    DOMAIN,
    GET_IMAGE_TIMEOUT,
    SECTION_ADVANCED,
)

_LOGGER = logging.getLogger(__name__)

DEFAULT_DATA = {
    CONF_NAME: DEFAULT_NAME,
    SECTION_ADVANCED: {
        CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
        CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
        CONF_FRAMERATE: 2,
        CONF_VERIFY_SSL: True,
        CONF_RTSP_TRANSPORT: "tcp",
    },
}

SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}
IMAGE_PREVIEWS_ACTIVE = "previews"


class InvalidStreamException(HomeAssistantError):
    """Error to indicate an invalid stream."""

    def __init__(self, error: str, details: str | None = None) -> None:
        """Initialize the error."""
        super().__init__(error)
        self.details = details


def build_schema(
    is_options_flow: bool = False,
    show_advanced_options: bool = False,
) -> vol.Schema:
    """Create schema for camera config setup."""
    rtsp_options = [
        SelectOptionDict(
            value=value,
            label=name,
        )
        for value, name in RTSP_TRANSPORTS.items()
    ]

    advanced_section = {
        vol.Required(CONF_FRAMERATE): vol.All(
            vol.Range(min=0, min_included=False), cv.positive_float
        ),
        vol.Required(CONF_VERIFY_SSL): bool,
        vol.Optional(CONF_RTSP_TRANSPORT): SelectSelector(
            SelectSelectorConfig(
                options=rtsp_options,
                mode=SelectSelectorMode.DROPDOWN,
            )
        ),
        vol.Optional(CONF_AUTHENTICATION): vol.In(
            [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]
        ),
    }
    spec = {
        vol.Optional(CONF_STREAM_SOURCE): str,
        vol.Optional(CONF_STILL_IMAGE_URL): str,
        vol.Optional(CONF_USERNAME): str,
        vol.Optional(CONF_PASSWORD): str,
        vol.Required(SECTION_ADVANCED): section(
            vol.Schema(advanced_section), {"collapsed": True}
        ),
    }
    if is_options_flow:
        advanced_section[vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE)] = bool
        if show_advanced_options:
            advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool

    return vol.Schema(spec)


def get_image_type(image: bytes) -> str | None:
    """Get the format of downloaded bytes that could be an image."""
    fmt = None
    imagefile = io.BytesIO(image)
    with contextlib.suppress(PIL.UnidentifiedImageError):
        img = PIL.Image.open(imagefile)
        fmt = img.format.lower() if img.format else None

    if fmt is None:
        # if PIL can't figure it out, could be svg.
        with contextlib.suppress(UnicodeDecodeError):
            if image.decode("utf-8").lstrip().startswith("<svg"):
                return "svg+xml"
    return fmt


async def async_test_still(
    hass: HomeAssistant, info: Mapping[str, Any]
) -> tuple[dict[str, str], str | None]:
    """Verify that the still image is valid before we create an entity."""
    fmt = None
    if not (url := info.get(CONF_STILL_IMAGE_URL)):
        # If user didn't specify a still image URL,the automatically generated
        # still image that stream generates is always jpeg.
        return {}, info.get(CONF_CONTENT_TYPE, "image/jpeg")
    try:
        if not isinstance(url, template_helper.Template):
            url = template_helper.Template(url, hass)
        url = url.async_render(parse_result=False)
    except TemplateError as err:
        _LOGGER.warning("Problem rendering template %s: %s", url, err)
        return {CONF_STILL_IMAGE_URL: "template_error"}, None
    try:
        yarl_url = yarl.URL(url)
    except ValueError:
        return {CONF_STILL_IMAGE_URL: "malformed_url"}, None
    if not yarl_url.is_absolute():
        return {CONF_STILL_IMAGE_URL: "relative_url"}, None
    verify_ssl = info[SECTION_ADVANCED][CONF_VERIFY_SSL]
    auth = generate_auth(info)
    try:
        async_client = get_async_client(hass, verify_ssl=verify_ssl)
        async with asyncio.timeout(GET_IMAGE_TIMEOUT):
            response = await async_client.get(
                url, auth=auth, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True
            )
            response.raise_for_status()
            image = response.content
    except (
        TimeoutError,
        RequestError,
        TimeoutException,
    ) as err:
        _LOGGER.error("Error getting camera image from %s: %s", url, type(err).__name__)
        return {CONF_STILL_IMAGE_URL: "unable_still_load"}, None
    except HTTPStatusError as err:
        _LOGGER.error(
            "Error getting camera image from %s: %s %s",
            url,
            type(err).__name__,
            err.response.text,
        )
        if err.response.status_code in [401, 403]:
            return {CONF_STILL_IMAGE_URL: "unable_still_load_auth"}, None
        if err.response.status_code == 404:
            return {CONF_STILL_IMAGE_URL: "unable_still_load_not_found"}, None
        if err.response.status_code in [500, 503]:
            return {CONF_STILL_IMAGE_URL: "unable_still_load_server_error"}, None
        return {CONF_STILL_IMAGE_URL: "unable_still_load"}, None

    if not image:
        return {CONF_STILL_IMAGE_URL: "unable_still_load_no_image"}, None
    fmt = get_image_type(image)
    _LOGGER.debug(
        "Still image at '%s' detected format: %s",
        info[CONF_STILL_IMAGE_URL],
        fmt,
    )
    if fmt not in SUPPORTED_IMAGE_TYPES:
        return {CONF_STILL_IMAGE_URL: "invalid_still_image"}, None
    return {}, f"image/{fmt}"


def slug(
    hass: HomeAssistant, template: str | template_helper.Template | None
) -> str | None:
    """Convert a camera url into a string suitable for a camera name."""
    url = ""
    if not template:
        return None
    if not isinstance(template, template_helper.Template):
        template = template_helper.Template(template, hass)
    try:
        url = template.async_render(parse_result=False)
        return slugify(yarl.URL(url).host)
    except (ValueError, TemplateError, TypeError) as err:
        _LOGGER.error("Syntax error in '%s': %s", template, err)
    return None


async def async_test_and_preview_stream(
    hass: HomeAssistant, info: Mapping[str, Any]
) -> Stream | None:
    """Verify that the stream is valid before we create an entity.

    Returns the stream object if valid. Raises InvalidStreamException if not.
    The stream object is used to preview the video in the UI.
    """
    if not (stream_source := info.get(CONF_STREAM_SOURCE)):
        return None

    if not isinstance(stream_source, template_helper.Template):
        stream_source = template_helper.Template(stream_source, hass)
    try:
        stream_source = stream_source.async_render(parse_result=False)
    except TemplateError as err:
        _LOGGER.warning("Problem rendering template %s: %s", stream_source, err)
        raise InvalidStreamException("template_error") from err
    stream_options: dict[str, str | bool | float] = {}
    if rtsp_transport := info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT):
        stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
    if info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
        stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True

    try:
        url = yarl.URL(stream_source)
    except ValueError as err:
        raise InvalidStreamException("malformed_url") from err
    if not url.is_absolute():
        raise InvalidStreamException("relative_url")
    if not url.user and not url.password:
        username = info.get(CONF_USERNAME)
        password = info.get(CONF_PASSWORD)
        if username and password:
            url = url.with_user(username).with_password(password)
            stream_source = str(url)
    try:
        stream = create_stream(
            hass,
            stream_source,
            stream_options,
            DynamicStreamSettings(),
            f"{DOMAIN}.test_stream",
        )
        hls_provider = stream.add_provider(HLS_PROVIDER)
    except PermissionError as err:
        raise InvalidStreamException("stream_not_permitted") from err
    except OSError as err:
        if err.errno == EHOSTUNREACH:
            raise InvalidStreamException("stream_no_route_to_host") from err
        if err.errno == EIO:  # input/output error
            raise InvalidStreamException("stream_io_error") from err
        raise
    except HomeAssistantError as err:
        if "Stream integration is not set up" in str(err):
            raise InvalidStreamException("stream_not_set_up") from err
        raise
    await stream.start()
    if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT):
        hass.async_create_task(stream.stop())
        raise InvalidStreamException("timeout")
    return stream


def register_still_preview(hass: HomeAssistant) -> None:
    """Set up still image preview for camera feeds during config flow."""
    hass.data.setdefault(DOMAIN, {})

    if not hass.data[DOMAIN].get(IMAGE_PREVIEWS_ACTIVE):
        _LOGGER.debug("Registering camera image preview handler")
        hass.http.register_view(CameraImagePreview(hass))
    hass.data[DOMAIN][IMAGE_PREVIEWS_ACTIVE] = True


class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
    """Config flow for generic IP camera."""

    VERSION = 2

    def __init__(self) -> None:
        """Initialize Generic ConfigFlow."""
        self.preview_image_settings: dict[str, Any] = {}
        self.preview_stream: Stream | None = None
        self.user_input: dict[str, Any] = {}
        self.title = ""

    @staticmethod
    def async_get_options_flow(
        config_entry: ConfigEntry,
    ) -> GenericOptionsFlowHandler:
        """Get the options flow for this handler."""
        return GenericOptionsFlowHandler()

    async def async_step_user(
        self, user_input: dict[str, Any] | None = None
    ) -> ConfigFlowResult:
        """Handle the start of the config flow."""
        errors = {}
        hass = self.hass
        if user_input:
            # Secondary validation because serialised vol can't seem to handle this complexity:
            if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get(
                CONF_STREAM_SOURCE
            ):
                errors["base"] = "no_still_image_or_stream_url"
            else:
                errors, still_format = await async_test_still(hass, user_input)
                try:
                    self.preview_stream = await async_test_and_preview_stream(
                        hass, user_input
                    )
                except InvalidStreamException as err:
                    errors[CONF_STREAM_SOURCE] = str(err)
                    self.preview_stream = None
                if not errors:
                    user_input[CONF_CONTENT_TYPE] = still_format
                    still_url = user_input.get(CONF_STILL_IMAGE_URL)
                    stream_url = user_input.get(CONF_STREAM_SOURCE)
                    name = (
                        slug(hass, still_url) or slug(hass, stream_url) or DEFAULT_NAME
                    )
                    self.user_input = user_input
                    self.title = name
                    # temporary preview for user to check the image
                    self.preview_image_settings = user_input
                    return await self.async_step_user_confirm()
        elif self.user_input:
            user_input = self.user_input
        else:
            user_input = DEFAULT_DATA.copy()
        return self.async_show_form(
            step_id="user",
            data_schema=self.add_suggested_values_to_schema(build_schema(), user_input),
            errors=errors,
        )

    async def async_step_user_confirm(
        self, user_input: dict[str, Any] | None = None
    ) -> ConfigFlowResult:
        """Handle user clicking confirm after still preview."""
        if user_input:
            if ha_stream := self.preview_stream:
                # Kill off the temp stream we created.
                await ha_stream.stop()
            if not user_input.get(CONF_CONFIRMED_OK):
                return await self.async_step_user()
            return self.async_create_entry(
                title=self.title, data={}, options=self.user_input
            )
        register_still_preview(self.hass)
        return self.async_show_form(
            step_id="user_confirm",
            data_schema=vol.Schema(
                {
                    vol.Required(CONF_CONFIRMED_OK, default=False): bool,
                }
            ),
            errors=None,
            preview="generic_camera",
        )

    @staticmethod
    async def async_setup_preview(hass: HomeAssistant) -> None:
        """Set up preview WS API."""
        websocket_api.async_register_command(hass, ws_start_preview)


class GenericOptionsFlowHandler(OptionsFlow):
    """Handle Generic IP Camera options."""

    def __init__(self) -> None:
        """Initialize Generic IP Camera options flow."""
        self.preview_image_settings: dict[str, Any] = {}
        self.preview_stream: Stream | None = None
        self.user_input: dict[str, Any] = {}

    async def async_step_init(
        self, user_input: dict[str, Any] | None = None
    ) -> ConfigFlowResult:
        """Manage Generic IP Camera options."""
        errors: dict[str, str] = {}
        hass = self.hass

        if user_input:
            # Secondary validation because serialised vol can't seem to handle this complexity:
            if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get(
                CONF_STREAM_SOURCE
            ):
                errors["base"] = "no_still_image_or_stream_url"
            else:
                errors, still_format = await async_test_still(hass, user_input)
                try:
                    self.preview_stream = await async_test_and_preview_stream(
                        hass, user_input
                    )
                except InvalidStreamException as err:
                    errors[CONF_STREAM_SOURCE] = str(err)
                    self.preview_stream = None
                if not errors:
                    data = {
                        **user_input,
                        CONF_CONTENT_TYPE: still_format
                        or self.config_entry.options.get(CONF_CONTENT_TYPE),
                    }
                    if (
                        CONF_USE_WALLCLOCK_AS_TIMESTAMPS
                        not in user_input[SECTION_ADVANCED]
                    ):
                        data[SECTION_ADVANCED][CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = (
                            self.config_entry.options[SECTION_ADVANCED].get(
                                CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
                            )
                        )
                    self.user_input = data
                    # temporary preview for user to check the image
                    self.preview_image_settings = data
                    return await self.async_step_user_confirm()
        elif self.user_input:
            user_input = self.user_input
        return self.async_show_form(
            step_id="init",
            data_schema=self.add_suggested_values_to_schema(
                build_schema(
                    True,
                    self.show_advanced_options,
                ),
                user_input or self.config_entry.options,
            ),
            errors=errors,
        )

    async def async_step_user_confirm(
        self, user_input: dict[str, Any] | None = None
    ) -> ConfigFlowResult:
        """Handle user clicking confirm after still preview."""
        if user_input:
            if ha_stream := self.preview_stream:
                # Kill off the temp stream we created.
                await ha_stream.stop()
            if not user_input.get(CONF_CONFIRMED_OK):
                return await self.async_step_init()
            return self.async_create_entry(
                title=self.config_entry.title,
                data=self.user_input,
            )
        register_still_preview(self.hass)
        return self.async_show_form(
            step_id="user_confirm",
            data_schema=vol.Schema(
                {
                    vol.Required(CONF_CONFIRMED_OK, default=False): bool,
                }
            ),
            errors=None,
            preview="generic_camera",
        )

    @staticmethod
    async def async_setup_preview(hass: HomeAssistant) -> None:
        """Set up preview WS API."""
        websocket_api.async_register_command(hass, ws_start_preview)


class CameraImagePreview(HomeAssistantView):
    """Camera view to temporarily serve an image."""

    url = "/api/generic/preview_flow_image/{flow_id}"
    name = "api:generic:preview_flow_image"
    requires_auth = False

    def __init__(self, hass: HomeAssistant) -> None:
        """Initialise."""
        self.hass = hass

    async def get(self, request: web.Request, flow_id: str) -> web.Response:
        """Start a GET request."""
        _LOGGER.debug("processing GET request for flow_id=%s", flow_id)
        flow = cast(
            GenericIPCamConfigFlow,
            self.hass.config_entries.flow._progress.get(flow_id),  # noqa: SLF001
        ) or cast(
            GenericOptionsFlowHandler,
            self.hass.config_entries.options._progress.get(flow_id),  # noqa: SLF001
        )
        if not flow:
            _LOGGER.warning("Unknown flow while getting image preview")
            raise web.HTTPNotFound
        user_input = flow.preview_image_settings
        camera = GenericCamera(self.hass, user_input, flow_id, "preview")
        if not camera.is_on:
            _LOGGER.debug("Camera is off")
            raise web.HTTPServiceUnavailable
        image = await _async_get_image(
            camera,
            CAMERA_IMAGE_TIMEOUT,
        )
        return web.Response(body=image.content, content_type=image.content_type)


@websocket_api.websocket_command(
    {
        vol.Required("type"): "generic_camera/start_preview",
        vol.Required("flow_id"): str,
        vol.Optional("flow_type"): vol.Any("config_flow", "options_flow"),
        vol.Optional("user_input"): dict,
    }
)
@websocket_api.async_response
async def ws_start_preview(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Generate websocket handler for the camera still/stream preview."""
    _LOGGER.debug("Generating websocket handler for generic camera preview")

    flow_id = msg["flow_id"]
    flow: GenericIPCamConfigFlow | GenericOptionsFlowHandler
    if msg.get("flow_type", "config_flow") == "config_flow":
        flow = cast(
            GenericIPCamConfigFlow,
            hass.config_entries.flow._progress.get(flow_id),  # noqa: SLF001
        )
    else:  # (flow type == "options flow")
        flow = cast(
            GenericOptionsFlowHandler,
            hass.config_entries.options._progress.get(flow_id),  # noqa: SLF001
        )
    user_input = flow.preview_image_settings

    # Create PlatformData, needed for name translations
    platform_data = PlatformData(hass=hass, domain=CAMERA_DOMAIN, platform_name=DOMAIN)
    await platform_data.async_load_translations()

    ha_still_url = None
    ha_stream_url = None

    if user_input.get(CONF_STILL_IMAGE_URL):
        ha_still_url = f"/api/generic/preview_flow_image/{msg['flow_id']}?t={datetime.now().isoformat()}"
        _LOGGER.debug("Got preview still URL: %s", ha_still_url)

    if ha_stream := flow.preview_stream:
        # HLS player needs an absolute URL as base for constructing child playlist URLs
        ha_stream_url = f"{get_url(hass)}{ha_stream.endpoint_url(HLS_PROVIDER)}"
        _LOGGER.debug("Got preview stream URL: %s", ha_stream_url)

    connection.send_message(
        websocket_api.event_message(
            msg["id"],
            {"attributes": {"still_url": ha_still_url, "stream_url": ha_stream_url}},
        )
    )
