"""Provide persistent configuration for the backup integration."""

from __future__ import annotations

from collections import defaultdict
from dataclasses import dataclass, field, replace
import datetime as dt
from datetime import datetime, timedelta
from enum import StrEnum
import random
from typing import TYPE_CHECKING, Self, TypedDict

from cronsim import CronSim

from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.event import async_call_later, async_track_point_in_time
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util

from .const import DOMAIN, LOGGER
from .models import BackupManagerError, Folder

if TYPE_CHECKING:
    from .manager import BackupManager, ManagerBackup

AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID = "automatic_backup_agents_unavailable"

CRON_PATTERN_DAILY = "{m} {h} * * *"
CRON_PATTERN_WEEKLY = "{m} {h} * * {d}"

# The default time for automatic backups to run is at 04:45.
# This time is chosen to be compatible with the time of the recorder's
# nightly job which runs at 04:12.
DEFAULT_BACKUP_TIME = dt.time(4, 45)

# Randomize the start time of the backup by up to 60 minutes to avoid
# all backups running at the same time.
BACKUP_START_TIME_JITTER = 60 * 60


class StoredBackupConfig(TypedDict):
    """Represent the stored backup config."""

    agents: dict[str, StoredAgentConfig]
    automatic_backups_configured: bool
    create_backup: StoredCreateBackupConfig
    last_attempted_automatic_backup: str | None
    last_completed_automatic_backup: str | None
    retention: StoredRetentionConfig
    schedule: StoredBackupSchedule


@dataclass(kw_only=True)
class BackupConfigData:
    """Represent loaded backup config data."""

    agents: dict[str, AgentConfig]
    automatic_backups_configured: bool  # only used by frontend
    create_backup: CreateBackupConfig
    last_attempted_automatic_backup: datetime | None = None
    last_completed_automatic_backup: datetime | None = None
    retention: RetentionConfig
    schedule: BackupSchedule

    @classmethod
    def from_dict(cls, data: StoredBackupConfig) -> Self:
        """Initialize backup config data from a dict."""
        include_folders_data = data["create_backup"]["include_folders"]
        if include_folders_data:
            include_folders = [Folder(folder) for folder in include_folders_data]
        else:
            include_folders = None
        retention = data["retention"]

        if last_attempted_str := data["last_attempted_automatic_backup"]:
            last_attempted = dt_util.parse_datetime(last_attempted_str)
        else:
            last_attempted = None

        if last_attempted_str := data["last_completed_automatic_backup"]:
            last_completed = dt_util.parse_datetime(last_attempted_str)
        else:
            last_completed = None

        if time_str := data["schedule"]["time"]:
            time = dt_util.parse_time(time_str)
        else:
            time = None
        days = [Day(day) for day in data["schedule"]["days"]]
        agents = {}
        for agent_id, agent_data in data["agents"].items():
            protected = agent_data["protected"]
            stored_retention = agent_data["retention"]
            agent_retention: AgentRetentionConfig | None
            if stored_retention:
                agent_retention = AgentRetentionConfig(
                    copies=stored_retention["copies"],
                    days=stored_retention["days"],
                )
            else:
                agent_retention = None
            agent_config = AgentConfig(
                protected=protected,
                retention=agent_retention,
            )
            agents[agent_id] = agent_config

        return cls(
            agents=agents,
            automatic_backups_configured=data["automatic_backups_configured"],
            create_backup=CreateBackupConfig(
                agent_ids=data["create_backup"]["agent_ids"],
                include_addons=data["create_backup"]["include_addons"],
                include_all_addons=data["create_backup"]["include_all_addons"],
                include_database=data["create_backup"]["include_database"],
                include_folders=include_folders,
                name=data["create_backup"]["name"],
                password=data["create_backup"]["password"],
            ),
            last_attempted_automatic_backup=last_attempted,
            last_completed_automatic_backup=last_completed,
            retention=RetentionConfig(
                copies=retention["copies"],
                days=retention["days"],
            ),
            schedule=BackupSchedule(
                days=days,
                recurrence=ScheduleRecurrence(data["schedule"]["recurrence"]),
                time=time,
            ),
        )

    def to_dict(self) -> StoredBackupConfig:
        """Convert backup config data to a dict."""
        if self.last_attempted_automatic_backup:
            last_attempted = self.last_attempted_automatic_backup.isoformat()
        else:
            last_attempted = None

        if self.last_completed_automatic_backup:
            last_completed = self.last_completed_automatic_backup.isoformat()
        else:
            last_completed = None

        return StoredBackupConfig(
            agents={
                agent_id: agent.to_dict() for agent_id, agent in self.agents.items()
            },
            automatic_backups_configured=self.automatic_backups_configured,
            create_backup=self.create_backup.to_dict(),
            last_attempted_automatic_backup=last_attempted,
            last_completed_automatic_backup=last_completed,
            retention=self.retention.to_dict(),
            schedule=self.schedule.to_dict(),
        )


