"""Support for tracking people."""
from __future__ import annotations

import logging
from typing import cast

import voluptuous as vol

from homeassistant.auth import EVENT_USER_REMOVED
from homeassistant.components import persistent_notification, websocket_api
from homeassistant.components.device_tracker import (
    ATTR_SOURCE_TYPE,
    DOMAIN as DEVICE_TRACKER_DOMAIN,
    SOURCE_TYPE_GPS,
)
from homeassistant.const import (
    ATTR_EDITABLE,
    ATTR_ENTITY_ID,
    ATTR_GPS_ACCURACY,
    ATTR_ID,
    ATTR_LATITUDE,
    ATTR_LONGITUDE,
    ATTR_NAME,
    CONF_ID,
    CONF_NAME,
    CONF_TYPE,
    EVENT_HOMEASSISTANT_START,
    SERVICE_RELOAD,
    STATE_HOME,
    STATE_NOT_HOME,
    STATE_UNAVAILABLE,
    STATE_UNKNOWN,
)
from homeassistant.core import (
    Event,
    HomeAssistant,
    ServiceCall,
    State,
    callback,
    split_entity_id,
)
from homeassistant.helpers import (
    collection,
    config_validation as cv,
    entity_registry,
    service,
)
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass

_LOGGER = logging.getLogger(__name__)

ATTR_SOURCE = "source"
ATTR_USER_ID = "user_id"

CONF_DEVICE_TRACKERS = "device_trackers"
CONF_USER_ID = "user_id"
CONF_PICTURE = "picture"

DOMAIN = "person"

STORAGE_KEY = DOMAIN
STORAGE_VERSION = 2
# Device tracker states to ignore
IGNORE_STATES = (STATE_UNKNOWN, STATE_UNAVAILABLE)

PERSON_SCHEMA = vol.Schema(
    {
        vol.Required(CONF_ID): cv.string,
        vol.Required(CONF_NAME): cv.string,
        vol.Optional(CONF_USER_ID): cv.string,
        vol.Optional(CONF_DEVICE_TRACKERS, default=[]): vol.All(
            cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)
        ),
        vol.Optional(CONF_PICTURE): cv.string,
    }
)

CONFIG_SCHEMA = vol.Schema(
    {
        vol.Optional(DOMAIN, default=[]): vol.All(
            cv.ensure_list, cv.remove_falsy, [PERSON_SCHEMA]
        )
    },
    extra=vol.ALLOW_EXTRA,
)


@bind_hass
async def async_create_person(hass, name, *, user_id=None, device_trackers=None):
    """Create a new person."""
    await hass.data[DOMAIN][1].async_create_item(
        {
            ATTR_NAME: name,
            ATTR_USER_ID: user_id,
            CONF_DEVICE_TRACKERS: device_trackers or [],
        }
    )


@bind_hass
async def async_add_user_device_tracker(
    hass: HomeAssistant, user_id: str, device_tracker_entity_id: str
):
    """Add a device tracker to a person linked to a user."""
    coll = cast(PersonStorageCollection, hass.data[DOMAIN][1])

    for person in coll.async_items():
        if person.get(ATTR_USER_ID) != user_id:
            continue

        device_trackers = person[CONF_DEVICE_TRACKERS]

        if device_tracker_entity_id in device_trackers:
            return

        await coll.async_update_item(
            person[CONF_ID],
            {CONF_DEVICE_TRACKERS: device_trackers + [device_tracker_entity_id]},
        )
        break


CREATE_FIELDS = {
    vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)),
    vol.Optional(CONF_USER_ID): vol.Any(str, None),
    vol.Optional(CONF_DEVICE_TRACKERS, default=list): vol.All(
        cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)
    ),
    vol.Optional(CONF_PICTURE): vol.Any(str, None),
}


UPDATE_FIELDS = {
    vol.Optional(CONF_NAME): vol.All(str, vol.Length(min=1)),
    vol.Optional(CONF_USER_ID): vol.Any(str, None),
    vol.Optional(CONF_DEVICE_TRACKERS, default=list): vol.All(
        cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)
    ),
    vol.Optional(CONF_PICTURE): vol.Any(str, None),
}


class PersonStore(Store):
    """Person storage."""

    async def _async_migrate_func(self, old_major_version, old_minor_version, old_data):
        """Migrate to the new version.

        Migrate storage to use format of collection helper.
        """
        return {"items": old_data["persons"]}


