"""Camera that loads a picture from a local file."""

from __future__ import annotations

import logging
import os
from pathlib import Path

import anyio
import voluptuous as vol
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST
from homeassistant.core import ServiceCall
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from . import MailAndPackagesConfigEntry, const
from .const import (
    ATTR_IMAGE_PATH,
    ATTR_USPS_IMAGE,
    CAMERA_DATA,
    CONF_CUSTOM_IMG,
    CONF_CUSTOM_IMG_FILE,
    CONF_DURATION,
    CONF_POST_DE_CUSTOM_IMG,
    CONF_POST_DE_CUSTOM_IMG_FILE,
    DOMAIN,
    SENSOR_NAME,
    VERSION,
)
from .utils.image import cleanup_images, generate_delivery_gif, resize_images

SERVICE_UPDATE_IMAGE = "update_image"
_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(
    hass,
    config: MailAndPackagesConfigEntry,
    async_add_entities,
):
    """Set up the Camera that works with local files."""
    coordinator = config.runtime_data.coordinator
    camera = []

    for variable in CAMERA_DATA:
        temp_cam = MailCam(hass, variable, config, coordinator)
        camera.append(temp_cam)
        config.runtime_data.cameras.append(temp_cam)

    async def _update_image(service: ServiceCall) -> None:
        """Refresh camera image."""
        _LOGGER.debug("Updating image: %s", service)
        cameras = config.runtime_data.cameras
        entity_id = None

        if ATTR_ENTITY_ID in service.data:
            entity_id = service.data[ATTR_ENTITY_ID]

        # Update all cameras if no entity_id
        if entity_id is None:
            for cam in cameras:
                await cam.update_file_path()

        else:
            for cam in cameras:
                if cam.entity_id in entity_id:
                    await cam.update_file_path()
        return True

    hass.services.async_register(
        DOMAIN,
        SERVICE_UPDATE_IMAGE,
        _update_image,
        schema=vol.Schema(
            {
                vol.Optional(ATTR_ENTITY_ID): vol.Coerce(str),
            },
        ),
    )

    async_add_entities(camera)