class BackupConfig:
    """Handle backup config."""

    def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None:
        """Initialize backup config."""
        self.data = BackupConfigData(
            agents={},
            automatic_backups_configured=False,
            create_backup=CreateBackupConfig(),
            retention=RetentionConfig(),
            schedule=BackupSchedule(),
        )
        self._hass = hass
        self._manager = manager

    def load(self, stored_config: StoredBackupConfig) -> None:
        """Load config."""
        self.data = BackupConfigData.from_dict(stored_config)
        self.data.retention.apply(self._manager)
        self.data.schedule.apply(self._manager)

    @callback
    def update(
        self,
        *,
        agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,
        automatic_backups_configured: bool | UndefinedType = UNDEFINED,
        create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
        retention: RetentionParametersDict | UndefinedType = UNDEFINED,
        schedule: ScheduleParametersDict | UndefinedType = UNDEFINED,
    ) -> None:
        """Update config."""
        if agents is not UNDEFINED:
            for agent_id, agent_config in agents.items():
                agent_retention = agent_config.get("retention")
                if agent_retention is None:
                    new_agent_retention = None
                else:
                    new_agent_retention = AgentRetentionConfig(
                        copies=agent_retention.get("copies"),
                        days=agent_retention.get("days"),
                    )
                if agent_id not in self.data.agents:
                    old_agent_retention = None
                    self.data.agents[agent_id] = AgentConfig(
                        protected=agent_config.get("protected", True),
                        retention=new_agent_retention,
                    )
                else:
                    new_agent_config = self.data.agents[agent_id]
                    old_agent_retention = new_agent_config.retention
                    if "protected" in agent_config:
                        new_agent_config = replace(
                            new_agent_config, protected=agent_config["protected"]
                        )
                    if "retention" in agent_config:
                        new_agent_config = replace(
                            new_agent_config, retention=new_agent_retention
                        )
                    self.data.agents[agent_id] = new_agent_config
                if new_agent_retention != old_agent_retention:
                    # There's a single retention application method
                    # for both global and agent retention settings.
                    self.data.retention.apply(self._manager)
        if automatic_backups_configured is not UNDEFINED:
            self.data.automatic_backups_configured = automatic_backups_configured
        if create_backup is not UNDEFINED:
            self.data.create_backup = replace(self.data.create_backup, **create_backup)
            if "agent_ids" in create_backup:
                check_unavailable_agents(self._hass, self._manager)
        if retention is not UNDEFINED:
            new_retention = RetentionConfig(**retention)
            if new_retention != self.data.retention:
                self.data.retention = new_retention
                self.data.retention.apply(self._manager)
        if schedule is not UNDEFINED:
            new_schedule = BackupSchedule(**schedule)
            if new_schedule.to_dict() != self.data.schedule.to_dict():
                self.data.schedule = new_schedule
                self.data.schedule.apply(self._manager)

        self._manager.store.save()