class PersonStorageCollection(collection.StorageCollection):
    """Person collection stored in storage."""

    CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
    UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS)

    def __init__(
        self,
        store: Store,
        logger: logging.Logger,
        id_manager: collection.IDManager,
        yaml_collection: collection.YamlCollection,
    ) -> None:
        """Initialize a person storage collection."""
        super().__init__(store, logger, id_manager)
        self.yaml_collection = yaml_collection

    async def _async_load_data(self) -> dict | None:
        """Load the data.

        A past bug caused onboarding to create invalid person objects.
        This patches it up.
        """
        data = await super()._async_load_data()

        if data is None:
            return data

        for person in data["items"]:
            if person[CONF_DEVICE_TRACKERS] is None:
                person[CONF_DEVICE_TRACKERS] = []

        return data

    async def async_load(self) -> None:
        """Load the Storage collection."""
        await super().async_load()
        self.hass.bus.async_listen(
            entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, self._entity_registry_updated
        )

    async def _entity_registry_updated(self, event) -> None:
        """Handle entity registry updated."""
        if event.data["action"] != "remove":
            return

        entity_id = event.data[ATTR_ENTITY_ID]

        if split_entity_id(entity_id)[0] != "device_tracker":
            return

        for person in list(self.data.values()):
            if entity_id not in person[CONF_DEVICE_TRACKERS]:
                continue

            await self.async_update_item(
                person[CONF_ID],
                {
                    CONF_DEVICE_TRACKERS: [
                        devt
                        for devt in person[CONF_DEVICE_TRACKERS]
                        if devt != entity_id
                    ]
                },
            )

    async def _process_create_data(self, data: dict) -> dict:
        """Validate the config is valid."""
        data = self.CREATE_SCHEMA(data)

        if (user_id := data.get(CONF_USER_ID)) is not None:
            await self._validate_user_id(user_id)

        return data

    @callback
    def _get_suggested_id(self, info: dict) -> str:
        """Suggest an ID based on the config."""
        return info[CONF_NAME]

    async def _update_data(self, data: dict, update_data: dict) -> dict:
        """Return a new updated data object."""
        update_data = self.UPDATE_SCHEMA(update_data)

        user_id = update_data.get(CONF_USER_ID)

        if user_id is not None and user_id != data.get(CONF_USER_ID):
            await self._validate_user_id(user_id)

        return {**data, **update_data}

    async def _validate_user_id(self, user_id):
        """Validate the used user_id."""
        if await self.hass.auth.async_get_user(user_id) is None:
            raise ValueError("User does not exist")

        for persons in (self.data.values(), self.yaml_collection.async_items()):
            if any(person for person in persons if person.get(CONF_USER_ID) == user_id):
                raise ValueError("User already taken")


async def filter_yaml_data(hass: HomeAssistant, persons: list[dict]) -> list[dict]:
    """Validate YAML data that we can't validate via schema."""
    filtered = []
    person_invalid_user = []

    for person_conf in persons:
        user_id = person_conf.get(CONF_USER_ID)

        if user_id is not None and await hass.auth.async_get_user(user_id) is None:
            _LOGGER.error(
                "Invalid user_id detected for person %s",
                person_conf[CONF_ID],
            )
            person_invalid_user.append(
                f"- Person {person_conf[CONF_NAME]} (id: {person_conf[CONF_ID]}) points at invalid user {user_id}"
            )
            continue

        filtered.append(person_conf)

    if person_invalid_user:
        persistent_notification.async_create(
            hass,
            f"""
The following persons point at invalid users:

{"- ".join(person_invalid_user)}
            """,
            "Invalid Person Configuration",
            DOMAIN,
        )

    return filtered


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
    """Set up the person component."""
    entity_component = EntityComponent(_LOGGER, DOMAIN, hass)
    id_manager = collection.IDManager()
    yaml_collection = collection.YamlCollection(
        logging.getLogger(f"{__name__}.yaml_collection"), id_manager
    )
    storage_collection = PersonStorageCollection(
        PersonStore(hass, STORAGE_VERSION, STORAGE_KEY),
        logging.getLogger(f"{__name__}.storage_collection"),
        id_manager,
        yaml_collection,
    )

    collection.sync_entity_lifecycle(
        hass, DOMAIN, DOMAIN, entity_component, yaml_collection, Person
    )
    collection.sync_entity_lifecycle(
        hass, DOMAIN, DOMAIN, entity_component, storage_collection, Person.from_yaml
    )

    await yaml_collection.async_load(
        await filter_yaml_data(hass, config.get(DOMAIN, []))
    )
    await storage_collection.async_load()

    hass.data[DOMAIN] = (yaml_collection, storage_collection)

    collection.StorageCollectionWebsocket(
        storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
    ).async_setup(hass, create_list=False)

    websocket_api.async_register_command(hass, ws_list_person)

    async def _handle_user_removed(event: Event) -> None:
        """Handle a user being removed."""
        user_id = event.data[ATTR_USER_ID]
        for person in storage_collection.async_items():
            if person[CONF_USER_ID] == user_id:
                await storage_collection.async_update_item(
                    person[CONF_ID], {CONF_USER_ID: None}
                )

    hass.bus.async_listen(EVENT_USER_REMOVED, _handle_user_removed)

    async def async_reload_yaml(call: ServiceCall) -> None:
        """Reload YAML."""
        conf = await entity_component.async_prepare_reload(skip_reset=True)
        if conf is None:
            return
        await yaml_collection.async_load(
            await filter_yaml_data(hass, conf.get(DOMAIN, []))
        )

    service.async_register_admin_service(
        hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml
    )

    return True