class MailCam(CoordinatorEntity, Camera):
    """Representation of a local file camera."""

    def __init__(
        self,
        hass,
        name: str,
        config: ConfigEntry,
        coordinator,
    ) -> None:
        """Initialize Local File Camera component."""
        CoordinatorEntity.__init__(self, coordinator)
        Camera.__init__(self)

        self.hass = hass
        self.config = config
        self._name = CAMERA_DATA[name][SENSOR_NAME]
        self._type = name
        self._host = config.data.get(CONF_HOST)
        self._unique_id = config.entry_id

        # Derive config keys and default image from camera type name
        # Remove "_camera" suffix to get base name (e.g., "usps_camera" -> "usps")
        base_name = self._type.removesuffix("_camera")

        # USPS and Post DE use mail_none.gif, others use no_deliveries_*.jpg
        if base_name in ("usps", "post_de"):
            if base_name == "usps":
                custom_img_key = CONF_CUSTOM_IMG
                custom_img_file_key = CONF_CUSTOM_IMG_FILE
            else:
                custom_img_key = CONF_POST_DE_CUSTOM_IMG
                custom_img_file_key = CONF_POST_DE_CUSTOM_IMG_FILE
            default_image = "mail_none.gif"
        else:
            # Derive config key names dynamically (e.g., "amazon" -> CONF_AMAZON_CUSTOM_IMG)
            custom_img_key = getattr(
                const,
                f"CONF_{base_name.upper()}_CUSTOM_IMG",
                None,
            )
            custom_img_file_key = getattr(
                const,
                f"CONF_{base_name.upper()}_CUSTOM_IMG_FILE",
                None,
            )
            default_image = f"no_deliveries_{base_name}.jpg"

        # Set custom image paths
        self._no_mail = None
        if custom_img_key and config.data.get(custom_img_key):
            self._no_mail = config.data.get(custom_img_file_key)
            _LOGGER.debug(
                "%s camera - custom image enabled: %s",
                self._type,
                self._no_mail,
            )

        # Set initial file path based on camera type and custom settings
        if custom_img_key and config.data.get(custom_img_key):
            self._file_path = config.data.get(custom_img_file_key)
            _LOGGER.debug(
                "%s camera - initial file path set to: %s",
                self._type,
                self._file_path,
            )
        else:
            self._file_path = f"{Path(__file__).parent}/{default_image}"

        # Canonical bundled placeholder for this camera. Always available
        # regardless of later _file_path changes, so a missing delivery image
        # can fall back to it instead of returning None (which makes the HA
        # camera proxy serve HTTP 500).
        self._default_image_path = f"{Path(__file__).parent}/{default_image}"

        self._cached_image_path: str | None = None
        self._cached_image_bytes: bytes | None = None
        self._last_delivery_images: list[str] | None = None
        self._is_generic: bool = True

    async def async_camera_image(
        self,
        width: int | None = None,
        height: int | None = None,
    ) -> bytes | None:
        """Return image response."""
        if (
            self._file_path == self._cached_image_path
            and self._cached_image_bytes is not None
        ):
            return self._cached_image_bytes

        _LOGGER.debug(
            "Camera %s reading image from: %s",
            self._name,
            self._file_path,
        )

        def _read_file(path: str) -> bytes:
            with Path(path).open("rb") as f:
                data = f.read()
            if not data:
                # A 0-byte image (e.g. a failed extraction that wrote an empty
                # file) is as unservable as a missing one — raise so it routes
                # through the same placeholder fallback below.
                raise FileNotFoundError(f"empty image file: {path}")
            return data

        try:
            image_bytes = await self.hass.async_add_executor_job(
                _read_file, self._file_path
            )
        except FileNotFoundError:
            _LOGGER.debug(
                "Could not read camera %s image from file: %s; "
                "falling back to bundled placeholder %s",
                self._name,
                self._file_path,
                self._default_image_path,
            )
            # Fall back to the bundled placeholder so the camera proxy never
            # serves HTTP 500 when a delivery image is missing on disk. Skip the
            # fallback when the primary path already IS the placeholder (true for
            # non-custom cameras, where _file_path and _default_image_path are
            # both built from the same default image): re-reading the same
            # missing file would only fail again, so return None instead.
            if self._file_path != self._default_image_path:
                try:
                    image_bytes = await self.hass.async_add_executor_job(
                        _read_file, self._default_image_path
                    )
                except FileNotFoundError:
                    _LOGGER.warning(
                        "Camera %s placeholder image also missing: %s",
                        self._name,
                        self._default_image_path,
                    )
                    return None
                else:
                    # Cache against the placeholder path so repeated reads are
                    # cheap; update_file_path() invalidates the cache when the
                    # primary path changes.
                    self._cached_image_path = self._default_image_path
                    self._cached_image_bytes = image_bytes
                    return image_bytes
            return None
        else:
            self._cached_image_path = self._file_path
            self._cached_image_bytes = image_bytes
            return image_bytes

    def check_file_path_access(self, file_path: str) -> None:
        """Check that filepath given is readable."""
        if not os.access(file_path, os.R_OK):
            _LOGGER.debug(
                "Could not read camera %s image from file: %s",
                self._name,
                file_path,
            )

    async def update_file_path(self) -> None:
        """Update the file_path."""
        _LOGGER.debug("Camera Update: %s", self._type)
        _LOGGER.debug("Custom No Mail: %s", self._no_mail)

        if not self.coordinator.last_update_success:
            _LOGGER.debug("Update to update camera image. Unavailable.")
            return

        if self.coordinator.data is None:
            _LOGGER.debug("Unable to update camera image, no data.")
            return

        if self._type == "usps_camera":
            self._update_usps_camera()
        elif self._type == "generic_camera":
            await self._update_generic_camera()
        else:
            await self._update_standard_camera()

        # Invalidate the cache so the next frontend request re-reads the image from disk.
        # This is necessary because for some shippers (like USPS), the image file path
        # stays the same but the underlying image content on disk changes.
        self._cached_image_path = None
        self._cached_image_bytes = None

        self.check_file_path_access(self._file_path)
        self.schedule_update_ha_state()

    def _update_usps_camera(self) -> None:
        """Update file path for USPS camera."""
        self._file_path = f"{Path(__file__).parent}/mail_none.gif"
        self._is_generic = True
        required_keys = {ATTR_USPS_IMAGE, ATTR_IMAGE_PATH}
        if required_keys.issubset(self.coordinator.data):
            image = self.coordinator.data[ATTR_USPS_IMAGE]
            path = self.coordinator.data[ATTR_IMAGE_PATH]
            self._file_path = self.hass.config.path(path, image)
            self._is_generic = not self.coordinator.data.get("usps_update", False)
            _LOGGER.debug(
                "usps_camera camera - file path set to: %s",
                self._file_path,
            )
        elif self._no_mail:
            self._file_path = self._no_mail

    async def _update_generic_camera(self) -> None:
        """Update file path for Generic camera."""
        if self._no_mail:
            self._file_path = self._no_mail
            self._is_generic = True
            _LOGGER.debug("Generic camera - using custom no mail: %s", self._file_path)
            return

        delivery_images = self._collect_generic_delivery_images()

        if (
            self._last_delivery_images is not None
            and delivery_images == self._last_delivery_images
        ):
            _LOGGER.debug(
                "Generic camera - delivery images unchanged, skipping GIF regeneration"
            )
            return

        self._last_delivery_images = delivery_images

        if not delivery_images:
            self._file_path = f"{Path(__file__).parent}/no_deliveries_generic.jpg"
            self._is_generic = True
            _LOGGER.debug(
                "Generic camera - no deliveries found, using default: %s",
                self._file_path,
            )
            return

        image_path = self.coordinator.data.get(ATTR_IMAGE_PATH, "")
        full_storage_path = Path(self.hass.config.path(image_path))
        gif_path = str(full_storage_path / "generic_deliveries.gif")

        resized_images = await self.hass.async_add_executor_job(
            resize_images, delivery_images, 800, 600
        )

        duration = self.config.data.get(CONF_DURATION, 5) * 1000
        gif_created = await self.hass.async_add_executor_job(
            generate_delivery_gif,
            resized_images,
            gif_path,
            duration,
        )

        if gif_created:
            self._file_path = gif_path
            self._is_generic = False
            _LOGGER.debug(
                "Generic camera - created animated GIF with %d delivery images at %s",
                len(delivery_images),
                gif_path,
            )
        else:
            _LOGGER.warning(
                "Failed to create animated GIF, using first delivery image",
            )
            self._file_path = delivery_images[0]
            self._is_generic = False

        for img in resized_images:
            if await anyio.Path(img).exists():
                await self.hass.async_add_executor_job(
                    cleanup_images, str(Path(img).parent) + "/", Path(img).name
                )

    def _collect_generic_delivery_images(self) -> list[str]:
        """Collect delivery images for the generic camera."""
        delivery_images = []
        enabled_resources = self.config.data.get("resources", [])

        for camera_type in CAMERA_DATA:
            # Skip generic, USPS, and Post DE cameras
            if camera_type in ("generic_camera", "usps_camera", "post_de_camera"):
                continue

            base_name = camera_type.removesuffix("_camera")
            delivered_key = f"{base_name}_delivered"

            # Check if this shipper's delivery sensor is enabled
            if delivered_key not in enabled_resources:
                _LOGGER.debug(
                    "Generic camera - skipping %s (sensor %s not enabled)",
                    base_name,
                    delivered_key,
                )
                continue

            # Set image attributes
            image_attr_name = f"ATTR_{base_name.upper()}_IMAGE"
            image_attr = getattr(const, image_attr_name, None)
            path_suffix = f"{base_name}/"
            no_mail_check = "no_deliveries"

            required_keys = {image_attr, ATTR_IMAGE_PATH}
            if not required_keys.issubset(self.coordinator.data):
                continue

            image = self.coordinator.data[image_attr]
            path = f"{self.coordinator.data[ATTR_IMAGE_PATH]}{path_suffix}"
            delivery_file_path = self.hass.config.path(path, image)

            is_no_mail = image.startswith(
                no_mail_check,
            ) or self._is_custom_no_mail_image(base_name, delivery_file_path)

            delivery_count_key = f"{base_name}_delivered"
            has_current_deliveries = (
                delivery_count_key in self.coordinator.data
                and self.coordinator.data[delivery_count_key] > 0
            )

            if (
                not is_no_mail
                and Path(delivery_file_path).exists()
                and has_current_deliveries
            ):
                delivery_images.append(delivery_file_path)
            elif is_no_mail:
                _LOGGER.debug(
                    "Generic camera - filtered out %s no-mail image: %s",
                    base_name,
                    image,
                )
            elif not has_current_deliveries:
                _LOGGER.debug(
                    "Generic camera - filtered out %s (no current deliveries, count=%s): %s",
                    base_name,
                    self.coordinator.data.get(delivery_count_key, 0),
                    image,
                )

        return delivery_images

    async def _update_standard_camera(self) -> None:
        """Update file path for standard cameras (Amazon, UPS, etc)."""
        base_name = self._type.removesuffix("_camera")
        if base_name == "post_de":
            self._file_path = f"{Path(__file__).parent}/mail_none.gif"
        else:
            self._file_path = f"{Path(__file__).parent}/no_deliveries_{base_name}.jpg"
        self._is_generic = True

        if self._no_mail:
            self._file_path = self._no_mail
            _LOGGER.debug(
                "%s camera - using custom no mail: %s",
                self._type,
                self._file_path,
            )
            return

        image_attr_name = f"ATTR_{base_name.upper()}_IMAGE"
        image_attr = getattr(const, image_attr_name, None)

        if not image_attr:
            return

        required_keys = {image_attr, ATTR_IMAGE_PATH}
        if not required_keys.issubset(self.coordinator.data):
            return

        image = self.coordinator.data[image_attr]
        image_path = self.coordinator.data[ATTR_IMAGE_PATH].rstrip("/") + "/"
        path = f"{image_path}{base_name}/"
        coordinator_file_path = self.hass.config.path(path, image)

        _LOGGER.debug(
            "=== %s CAMERA UPDATE === coordinator %s = '%s'",
            self._type,
            image_attr,
            image,
        )

        # Log all image-related keys in coordinator for this camera
        all_image_keys = {
            k: self.coordinator.data.get(k, "NOT SET")
            for k in self.coordinator.data
            if "image" in k.lower()
        }
        _LOGGER.debug(
            "%s camera - All image keys in coordinator: %s",
            self._type,
            all_image_keys,
        )

        if await anyio.Path(coordinator_file_path).exists() and os.access(
            coordinator_file_path,
            os.R_OK,
        ):
            self._file_path = coordinator_file_path
            _LOGGER.debug(
                "%s camera - found coordinator file: %s",
                self._type,
                self._file_path,
            )
        else:
            await self._find_alternative_image(coordinator_file_path, image)

        # Coordinator hashes the delivery image against the placeholder to detect
        # whether a real email image was saved; use that result rather than guessing
        # from file existence (the placeholder may have been copied to the same path).
        self._is_generic = not self.coordinator.data.get(f"{base_name}_update", False)

    async def _find_alternative_image(
        self,
        coordinator_file_path: str,
        expected_image: str,
    ) -> None:
        """Attempt to find an alternative image in the directory."""
        path_dir = Path(coordinator_file_path).parent
        _LOGGER.debug(
            "%s camera - coordinator file not found: %s",
            self._type,
            coordinator_file_path,
        )

        # Define a helper to run blocking I/O in the executor
        def _scan_images():
            if not path_dir.exists():
                _LOGGER.debug(
                    "%s camera - directory does not exist: %s",
                    self._type,
                    path_dir,
                )
                return None

            try:
                found_images = []
                for file_path in path_dir.iterdir():
                    if file_path.name.lower().endswith(
                        (".jpg", ".jpeg", ".png", ".gif"),
                    ):
                        if file_path.exists() and os.access(file_path, os.R_OK):
                            if "no_deliveries" not in file_path.name:
                                found_images.append(
                                    (str(file_path), file_path.stat().st_mtime),
                                )
            except OSError as err:
                _LOGGER.debug(
                    "%s camera - error listing directory %s: %s",
                    self._type,
                    path_dir,
                    err,
                )
                return None
            else:
                return found_images

        # Execute the scan in a background thread
        image_files = await self.hass.async_add_executor_job(_scan_images)

        if image_files:
            image_files.sort(key=lambda x: x[1], reverse=True)
            self._file_path = image_files[0][0]
            _LOGGER.debug(
                "%s camera - found alternative image file (most recent): %s",
                self._type,
                self._file_path,
            )

    def _is_custom_no_mail_image(self, base_name: str, file_path: str) -> bool:
        """Check if the given file path is a custom 'no mail' image for the specified camera.

        Args:
            base_name: The base name of the camera (e.g., 'amazon', 'ups', 'walmart', 'usps')
            file_path: The full file path to check

        Returns:
            True if this is a custom 'no mail' image, False otherwise

        """
        # Handle USPS camera differently (uses CONF_CUSTOM_IMG instead of CONF_USPS_CUSTOM_IMG)
        if base_name == "usps":
            custom_img_key = "CONF_CUSTOM_IMG"
            custom_img_file_key = "CONF_CUSTOM_IMG_FILE"
        else:
            # Handle other cameras (Amazon, UPS, Walmart)
            custom_img_key = f"CONF_{base_name.upper()}_CUSTOM_IMG"
            custom_img_file_key = f"CONF_{base_name.upper()}_CUSTOM_IMG_FILE"

        custom_img_conf = getattr(const, custom_img_key, None)
        custom_img_file_conf = getattr(const, custom_img_file_key, None)

        if (
            custom_img_conf
            and custom_img_file_conf
            and self.config.data.get(custom_img_conf)
        ):
            custom_file_path = self.config.data.get(custom_img_file_conf)
            if custom_file_path and Path(custom_file_path).exists():
                # Check if the file path matches the custom "no mail" image
                return Path(file_path).resolve() == Path(custom_file_path).resolve()

        return False

    def _get_sensor_name_for_camera(self, camera_type: str) -> str:
        """Get the sensor name that corresponds to a camera type.

        Args:
            camera_type: The camera type (e.g., 'amazon_camera', 'ups_camera', etc.)

        Returns:
            The corresponding sensor name, or None if no mapping exists

        """
        # Remove "_camera" suffix to get base name
        base_name = camera_type.removesuffix("_camera")

        # Special cases for mail scans cameras
        if base_name == "usps":
            return "usps_mail"
        if base_name == "post_de":
            return "post_de_mail"

        # For other cameras, use the pattern: {base_name}_delivered
        return f"{base_name}_delivered"

    async def async_added_to_hass(self) -> None:
        """Run when entity is added to hass."""
        await super().async_added_to_hass()
        if self.coordinator.data is not None:
            await self.update_file_path()

    async def async_on_demand_update(self):
        """Update state."""
        self.async_schedule_update_ha_state(True)

    @property
    def device_info(self) -> dict:
        """Return device information about the mailbox."""
        return {
            "connections": {(DOMAIN, self._unique_id)},
            "name": self._host,
            "manufacturer": "IMAP E-Mail",
            "sw_version": VERSION,
        }

    @property
    def unique_id(self) -> str:
        """Return a unique, Home Assistant friendly identifier for this entity."""
        return f"camera_{self._host}_{self._type}_{self._unique_id}"

    @property
    def name(self):
        """Return the name of this camera."""
        return self._name

    @property
    def extra_state_attributes(self):
        """Return the camera state attributes."""
        return {
            "file_path": self._file_path,
            "is_generic": self._is_generic,
        }

    @property
    def should_poll(self) -> bool:
        """Return True if entity has to be polled for state.

        False if entity pushes its state to HA.
        """
        return False

    @property
    def available(self) -> bool:
        """Return if entity is available."""
        return self.coordinator.data is not None

    def _handle_coordinator_update(self) -> None:
        """Handle updated data from the coordinator."""
        _LOGGER.debug(
            "%s camera - coordinator update received, updating file path",
            self._type,
        )
        self.hass.async_create_task(self._async_handle_coordinator_update())

    async def _async_handle_coordinator_update(self) -> None:
        """Update file path then write state so the frontend gets the correct image URL."""
        await self.update_file_path()
        self.async_write_ha_state()