@dataclass(kw_only=True)
class AgentConfig:
    """Represent the config for an agent."""

    protected: bool
    """Agent protected configuration.

    If True, the agent backups are password protected.
    """
    retention: AgentRetentionConfig | None = None
    """Agent retention configuration.

    If None, the global retention configuration is used.
    If not None, the global retention configuration is ignored for this agent.
    If an agent retention configuration is set and both copies and days are None,
    backups will be kept forever for that agent.
    """

    def to_dict(self) -> StoredAgentConfig:
        """Convert agent config to a dict."""
        return {
            "protected": self.protected,
            "retention": self.retention.to_dict() if self.retention else None,
        }


class StoredAgentConfig(TypedDict):
    """Represent the stored config for an agent."""

    protected: bool
    retention: StoredRetentionConfig | None


class AgentParametersDict(TypedDict, total=False):
    """Represent the parameters for an agent."""

    protected: bool
    retention: RetentionParametersDict | None


@dataclass(kw_only=True)
class BaseRetentionConfig:
    """Represent the base backup retention configuration."""

    copies: int | None = None
    days: int | None = None

    def to_dict(self) -> StoredRetentionConfig:
        """Convert backup retention configuration to a dict."""
        return StoredRetentionConfig(
            copies=self.copies,
            days=self.days,
        )


@dataclass(kw_only=True)
class RetentionConfig(BaseRetentionConfig):
    """Represent the backup retention configuration."""

    def apply(self, manager: BackupManager) -> None:
        """Apply backup retention configuration."""
        agents_retention = {
            agent_id: agent_config.retention
            for agent_id, agent_config in manager.config.data.agents.items()
        }

        if self.days is not None or any(
            agent_retention and agent_retention.days is not None
            for agent_retention in agents_retention.values()
        ):
            LOGGER.debug(
                "Scheduling next automatic delete of backups older than %s in 1 day",
                self.days,
            )
            self._schedule_next(manager)
        else:
            LOGGER.debug("Unscheduling next automatic delete")
            self._unschedule_next(manager)

    @callback
    def _schedule_next(
        self,
        manager: BackupManager,
    ) -> None:
        """Schedule the next delete after days."""
        self._unschedule_next(manager)

        async def _delete_backups(now: datetime) -> None:
            """Delete backups older than days."""
            self._schedule_next(manager)

            def _delete_filter(
                backups: dict[str, ManagerBackup],
            ) -> dict[str, ManagerBackup]:
                """Return backups older than days to delete."""
                # we need to check here since we await before
                # this filter is applied
                agents_retention = {
                    agent_id: agent_config.retention
                    for agent_id, agent_config in manager.config.data.agents.items()
                }
                has_agents_retention = any(
                    agent_retention for agent_retention in agents_retention.values()
                )
                has_agents_retention_days = any(
                    agent_retention and agent_retention.days is not None
                    for agent_retention in agents_retention.values()
                )
                if (global_days := self.days) is None and not has_agents_retention_days:
                    # No global retention days and no agent retention days
                    return {}

                now = dt_util.utcnow()
                if global_days is not None and not has_agents_retention:
                    # Return early to avoid the longer filtering below.
                    return {
                        backup_id: backup
                        for backup_id, backup in backups.items()
                        if dt_util.parse_datetime(backup.date, raise_on_error=True)
                        + timedelta(days=global_days)
                        < now
                    }

                # If there are any agent retention settings, we need to check
                # the retention settings, for every backup and agent combination.

                backups_to_delete = {}

                for backup_id, backup in backups.items():
                    backup_date = dt_util.parse_datetime(
                        backup.date, raise_on_error=True
                    )
                    delete_from_agents = set(backup.agents)
                    for agent_id in backup.agents:
                        agent_retention = agents_retention.get(agent_id)
                        if agent_retention is None:
                            # This agent does not have a retention setting,
                            # so the global retention setting should be used.
                            if global_days is None:
                                # This agent does not have a retention setting
                                # and the global retention days setting is None,
                                # so this backup should not be deleted.
                                delete_from_agents.discard(agent_id)
                                continue
                            days = global_days
                        elif (agent_days := agent_retention.days) is None:
                            # This agent has a retention setting
                            # where days is set to None,
                            # so the backup should not be deleted.
                            delete_from_agents.discard(agent_id)
                            continue
                        else:
                            # This agent has a retention setting
                            # where days is set to a number,
                            # so that setting should be used.
                            days = agent_days
                        if backup_date + timedelta(days=days) >= now:
                            # This backup is not older than the retention days,
                            # so this agent should not be deleted.
                            delete_from_agents.discard(agent_id)

                    filtered_backup = replace(
                        backup,
                        agents={
                            agent_id: agent_backup_status
                            for agent_id, agent_backup_status in backup.agents.items()
                            if agent_id in delete_from_agents
                        },
                    )
                    backups_to_delete[backup_id] = filtered_backup

                return backups_to_delete

            await manager.async_delete_filtered_backups(
                include_filter=_automatic_backups_filter, delete_filter=_delete_filter
            )

        manager.remove_next_delete_event = async_call_later(
            manager.hass, timedelta(days=1), _delete_backups
        )

    @callback
    def _unschedule_next(self, manager: BackupManager) -> None:
        """Unschedule the next delete after days."""
        if (remove_next_event := manager.remove_next_delete_event) is not None:
            remove_next_event()
            manager.remove_next_delete_event = None


