"""The Alarmo Integration."""

import re
import base64
import logging
import concurrent.futures

import bcrypt
from homeassistant.core import (
    HomeAssistant,
    asyncio,
    callback,
)
from homeassistant.const import (
    ATTR_CODE,
    ATTR_NAME,
)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.service import (
    async_register_admin_service,
)
from homeassistant.helpers.dispatcher import (
    async_dispatcher_send,
    async_dispatcher_connect,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.components.alarm_control_panel import DOMAIN as PLATFORM

from . import const
from .card import async_register_card
from .mqtt import MqttHandler
from .event import EventHandler
from .panel import (
    async_register_panel,
    async_unregister_panel,
)
from .store import async_get_registry
from .sensors import (
    ATTR_GROUP,
    ATTR_ENTITIES,
    ATTR_NEW_ENTITY_ID,
    SensorHandler,
)
from .websockets import async_register_websockets
from .automations import AutomationHandler

_LOGGER = logging.getLogger(__name__)

# Max number of threads to start when checking user codes.
MAX_WORKERS = 4
# Number of rounds of hashing when computing user hashes.
BCRYPT_NUM_ROUNDS = 10


async def async_setup(hass, config):
    """Track states and offer events for sensors."""
    return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
    """Set up Alarmo integration from a config entry."""
    session = async_get_clientsession(hass)

    store = await async_get_registry(hass)
    coordinator = AlarmoCoordinator(hass, session, entry, store)

    device_registry = dr.async_get(hass)
    device_registry.async_get_or_create(
        config_entry_id=entry.entry_id,
        identifiers={(const.DOMAIN, coordinator.id)},
        name=const.NAME,
        model=const.NAME,
        sw_version=const.VERSION,
        manufacturer=const.MANUFACTURER,
    )

    hass.data.setdefault(const.DOMAIN, {})
    hass.data[const.DOMAIN] = {"coordinator": coordinator, "areas": {}, "master": None}

    if entry.unique_id is None:
        hass.config_entries.async_update_entry(entry, unique_id=coordinator.id, data={})

    await hass.config_entries.async_forward_entry_setups(entry, [PLATFORM])

    # Register the panel (frontend)
    await async_register_panel(hass)
    await async_register_card(hass)

    # Websocket support
    await async_register_websockets(hass)

    # Register custom services
    register_services(hass)

    return True


async def async_unload_entry(hass, entry):
    """Unload Alarmo config entry."""
    unload_ok = all(
        await asyncio.gather(
            *[hass.config_entries.async_forward_entry_unload(entry, PLATFORM)]
        )
    )
    if not unload_ok:
        return False

    async_unregister_panel(hass)
    coordinator = hass.data[const.DOMAIN]["coordinator"]
    await coordinator.async_unload()
    return True


async def async_remove_entry(hass, entry):
    """Remove Alarmo config entry."""
    async_unregister_panel(hass)
    coordinator = hass.data[const.DOMAIN]["coordinator"]
    await coordinator.async_delete_config()
    del hass.data[const.DOMAIN]


async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    """Handle migration of config entry."""
    return True


class AlarmoCoordinator(DataUpdateCoordinator):
    """Define an object to hold Alarmo device."""

    def __init__(self, hass, session, entry, store):
        """Initialize."""
        self.id = entry.unique_id
        self.hass = hass
        self.entry = entry
        self.store = store
        self._subscriptions = []

        self._subscriptions.append(
            async_dispatcher_connect(
                hass, "alarmo_platform_loaded", self.setup_alarm_entities
            )
        )
        self.register_events()

        super().__init__(hass, _LOGGER, config_entry=entry, name=const.DOMAIN)

    @callback
    def setup_alarm_entities(self):
        """Set up alarm_control_panel entities based on areas in storage."""
        self.hass.data[const.DOMAIN]["sensor_handler"] = SensorHandler(self.hass)
        self.hass.data[const.DOMAIN]["automation_handler"] = AutomationHandler(
            self.hass
        )
        self.hass.data[const.DOMAIN]["mqtt_handler"] = MqttHandler(self.hass)
        self.hass.data[const.DOMAIN]["event_handler"] = EventHandler(self.hass)

        areas = self.store.async_get_areas()
        config = self.store.async_get_config()

        for item in areas.values():
            async_dispatcher_send(self.hass, "alarmo_register_entity", item)

        if len(areas) > 1 and config["master"]["enabled"]:
            async_dispatcher_send(self.hass, "alarmo_register_master", config["master"])

    async def async_update_config(self, data):
        """Update the main configuration."""
        if "master" in data:
            old_config = self.store.async_get_config()
            if old_config[const.ATTR_MASTER] != data["master"]:
                if self.hass.data[const.DOMAIN]["master"]:
                    await self.async_remove_entity("master")
                if data["master"]["enabled"]:
                    async_dispatcher_send(
                        self.hass, "alarmo_register_master", data["master"]
                    )
                else:
                    automations = self.hass.data[const.DOMAIN][
                        "automation_handler"
                    ].get_automations_by_area(None)
                    if len(automations):
                        for el in automations:
                            self.store.async_delete_automation(el)
                        async_dispatcher_send(self.hass, "alarmo_automations_updated")

        self.store.async_update_config(data)
        async_dispatcher_send(self.hass, "alarmo_config_updated")

    async def async_update_area_config(
        self,
        area_id: str | None = None,
        data: dict = {},
    ):
        """Update area configuration."""
        if const.ATTR_REMOVE in data:
            # delete an area
            res = self.store.async_get_area(area_id)
            if not res:
                return
            sensors = self.store.async_get_sensors()
            sensors = dict(filter(lambda el: el[1]["area"] == area_id, sensors.items()))
            if sensors:
                for el in sensors.keys():
                    self.store.async_delete_sensor(el)
                async_dispatcher_send(self.hass, "alarmo_sensors_updated")

            automations = self.hass.data[const.DOMAIN][
                "automation_handler"
            ].get_automations_by_area(area_id)
            if len(automations):
                for el in automations:
                    self.store.async_delete_automation(el)
                async_dispatcher_send(self.hass, "alarmo_automations_updated")

            self.store.async_delete_area(area_id)
            await self.async_remove_entity(area_id)

            if (
                len(self.store.async_get_areas()) == 1
                and self.hass.data[const.DOMAIN]["master"]
            ):
                await self.async_remove_entity("master")

        elif self.store.async_get_area(area_id):
            # modify an area
            entry = self.store.async_update_area(area_id, data)
            if "name" not in data:
                async_dispatcher_send(self.hass, "alarmo_config_updated", area_id)
            else:
                await self.async_remove_entity(area_id)
                async_dispatcher_send(self.hass, "alarmo_register_entity", entry)
        else:
            # create an area
            entry = self.store.async_create_area(data)
            async_dispatcher_send(self.hass, "alarmo_register_entity", entry)

            config = self.store.async_get_config()

            if len(self.store.async_get_areas()) == 2 and config["master"]["enabled"]:
                async_dispatcher_send(
                    self.hass, "alarmo_register_master", config["master"]
                )

    def async_update_sensor_config(self, entity_id: str, data: dict):
        """Update sensor configuration."""
        group = None
        if ATTR_GROUP in data:
            group = data[ATTR_GROUP]
            del data[ATTR_GROUP]

        if ATTR_NEW_ENTITY_ID in data:
            # delete old sensor entry when changing the entity_id
            new_entity_id = data[ATTR_NEW_ENTITY_ID]
            del data[ATTR_NEW_ENTITY_ID]
            self.store.async_delete_sensor(entity_id)
            self.assign_sensor_to_group(new_entity_id, group)
            self.assign_sensor_to_group(entity_id, None)
            entity_id = new_entity_id

        if const.ATTR_REMOVE in data:
            self.store.async_delete_sensor(entity_id)
            self.assign_sensor_to_group(entity_id, None)
        elif self.store.async_get_sensor(entity_id):
            self.store.async_update_sensor(entity_id, data)
            self.assign_sensor_to_group(entity_id, group)
        else:
            self.store.async_create_sensor(entity_id, data)
            self.assign_sensor_to_group(entity_id, group)

        async_dispatcher_send(self.hass, "alarmo_sensors_updated")

    async def _validate_user_code(self, user_id: str, data: dict):
        user_with_code = await self.async_authenticate_user(data[ATTR_CODE])
        if user_id:
            if const.ATTR_OLD_CODE not in data:
                return "No code provided"
            if not await self.async_authenticate_user(
                data[const.ATTR_OLD_CODE], user_id
            ):
                return "Wrong code provided"
            if user_with_code and user_with_code[const.ATTR_USER_ID] != user_id:
                return "User with same code already exists"
        elif user_with_code:
            return "User with same code already exists"
        return

    def _validate_user_name(self, user_id: str, data: dict):
        if not data[ATTR_NAME]:
            return "User name must not be empty"
        for user in self.store.async_get_users().values():
            if (
                data[ATTR_NAME] == user[ATTR_NAME]
                and user_id != user[const.ATTR_USER_ID]
            ):
                return "User with same name already exists"
        return

    async def async_update_user_config(
        self, user_id: str | None = None, data: dict = {}
    ):
        """Update user configuration."""
        if const.ATTR_REMOVE in data:
            self.store.async_delete_user(user_id)
            return

        if ATTR_NAME in data:
            err = self._validate_user_name(user_id, data)
            if err:
                _LOGGER.error(err)
                return err
        if ATTR_CODE in data:
            err = await self._validate_user_code(user_id, data)
            if err:
                _LOGGER.error(err)
                return err

        if data.get(ATTR_CODE):
            data[const.ATTR_CODE_FORMAT] = (
                "number" if data[ATTR_CODE].isdigit() else "text"
            )
            data[const.ATTR_CODE_LENGTH] = len(data[ATTR_CODE])

            def _hash_code(code):
                hashed = bcrypt.hashpw(
                    code.encode("utf-8"),
                    bcrypt.gensalt(rounds=BCRYPT_NUM_ROUNDS),
                )
                return base64.b64encode(hashed).decode()

            data[ATTR_CODE] = await self.hass.async_add_executor_job(
                _hash_code, data[ATTR_CODE]
            )

        if not user_id:
            self.store.async_create_user(data)
            return
        else:
            if ATTR_CODE in data:
                del data[const.ATTR_OLD_CODE]
            self.store.async_update_user(user_id, data)
            return

    async def async_authenticate_user(self, code, user_id=None):
        """Run user authentication in the executor.

        This wrapper ensures that the blocking bcrypt-based authentication
        is executed outside of the event loop, making it safe to call
        from async context.
        """

        def _authenticate_user_sync():
            """Perform user authentication using blocking bcrypt operations."""

            def check_user_code(user, code):
                """Returns None or the supplied user object if the code matches."""
                if not user[const.ATTR_ENABLED]:
                    return None
                elif not user[ATTR_CODE] and not code:
                    return user
                elif user[ATTR_CODE]:
                    try:
                        hash = base64.b64decode(user[ATTR_CODE])
                    except Exception:
                        return None
                    if bcrypt.checkpw((code or "").encode("utf-8"), hash):
                        return user

            if user_id:
                return check_user_code(self.store.async_get_user(user_id), code)

            users = self.store.async_get_users()
            with concurrent.futures.ThreadPoolExecutor(
                max_workers=MAX_WORKERS
            ) as executor:
                futures = [
                    executor.submit(check_user_code, user, code)
                    for user in users.values()
                ]
                for future in concurrent.futures.as_completed(futures):
                    if future.result():
                        executor.shutdown(wait=False, cancel_futures=True)
                        return future.result()

        return await self.hass.async_add_executor_job(_authenticate_user_sync)

    def async_update_automation_config(
        self,
        automation_id: str | None = None,
        data: dict = {},
    ):
        """Update automation configuration."""
        if const.ATTR_REMOVE in data:
            self.store.async_delete_automation(automation_id)
        elif not automation_id:
            self.store.async_create_automation(data)
        else:
            self.store.async_update_automation(automation_id, data)

        async_dispatcher_send(self.hass, "alarmo_automations_updated")

    def register_events(self):
        """Register event handlers."""

        # handle push notifications with action buttons
        @callback
        async def async_handle_push_event(event):
            if not event.data:
                return
            action = (
                event.data.get("actionName")
                if "actionName" in event.data
                else event.data.get("action")
            )

            if action not in const.EVENT_ACTIONS:
                return

            if self.hass.data[const.DOMAIN]["master"]:
                alarm_entity = self.hass.data[const.DOMAIN]["master"]
            elif len(self.hass.data[const.DOMAIN]["areas"]) == 1:
                alarm_entity = next(
                    iter(self.hass.data[const.DOMAIN]["areas"].values())
                )
            else:
                _LOGGER.info(
                    "Cannot process the push action, since there are multiple areas."
                )
                return

            if action == const.EVENT_ACTION_DISARM:
                _LOGGER.info("Received request for disarming")
                await alarm_entity.async_alarm_disarm(None, skip_code=True)
                return

            arm_mode = (
                alarm_entity._revert_state
                if alarm_entity._revert_state in const.ARM_MODES
                else alarm_entity._arm_mode
            )
            res = re.search(r"^ALARMO_ARM_", action)
            if res:
                arm_mode = action.replace("ALARMO_", "").lower().replace("arm", "armed")
            if not arm_mode:
                _LOGGER.info(
                    "Cannot process the push action, since the arm mode is not known."
                )
                return

            if action == const.EVENT_ACTION_FORCE_ARM:
                _LOGGER.info("Received request for force arming")
                await alarm_entity.async_handle_arm_request(
                    arm_mode, skip_code=True, bypass_open_sensors=True
                )
            elif action == const.EVENT_ACTION_RETRY_ARM:
                _LOGGER.info("Received request for retry arming")
                await alarm_entity.async_handle_arm_request(arm_mode, skip_code=True)
            else:
                _LOGGER.info(
                    "Received request for arming with mode %s",
                    arm_mode,
                )
                await alarm_entity.async_handle_arm_request(arm_mode, skip_code=True)

        self._subscriptions.append(
            self.hass.bus.async_listen(const.PUSH_EVENT, async_handle_push_event)
        )

    async def async_remove_entity(self, area_id: str):
        """Remove an alarm_control_panel entity."""
        entity_registry = er.async_get(self.hass)
        if area_id == "master":
            entity = self.hass.data[const.DOMAIN]["master"]
            entity_registry.async_remove(entity.entity_id)
            self.hass.data[const.DOMAIN]["master"] = None
        else:
            entity = self.hass.data[const.DOMAIN]["areas"][area_id]
            entity_registry.async_remove(entity.entity_id)
            self.hass.data[const.DOMAIN]["areas"].pop(area_id, None)

    def async_get_sensor_groups(self):
        """Fetch a list of sensor groups (websocket API hook)."""
        groups = self.store.async_get_sensor_groups()
        return list(groups.values())

    def async_get_group_for_sensor(self, entity_id: str):
        """Fetch the group ID for a given sensor."""
        groups = self.async_get_sensor_groups()
        result = next((el for el in groups if entity_id in el[ATTR_ENTITIES]), None)
        return result["group_id"] if result else None

    def assign_sensor_to_group(self, entity_id: str, group_id: str):
        """Assign a sensor to a group."""
        updated = False
        old_group = self.async_get_group_for_sensor(entity_id)
        if old_group and group_id != old_group:
            # remove sensor from group
            el = self.store.async_get_sensor_group(old_group)
            if len(el[ATTR_ENTITIES]) > 2:
                self.store.async_update_sensor_group(
                    old_group,
                    {ATTR_ENTITIES: [x for x in el[ATTR_ENTITIES] if x != entity_id]},
                )
            else:
                self.store.async_delete_sensor_group(old_group)
            updated = True
        if group_id:
            # add sensor to group
            group = self.store.async_get_sensor_group(group_id)
            if not group:
                _LOGGER.error(
                    "Failed to assign entity %s to group %s",
                    entity_id,
                    group_id,
                )
            elif entity_id not in group[ATTR_ENTITIES]:
                self.store.async_update_sensor_group(
                    group_id, {ATTR_ENTITIES: group[ATTR_ENTITIES] + [entity_id]}
                )
                updated = True
        if updated:
            async_dispatcher_send(self.hass, "alarmo_sensors_updated")

    def async_update_sensor_group_config(
        self,
        group_id: str | None = None,
        data: dict = {},
    ):
        """Update sensor group configuration."""
        if const.ATTR_REMOVE in data:
            self.store.async_delete_sensor_group(group_id)
        elif not group_id:
            self.store.async_create_sensor_group(data)
        else:
            self.store.async_update_sensor_group(group_id, data)

        async_dispatcher_send(self.hass, "alarmo_sensors_updated")

    async def async_unload(self):
        """Remove all alarmo objects."""
        # remove alarm_control_panel entities
        areas = list(self.hass.data[const.DOMAIN]["areas"].keys())
        for area in areas:
            await self.async_remove_entity(area)
        if self.hass.data[const.DOMAIN]["master"]:
            await self.async_remove_entity("master")

        del self.hass.data[const.DOMAIN]["sensor_handler"]
        del self.hass.data[const.DOMAIN]["automation_handler"]
        del self.hass.data[const.DOMAIN]["mqtt_handler"]
        del self.hass.data[const.DOMAIN]["event_handler"]

        # remove subscriptions for coordinator
        while len(self._subscriptions):
            self._subscriptions.pop()()

    async def async_delete_config(self):
        """Wipe alarmo storage."""
        await self.store.async_delete()


@callback
def register_services(hass):
    """Register services used by alarmo component."""
    coordinator = hass.data[const.DOMAIN]["coordinator"]

    async def async_srv_toggle_user(call):
        """Enable a user by service call."""
        name = call.data.get(ATTR_NAME)
        enable = True if call.service == const.SERVICE_ENABLE_USER else False
        users = coordinator.store.async_get_users()
        user = next(
            (item for item in list(users.values()) if item[ATTR_NAME] == name), None
        )
        if user is None:
            _LOGGER.warning(
                "Failed to %s user, no match for name '%s'",
                "enable" if enable else "disable",
                name,
            )
            return

        coordinator.store.async_update_user(
            user[const.ATTR_USER_ID], {const.ATTR_ENABLED: enable}
        )
        _LOGGER.debug(
            "User user '%s' was %s", name, "enabled" if enable else "disabled"
        )

    async_register_admin_service(
        hass,
        const.DOMAIN,
        const.SERVICE_ENABLE_USER,
        async_srv_toggle_user,
        schema=const.SERVICE_TOGGLE_USER_SCHEMA,
    )
    async_register_admin_service(
        hass,
        const.DOMAIN,
        const.SERVICE_DISABLE_USER,
        async_srv_toggle_user,
        schema=const.SERVICE_TOGGLE_USER_SCHEMA,
    )