class Person(RestoreEntity):
    """Represent a tracked person."""

    def __init__(self, config):
        """Set up person."""
        self._config = config
        self.editable = True
        self._latitude = None
        self._longitude = None
        self._gps_accuracy = None
        self._source = None
        self._state = None
        self._unsub_track_device = None

    @classmethod
    def from_yaml(cls, config):
        """Return entity instance initialized from yaml storage."""
        person = cls(config)
        person.editable = False
        return person

    @property
    def name(self):
        """Return the name of the entity."""
        return self._config[CONF_NAME]

    @property
    def entity_picture(self) -> str | None:
        """Return entity picture."""
        return self._config.get(CONF_PICTURE)

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

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

    @property
    def state(self):
        """Return the state of the person."""
        return self._state

    @property
    def extra_state_attributes(self):
        """Return the state attributes of the person."""
        data = {ATTR_EDITABLE: self.editable, ATTR_ID: self.unique_id}
        if self._latitude is not None:
            data[ATTR_LATITUDE] = self._latitude
        if self._longitude is not None:
            data[ATTR_LONGITUDE] = self._longitude
        if self._gps_accuracy is not None:
            data[ATTR_GPS_ACCURACY] = self._gps_accuracy
        if self._source is not None:
            data[ATTR_SOURCE] = self._source
        if (user_id := self._config.get(CONF_USER_ID)) is not None:
            data[ATTR_USER_ID] = user_id
        return data

    @property
    def unique_id(self):
        """Return a unique ID for the person."""
        return self._config[CONF_ID]

    async def async_added_to_hass(self):
        """Register device trackers."""
        await super().async_added_to_hass()
        if state := await self.async_get_last_state():
            self._parse_source_state(state)

        if self.hass.is_running:
            # Update person now if hass is already running.
            await self.async_update_config(self._config)
        else:
            # Wait for hass start to not have race between person
            # and device trackers finishing setup.
            async def person_start_hass(now):
                await self.async_update_config(self._config)

            self.hass.bus.async_listen_once(
                EVENT_HOMEASSISTANT_START, person_start_hass
            )

    async def async_update_config(self, config):
        """Handle when the config is updated."""
        self._config = config

        if self._unsub_track_device is not None:
            self._unsub_track_device()
            self._unsub_track_device = None

        if trackers := self._config[CONF_DEVICE_TRACKERS]:
            _LOGGER.debug("Subscribe to device trackers for %s", self.entity_id)

            self._unsub_track_device = async_track_state_change_event(
                self.hass, trackers, self._async_handle_tracker_update
            )

        self._update_state()

    @callback
    def _async_handle_tracker_update(self, event):
        """Handle the device tracker state changes."""
        self._update_state()

    @callback
    def _update_state(self):
        """Update the state."""
        latest_non_gps_home = latest_not_home = latest_gps = latest = None
        for entity_id in self._config[CONF_DEVICE_TRACKERS]:
            state = self.hass.states.get(entity_id)

            if not state or state.state in IGNORE_STATES:
                continue

            if state.attributes.get(ATTR_SOURCE_TYPE) == SOURCE_TYPE_GPS:
                latest_gps = _get_latest(latest_gps, state)
            elif state.state == STATE_HOME:
                latest_non_gps_home = _get_latest(latest_non_gps_home, state)
            elif state.state == STATE_NOT_HOME:
                latest_not_home = _get_latest(latest_not_home, state)

        if latest_non_gps_home:
            latest = latest_non_gps_home
        elif latest_gps:
            latest = latest_gps
        else:
            latest = latest_not_home

        if latest:
            self._parse_source_state(latest)
        else:
            self._state = None
            self._source = None
            self._latitude = None
            self._longitude = None
            self._gps_accuracy = None

        self.async_write_ha_state()

    @callback
    def _parse_source_state(self, state):
        """Parse source state and set person attributes.

        This is a device tracker state or the restored person state.
        """
        self._state = state.state
        self._source = state.entity_id
        self._latitude = state.attributes.get(ATTR_LATITUDE)
        self._longitude = state.attributes.get(ATTR_LONGITUDE)
        self._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY)


@websocket_api.websocket_command({vol.Required(CONF_TYPE): "person/list"})
def ws_list_person(
    hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg
):
    """List persons."""
    yaml, storage = hass.data[DOMAIN]
    connection.send_result(
        msg[ATTR_ID], {"storage": storage.async_items(), "config": yaml.async_items()}
    )


def _get_latest(prev: State | None, curr: State):
    """Get latest state."""
    if prev is None or curr.last_updated > prev.last_updated:
        return curr
    return prev