class StoredRetentionConfig(TypedDict):
    """Represent the stored backup retention configuration."""

    copies: int | None
    days: int | None


class RetentionParametersDict(TypedDict, total=False):
    """Represent the parameters for retention."""

    copies: int | None
    days: int | None


class AgentRetentionConfig(BaseRetentionConfig):
    """Represent an agent retention configuration."""


class StoredBackupSchedule(TypedDict):
    """Represent the stored backup schedule configuration."""

    days: list[Day]
    recurrence: ScheduleRecurrence
    time: str | None


class ScheduleParametersDict(TypedDict, total=False):
    """Represent parameters for backup schedule."""

    days: list[Day]
    recurrence: ScheduleRecurrence
    time: dt.time | None


class Day(StrEnum):
    """Represent the day(s) in a custom schedule recurrence."""

    MONDAY = "mon"
    TUESDAY = "tue"
    WEDNESDAY = "wed"
    THURSDAY = "thu"
    FRIDAY = "fri"
    SATURDAY = "sat"
    SUNDAY = "sun"


class ScheduleRecurrence(StrEnum):
    """Represent the schedule recurrence."""

    NEVER = "never"
    DAILY = "daily"
    CUSTOM_DAYS = "custom_days"


@dataclass(kw_only=True)
class BackupSchedule:
    """Represent the backup schedule."""

    days: list[Day] = field(default_factory=list)
    recurrence: ScheduleRecurrence = ScheduleRecurrence.NEVER
    time: dt.time | None = None
    cron_event: CronSim | None = field(init=False, default=None)
    next_automatic_backup: datetime | None = field(init=False, default=None)
    next_automatic_backup_additional = False

    @callback
    def apply(
        self,
        manager: BackupManager,
    ) -> None:
        """Apply a new schedule.

        There are only three possible recurrence types: never, daily, or custom_days
        """
        if self.recurrence is ScheduleRecurrence.NEVER or (
            self.recurrence is ScheduleRecurrence.CUSTOM_DAYS and not self.days
        ):
            self._unschedule_next(manager)
            return

        time = self.time if self.time is not None else DEFAULT_BACKUP_TIME
        if self.recurrence is ScheduleRecurrence.DAILY:
            self._schedule_next(
                CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour),
                manager,
            )
        else:  # ScheduleRecurrence.CUSTOM_DAYS
            self._schedule_next(
                CRON_PATTERN_WEEKLY.format(
                    m=time.minute,
                    h=time.hour,
                    d=",".join(day.value for day in self.days),
                ),
                manager,
            )

    @callback
    def _schedule_next(
        self,
        cron_pattern: str,
        manager: BackupManager,
    ) -> None:
        """Schedule the next backup."""
        self._unschedule_next(manager)
        now = dt_util.now()
        if (cron_event := self.cron_event) is None:
            seed_time = manager.config.data.last_completed_automatic_backup or now
            cron_event = self.cron_event = CronSim(cron_pattern, seed_time)
        next_time = next(cron_event)

        if next_time < now:
            # schedule a backup at next daily time once
            # if we missed the last scheduled backup
            time = self.time if self.time is not None else DEFAULT_BACKUP_TIME
            cron_event = CronSim(
                CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), now
            )
            next_time = next(cron_event)
            # reseed the cron event attribute
            # add a day to the next time to avoid scheduling at the same time again
            self.cron_event = CronSim(cron_pattern, now + timedelta(days=1))

            # Compare the computed next time with the next time from the cron pattern
            # to determine if an additional backup has been scheduled
            cron_event_configured = CronSim(cron_pattern, now)
            next_configured_time = next(cron_event_configured)
            self.next_automatic_backup_additional = next_time < next_configured_time
        else:
            self.next_automatic_backup_additional = False

        async def _create_backup(now: datetime) -> None:
            """Create backup."""
            manager.remove_next_backup_event = None
            self._schedule_next(cron_pattern, manager)

            # create the backup
            try:
                await manager.async_create_automatic_backup()
            except BackupManagerError as err:
                LOGGER.error("Error creating backup: %s", err)
            except Exception:  # noqa: BLE001
                LOGGER.exception("Unexpected error creating automatic backup")

        if self.time is None:
            # randomize the start time of the backup by up to 60 minutes if the time is
            # not set to avoid all backups running at the same time
            next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER))
        LOGGER.debug("Scheduling next automatic backup at %s", next_time)
        self.next_automatic_backup = next_time
        manager.remove_next_backup_event = async_track_point_in_time(
            manager.hass, _create_backup, next_time
        )

    def to_dict(self) -> StoredBackupSchedule:
        """Convert backup schedule to a dict."""
        return StoredBackupSchedule(
            days=self.days,
            recurrence=self.recurrence,
            time=self.time.isoformat() if self.time else None,
        )

    @callback
    def _unschedule_next(self, manager: BackupManager) -> None:
        """Unschedule the next backup."""
        self.next_automatic_backup = None
        if (remove_next_event := manager.remove_next_backup_event) is not None:
            remove_next_event()
            manager.remove_next_backup_event = None


@dataclass(kw_only=True)
class CreateBackupConfig:
    """Represent the config for async_create_backup."""

    agent_ids: list[str] = field(default_factory=list)
    include_addons: list[str] | None = None
    include_all_addons: bool = False
    include_database: bool = True
    include_folders: list[Folder] | None = None
    name: str | None = None
    password: str | None = None

    def to_dict(self) -> StoredCreateBackupConfig:
        """Convert create backup config to a dict."""
        return {
            "agent_ids": self.agent_ids,
            "include_addons": self.include_addons,
            "include_all_addons": self.include_all_addons,
            "include_database": self.include_database,
            "include_folders": self.include_folders,
            "name": self.name,
            "password": self.password,
        }


class StoredCreateBackupConfig(TypedDict):
    """Represent the stored config for async_create_backup."""

    agent_ids: list[str]
    include_addons: list[str] | None
    include_all_addons: bool
    include_database: bool
    include_folders: list[Folder] | None
    name: str | None
    password: str | None


class CreateBackupParametersDict(TypedDict, total=False):
    """Represent the parameters for async_create_backup."""

    agent_ids: list[str]
    include_addons: list[str] | None
    include_all_addons: bool
    include_database: bool
    include_folders: list[Folder] | None
    name: str | None
    password: str | None


def _automatic_backups_filter(
    backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
    """Return automatic backups."""
    return {
        backup_id: backup
        for backup_id, backup in backups.items()
        if backup.with_automatic_settings
    }


async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None:
    """Delete backups exceeding the configured retention count."""

    def _delete_filter(
        backups: dict[str, ManagerBackup],
    ) -> dict[str, ManagerBackup]:
        """Return oldest backups more numerous than copies to delete."""
        agents_retention = {
            agent_id: agent_config.retention
            for agent_id, agent_config in manager.config.data.agents.items()
        }
        has_agents_retention = any(
            agent_retention for agent_retention in agents_retention.values()
        )
        has_agents_retention_copies = any(
            agent_retention and agent_retention.copies is not None
            for agent_retention in agents_retention.values()
        )
        # we need to check here since we await before
        # this filter is applied
        if (
            global_copies := manager.config.data.retention.copies
        ) is None and not has_agents_retention_copies:
            # No global retention copies and no agent retention copies
            return {}
        if global_copies is not None and not has_agents_retention:
            # Return early to avoid the longer filtering below.
            return dict(
                sorted(
                    backups.items(),
                    key=lambda backup_item: backup_item[1].date,
                )[: max(len(backups) - global_copies, 0)]
            )

        backups_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(dict)
        for backup_id, backup in backups.items():
            for agent_id in backup.agents:
                backups_by_agent[agent_id][backup_id] = backup

        backups_to_delete_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(
            dict
        )
        for agent_id, agent_backups in backups_by_agent.items():
            agent_retention = agents_retention.get(agent_id)
            if agent_retention is None:
                # This agent does not have a retention setting,
                # so the global retention setting should be used.
                if global_copies is None:
                    # This agent does not have a retention setting
                    # and the global retention copies setting is None,
                    # so backups should not be deleted.
                    continue
                # The global retention setting will be used.
                copies = global_copies
            elif (agent_copies := agent_retention.copies) is None:
                # This agent has a retention setting
                # where copies is set to None,
                # so backups should not be deleted.
                continue
            else:
                # This agent retention setting will be used.
                copies = agent_copies

            backups_to_delete_by_agent[agent_id] = dict(
                sorted(
                    agent_backups.items(),
                    key=lambda backup_item: backup_item[1].date,
                )[: max(len(agent_backups) - copies, 0)]
            )

        backup_ids_to_delete: dict[str, set[str]] = defaultdict(set)
        for agent_id, to_delete in backups_to_delete_by_agent.items():
            for backup_id in to_delete:
                backup_ids_to_delete[backup_id].add(agent_id)
        backups_to_delete: dict[str, ManagerBackup] = {}
        for backup_id, agent_ids in backup_ids_to_delete.items():
            backup = backups[backup_id]
            # filter the backup to only include the agents that should be deleted
            filtered_backup = replace(
                backup,
                agents={
                    agent_id: agent_backup_status
                    for agent_id, agent_backup_status in backup.agents.items()
                    if agent_id in agent_ids
                },
            )
            backups_to_delete[backup_id] = filtered_backup
        return backups_to_delete

    await manager.async_delete_filtered_backups(
        include_filter=_automatic_backups_filter, delete_filter=_delete_filter
    )


@callback
def check_unavailable_agents(hass: HomeAssistant, manager: BackupManager) -> None:
    """Check for unavailable agents."""
    if missing_agent_ids := set(manager.config.data.create_backup.agent_ids) - set(
        manager.backup_agents
    ):
        LOGGER.debug(
            "Agents %s are configured for automatic backup but are unavailable",
            missing_agent_ids,
        )

    # Remove issues for unavailable agents that are not unavailable anymore.
    issue_registry = ir.async_get(hass)
    existing_missing_agent_issue_ids = {
        issue_id
        for domain, issue_id in issue_registry.issues
        if domain == DOMAIN
        and issue_id.startswith(AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID)
    }
    current_missing_agent_issue_ids = {
        f"{AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID}_{agent_id}": agent_id
        for agent_id in missing_agent_ids
    }
    for issue_id in existing_missing_agent_issue_ids - set(
        current_missing_agent_issue_ids
    ):
        ir.async_delete_issue(hass, DOMAIN, issue_id)
    for issue_id, agent_id in current_missing_agent_issue_ids.items():
        ir.async_create_issue(
            hass,
            DOMAIN,
            issue_id,
            is_fixable=False,
            learn_more_url="homeassistant://config/backup",
            severity=ir.IssueSeverity.WARNING,
            translation_key="automatic_backup_agents_unavailable",
            translation_placeholders={
                "agent_id": agent_id,
                "backup_settings": "/config/backup/settings",
            },
        )
