"""Irrigation Unlimited Coordinator and sub classes"""

# pylint: disable=too-many-lines
import weakref
from datetime import datetime, time, timedelta, timezone, date
from collections import deque
from collections.abc import Iterator
from types import MappingProxyType
from typing import OrderedDict, NamedTuple, Callable, Awaitable
from logging import WARNING, Logger, getLogger, INFO, DEBUG, ERROR
import uuid
import time as tm
import json
from decimal import Decimal
from enum import Enum, Flag, auto
import voluptuous as vol
from crontab import CronTab
from homeassistant.core import (
    HomeAssistant,
    HassJob,
    CALLBACK_TYPE,
    DOMAIN as HADOMAIN,
    Event as HAEvent,
    split_entity_id,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.template import Template, render_complex
from homeassistant.helpers.event import (
    async_track_point_in_utc_time,
    async_track_state_change_event,
)
from homeassistant.helpers import sun
from homeassistant.util import dt
from homeassistant.helpers import config_validation as cv

from homeassistant.const import (
    ATTR_ICON,
    CONF_AFTER,
    CONF_BEFORE,
    CONF_DELAY,
    CONF_ENTITY_ID,
    CONF_ICON,
    CONF_NAME,
    CONF_REPEAT,
    CONF_STATE,
    CONF_WEEKDAY,
    EVENT_HOMEASSISTANT_STOP,
    SERVICE_TURN_OFF,
    SERVICE_TURN_ON,
    SERVICE_CLOSE_VALVE,
    SERVICE_OPEN_VALVE,
    SERVICE_CLOSE_COVER,
    SERVICE_OPEN_COVER,
    STATE_OFF,
    STATE_ON,
    STATE_CLOSED,
    STATE_OPEN,
    WEEKDAYS,
    ATTR_ENTITY_ID,
    CONF_FOR,
    CONF_UNTIL,
    Platform,
)

from .history import IUHistory

from .const import (
    ATTR_ADJUSTED_DURATION,
    ATTR_ADJUSTMENT,
    ATTR_BASE_DURATION,
    ATTR_CURRENT_DURATION,
    ATTR_DEFAULT_DELAY,
    ATTR_DEFAULT_DURATION,
    ATTR_DURATION,
    ATTR_DURATION_FACTOR,
    ATTR_ENABLED,
    ATTR_FINAL_DURATION,
    ATTR_INDEX,
    ATTR_NAME,
    ATTR_SCHEDULE,
    ATTR_START,
    ATTR_STATUS,
    ATTR_SWITCH_ENTITIES,
    ATTR_TOTAL_DELAY,
    ATTR_TOTAL_DURATION,
    ATTR_ZONE_IDS,
    ATTR_ZONES,
    ATTR_SUSPENDED,
    BINARY_SENSOR,
    CONF_ACTUAL,
    CONF_ALL_ZONES_CONFIG,
    CONF_ALLOW_MANUAL,
    CONF_CLOCK,
    CONF_CONTROLLER,
    CONF_ZONE,
    CONF_MODE,
    CONF_RENAME_ENTITIES,
    CONF_ENTITY_BASE,
    CONF_RUN,
    CONF_SYNC_SWITCHES,
    DOMAIN,
    CONF_DAY,
    CONF_DECREASE,
    CONF_FINISH,
    CONF_INCREASE,
    CONF_INDEX,
    CONF_LOGGING,
    CONF_OUTPUT_EVENTS,
    CONF_PERCENTAGE,
    CONF_REFRESH_INTERVAL,
    CONF_RESET,
    CONF_RESULTS,
    CONF_SCHEDULE,
    CONF_SEQUENCE,
    CONF_SEQUENCE_ZONES,
    CONF_SEQUENCES,
    CONF_SEQUENCE_ID,
    CONF_SHOW_LOG,
    CONF_AUTOPLAY,
    CONF_ANCHOR,
    CONF_VERSION,
    COORDINATOR,
    DEFAULT_GRANULARITY,
    DEFAULT_REFRESH_INTERVAL,
    DEFAULT_TEST_SPEED,
    CONF_DURATION,
    CONF_ENABLED,
    CONF_GRANULARITY,
    CONF_TIME,
    CONF_SUN,
    CONF_PREAMBLE,
    CONF_POSTAMBLE,
    CONF_TESTING,
    CONF_SPEED,
    CONF_TIMES,
    CONF_START,
    CONF_END,
    CONF_CONTROLLERS,
    CONF_SCHEDULES,
    CONF_ZONES,
    CONF_MINIMUM,
    CONF_MAXIMUM,
    CONF_MONTH,
    EVENT_START,
    EVENT_FINISH,
    ICON_BLOCKED,
    ICON_CONTROLLER_OFF,
    ICON_CONTROLLER_ON,
    ICON_CONTROLLER_PAUSED,
    ICON_CONTROLLER_DELAY,
    ICON_DISABLED,
    ICON_SUSPENDED,
    ICON_SEQUENCE_DELAY,
    ICON_SEQUENCE_PAUSED,
    ICON_SEQUENCE_ZONE_OFF,
    ICON_SEQUENCE_ZONE_ON,
    ICON_ZONE_OFF,
    ICON_ZONE_ON,
    ICON_SEQUENCE_OFF,
    ICON_SEQUENCE_ON,
    RES_MANUAL,
    RES_TIMELINE_HISTORY,
    RES_TIMELINE_NEXT,
    RES_TIMELINE_RUNNING,
    RES_TIMELINE_SCHEDULED,
    TIMELINE_ADJUSTMENT,
    TIMELINE_END,
    TIMELINE_SCHEDULE_NAME,
    TIMELINE_START,
    MONTHS,
    CONF_ODD,
    CONF_EVEN,
    CONF_SHOW,
    CONF_CONFIG,
    CONF_TIMELINE,
    CONF_CONTROLLER_ID,
    CONF_ZONE_ID,
    CONF_FUTURE_SPAN,
    SERVICE_CANCEL,
    SERVICE_DISABLE,
    SERVICE_ENABLE,
    SERVICE_TOGGLE,
    SERVICE_MANUAL_RUN,
    SERVICE_TIME_ADJUST,
    SERVICE_LOAD_SCHEDULE,
    SERVICE_SUSPEND,
    SERVICE_SKIP,
    SERVICE_PAUSE,
    SERVICE_RESUME,
    STATUS_BLOCKED,
    STATUS_DELAY,
    STATUS_PAUSED,
    STATUS_DISABLED,
    STATUS_SUSPENDED,
    STATUS_INITIALISING,
    TIMELINE_STATUS,
    CONF_FIXED,
    CONF_MAX_LOG_ENTRIES,
    DEFAULT_MAX_LOG_ENTRIES,
    CONF_CRON,
    CONF_EVERY_N_DAYS,
    CONF_START_N_DAYS,
    CONF_CHECK_BACK,
    CONF_STATES,
    CONF_RETRIES,
    CONF_RESYNC,
    EVENT_SYNC_ERROR,
    EVENT_SWITCH_ERROR,
    CONF_FOUND,
    CONF_EXPECTED,
    CONF_SCHEDULE_ID,
    CONF_FROM,
    CONF_VOLUME,
    CONF_VOLUME_PRECISION,
    CONF_VOLUME_SCALE,
    CONF_FLOW_RATE_PRECISION,
    CONF_FLOW_RATE_SCALE,
    CONF_QUEUE,
    CONF_QUEUE_MANUAL,
    CONF_USER,
    CONF_TOGGLE,
    CONF_EXTENDED_CONFIG,
)

_LOGGER: Logger = getLogger(__package__)


# Useful time manipulation routine
def time_to_timedelta(offset: time) -> timedelta:
    """Create a timedelta from a time object"""
    return datetime.combine(datetime.min, offset) - datetime.min


def dt2lstr(stime: datetime) -> str:
    """Format the passed UTC datetime into a local date time string. The result
    is formatted to 24 hour time notation (YYYY-MM-DD HH:MM:SS).
    Example 2021-12-25 17:00:00 for 5pm on December 25 2021."""
    return datetime.strftime(dt.as_local(stime), "%Y-%m-%d %H:%M:%S")


def to_secs(atime: timedelta) -> int:
    """Convert the supplied time to whole seconds"""
    return round(atime.total_seconds())


# These routines truncate dates, times and deltas to the internal
# granularity. This should be no more than 1 minute and realistically
# no less than 1 second i.e. 1 >= GRANULARITY <= 60
# The current boundaries are whole minutes (60 seconds).
SYSTEM_GRANULARITY: int = DEFAULT_GRANULARITY  # Granularity in seconds


def reset_granularity() -> None:
    """Restore the original granularity"""
    global SYSTEM_GRANULARITY  # pylint: disable=global-statement
    SYSTEM_GRANULARITY = DEFAULT_GRANULARITY


def granularity_time() -> timedelta:
    """Return the system granularity as a timedelta"""
    return timedelta(seconds=SYSTEM_GRANULARITY)


def wash_td(delta: timedelta, granularity: int = None) -> timedelta:
    """Truncate the supplied timedelta to internal boundaries"""
    if delta is not None:
        if granularity is None:
            granularity = SYSTEM_GRANULARITY
        whole_seconds = int(delta.total_seconds())
        rounded_seconds = int(whole_seconds / granularity) * granularity
        return timedelta(seconds=rounded_seconds)
    return None


def wash_dt(value: datetime, granularity: int = None) -> datetime:
    """Truncate the supplied datetime to internal boundaries"""
    if value is not None:
        if granularity is None:
            granularity = SYSTEM_GRANULARITY
        rounded_seconds = int(value.second / granularity) * granularity
        return value.replace(second=rounded_seconds, microsecond=0)
    return None


def wash_t(stime: time, granularity: int = None) -> time:
    """Truncate the supplied time to internal boundaries"""
    if stime is not None:
        if granularity is None:
            granularity = SYSTEM_GRANULARITY
        utc = dt.utcnow()
        full_date = utc.combine(utc.date(), stime)
        rounded_seconds = int(full_date.second / granularity) * granularity
        return full_date.replace(second=rounded_seconds, microsecond=0).timetz()
    return None


def round_dt(stime: datetime, granularity: int = None) -> datetime:
    """Round the supplied datetime to internal boundaries"""
    if stime is not None:
        base_time = wash_dt(stime, granularity)
        return base_time + round_td(stime - base_time, granularity)
    return None


def round_td(delta: timedelta, granularity: int = None) -> timedelta:
    """Round the supplied timedelta to internal boundaries"""
    if delta is not None:
        if granularity is None:
            granularity = SYSTEM_GRANULARITY
        rounded_seconds = (
            int((delta.total_seconds() + granularity / 2) / granularity) * granularity
        )
        return timedelta(seconds=rounded_seconds)
    return None


def str_to_td(atime: str) -> timedelta:
    """Convert a string in 0:00:00 format to timedelta"""
    dur = datetime.strptime(atime, "%H:%M:%S")
    return timedelta(hours=dur.hour, minutes=dur.minute, seconds=dur.second)


def utc_eot() -> datetime:
    """Return the end of time in UTC format"""
    return datetime.max.replace(tzinfo=timezone.utc)


def render_positive_time_period(data: dict, key: str) -> None:
    """Resolve a template that specifies a timedelta"""
    if key in data:
        schema = vol.Schema({key: cv.positive_time_period})
        rendered = render_complex(data[key])
        data[key] = schema({key: rendered})[key]


def render_positive_float(hass: HomeAssistant, data: dict, key: str) -> None:
    """Resolve a template that specifies a float"""
    if isinstance(data.get(key), Template):
        template: Template = data[key]
        template.hass = hass
        schema = vol.Schema({key: cv.positive_float})
        data[key] = schema({key: template.async_render()})[key]


def check_item(index: int, items: list[int] | None) -> bool:
    """If items is None or contains only a 0 (match all) then
    return True. Otherwise check if index + 1 is in the list"""
    return items is None or (items is not None and items == [0]) or index + 1 in items


def s2b(test: bool, service: str) -> bool:
    """Convert the service code to bool"""
    if service == SERVICE_ENABLE:
        return True
    if service == SERVICE_DISABLE:
        return False
    return not test  # Must be SERVICE_TOGGLE


def suspend_until_date(
    data: MappingProxyType,
    stime: datetime,
) -> datetime:
    """Determine the suspend date and time"""
    if CONF_UNTIL in data:
        suspend_time = dt.as_utc(data[CONF_UNTIL])
    elif CONF_FOR in data:
        suspend_time = stime + data[CONF_FOR]
    else:
        suspend_time = None
    return wash_dt(suspend_time)


class IUJSONEncoder(json.JSONEncoder):
    """JSON serialiser to handle ISO datetime output"""

    def default(self, o):
        if isinstance(o, datetime):
            return dt.as_local(o).isoformat()
        if isinstance(o, timedelta):
            return round(o.total_seconds())
        return str(o)


class IUBase:
    """Irrigation Unlimited base class"""

    def __init__(self, index: int) -> None:
        # Private variables
        self._uid: int = uuid.uuid4().int
        self._index: int = index

    def __eq__(self, other) -> bool:
        return isinstance(other, IUBase) and self._uid == other._uid

    def __hash__(self) -> int:
        return self._uid

    @property
    def uid(self) -> int:
        """Return the unique id"""
        return self._uid

    @property
    def index(self) -> int:
        """Return position within siblings"""
        return self._index

    @property
    def id1(self) -> int:
        """Return the 1's based index"""
        return self._index + 1

    @staticmethod
    def ids(obj: "IUBase", default: str = "", offset: int = 0) -> str:
        """Return the index as a str"""
        if isinstance(obj, IUBase):
            return str(obj.index + offset)
        return default

    @staticmethod
    def idl(obj: "list[IUBase]", default: str = "", offset: int = 0) -> "list[str]":
        """Return a list of indexes"""
        result = []
        for item in obj:
            result.append(IUBase.ids(item, default, offset))
        return result


class IUAdjustment:
    """Irrigation Unlimited class to handle run time adjustment"""

    def __init__(self, adjustment: str = None) -> None:
        self._method: str = None
        self._time_adjustment = None
        self._minimum: timedelta = None
        self._maximum: timedelta = None
        if (
            adjustment is not None
            and isinstance(adjustment, str)
            and len(adjustment) >= 2
        ):
            method = adjustment[0]
            adj = adjustment[1:]
            if method == "=":
                self._method = CONF_ACTUAL
                self._time_adjustment = wash_td(str_to_td(adj))
            elif method == "%":
                self._method = CONF_PERCENTAGE
                self._time_adjustment = float(adj)
            elif method == "+":
                self._method = CONF_INCREASE
                self._time_adjustment = wash_td(str_to_td(adj))
            elif method == "-":
                self._method = CONF_DECREASE
                self._time_adjustment = wash_td(str_to_td(adj))

    def __str__(self) -> str:
        """Return the adjustment as a string notation"""
        if self._method == CONF_ACTUAL:
            return f"={self._time_adjustment}"
        if self._method == CONF_PERCENTAGE:
            return f"%{self._time_adjustment}"
        if self._method == CONF_INCREASE:
            return f"+{self._time_adjustment}"
        if self._method == CONF_DECREASE:
            return f"-{self._time_adjustment}"
        return ""

    @property
    def has_adjustment(self) -> bool:
        """Return True if an adjustment is in play"""
        return self._method is not None

    def load(self, data: MappingProxyType) -> bool:
        """Read the adjustment configuration. Return true if anything has changed"""

        # Save current settings
        old_method = self._method
        old_time_adjustment = self._time_adjustment
        old_minimum = self._minimum
        old_maximum = self._maximum

        if CONF_ACTUAL in data:
            self._method = CONF_ACTUAL
            self._time_adjustment = wash_td(data.get(CONF_ACTUAL))
        elif CONF_PERCENTAGE in data:
            self._method = CONF_PERCENTAGE
            self._time_adjustment = data.get(CONF_PERCENTAGE)
        elif CONF_INCREASE in data:
            self._method = CONF_INCREASE
            self._time_adjustment = wash_td(data.get(CONF_INCREASE))
        elif CONF_DECREASE in data:
            self._method = CONF_DECREASE
            self._time_adjustment = wash_td(data.get(CONF_DECREASE))
        elif CONF_RESET in data:
            self._method = None
            self._time_adjustment = None
            self._minimum = None
            self._maximum = None

        self._minimum = wash_td(data.get(CONF_MINIMUM, None))
        if self._minimum is not None:
            self._minimum = max(self._minimum, granularity_time())  # Set floor

        self._maximum = wash_td(data.get(CONF_MAXIMUM, None))

        return (
            self._method != old_method
            or self._time_adjustment != old_time_adjustment
            or self._minimum != old_minimum
            or self._maximum != old_maximum
        )

    def adjust(self, stime: timedelta) -> timedelta:
        """Return the adjusted time"""
        new_time: timedelta

        if self._method is None:
            new_time = stime
        elif self._method == CONF_ACTUAL:
            new_time = self._time_adjustment
        elif self._method == CONF_PERCENTAGE:
            new_time = round_td(stime * self._time_adjustment / 100)
        elif self._method == CONF_INCREASE:
            new_time = stime + self._time_adjustment
        elif self._method == CONF_DECREASE:
            new_time = stime - self._time_adjustment
        else:
            new_time = stime

        if self._minimum is not None:
            new_time = max(new_time, self._minimum)

        if self._maximum is not None:
            new_time = min(new_time, self._maximum)

        return new_time

    def to_str(self) -> str:
        """Return this adjustment as string or None if empty"""
        if self._method is not None:
            return str(self)
        return "None"

    def to_dict(self) -> dict:
        """Return this adjustment as a dict"""
        result = {}
        if self._method is not None:
            result[self._method] = self._time_adjustment
        if self._minimum is not None:
            result[CONF_MINIMUM] = self._minimum
        if self._maximum is not None:
            result[CONF_MAXIMUM] = self._maximum
        return result


class IURQStatus(Flag):
    """Define the return status of the run queues"""

    NONE = 0
    CLEARED = auto()
    EXTENDED = auto()
    REDUCED = auto()
    SORTED = auto()
    UPDATED = auto()
    CANCELED = auto()
    CHANGED = auto()

    def is_empty(self) -> bool:
        """Return True if there are no flags set"""
        return self.value == 0

    def has_any(self, other: "IURQStatus") -> bool:
        """Return True if the intersect is not empty"""
        return other.value & self.value != 0


class IUUser(dict):
    """Class to hold arbitrary static user information"""

    def load(self, config: OrderedDict, all_zones: OrderedDict):
        """Load info data from the configuration"""

        def load_params(config: OrderedDict) -> None:
            if config is None:
                return
            for key, data in config.items():
                self[f"{CONF_USER}_{key}"] = data

        self.clear()
        if all_zones is not None:
            load_params(all_zones.get(CONF_USER))
        load_params(config.get(CONF_USER))


class IUSchedule(IUBase):
    """Irrigation Unlimited Schedule class. Schedules are not actual
    points in time but describe a future event i.e. next Monday at
    sunrise"""

    # pylint: disable=too-many-instance-attributes
    def __init__(
        self,
        hass: HomeAssistant,
        coordinator: "IUCoordinator",
        schedule_index: int,
    ) -> None:
        super().__init__(schedule_index)
        # Passed parameters
        self._hass = hass
        self._coordinator = coordinator
        # Config parameters
        self._schedule_id: str = None
        self._time = None
        self._duration: timedelta = None
        self._name: str = None
        self._weekdays: list[int] = None
        self._months: list[int] = None
        self._days = None
        self._anchor: str = None
        self._from: date = None
        self._until: date = None
        self._enabled = True
        # Private variables

    @property
    def schedule_id(self) -> str:
        """Return the unique id for this schedule"""
        return self._schedule_id

    @property
    def name(self) -> str:
        """Return the friendly name of this schedule"""
        return self._name

    @property
    def is_setup(self) -> bool:
        """Return True if this schedule is setup"""
        return True

    @property
    def duration(self) -> timedelta:
        """Return the duration"""
        return self._duration

    @property
    def enabled(self) -> bool:
        """Return enabled status"""
        return self._enabled

    def load(self, config: OrderedDict, update: bool = False) -> "IUSchedule":
        """Load schedule data from config"""
        if not update:
            self._schedule_id = None
            self._time = None
            self._duration = None
            self._name = f"Schedule {self.index + 1}"
            self._weekdays = None
            self._months = None
            self._days = None
            self._anchor = None
            self._from = None
            self._until = None

        self._schedule_id = config.get(CONF_SCHEDULE_ID, self.schedule_id)
        self._time = config.get(CONF_TIME, self._time)
        self._anchor = config.get(CONF_ANCHOR, self._anchor)
        self._duration = wash_td(config.get(CONF_DURATION, self._duration))
        self._name = config.get(CONF_NAME, self._name)
        self._enabled = config.get(CONF_ENABLED, self._enabled)

        if CONF_WEEKDAY in config:
            self._weekdays = []
            for i in config[CONF_WEEKDAY]:
                self._weekdays.append(WEEKDAYS.index(i))

        if CONF_MONTH in config:
            self._months = []
            for i in config[CONF_MONTH]:
                self._months.append(MONTHS.index(i) + 1)

        self._days = config.get(CONF_DAY, self._days)
        self._from = config.get(CONF_FROM, self._from)
        self._until = config.get(CONF_UNTIL, self._until)

        return self

    def as_dict(self) -> OrderedDict:
        """Return the schedule as a dict"""
        result = OrderedDict()

        result[CONF_TIME] = self._time
        result[CONF_ANCHOR] = self._anchor
        result[CONF_DURATION] = self._duration
        result[CONF_NAME] = self._name
        result[CONF_ENABLED] = self._enabled
        if self._weekdays is not None:
            result[CONF_WEEKDAY] = [WEEKDAYS[d] for d in self._weekdays]
        if self._months is not None:
            result[CONF_MONTH] = [MONTHS[m - 1] for m in self._months]
        if self._days is not None:
            result[CONF_DAY] = self._days
        return result

    def get_next_run(
        self,
        stime: datetime,
        ftime: datetime,
        adjusted_duration: timedelta,
        is_running: bool,
    ) -> datetime:
        # pylint: disable=too-many-branches
        # pylint: disable=too-many-statements
        # pylint: disable=too-many-locals
        """
        Determine the next start time. Date processing in this routine
        is done in local time and returned as UTC. stime is the current time
        and ftime is the farthest time we are interested in. adjusted_duration
        is the total time this schedule will run for, used to work backwards when
        the anchor is finish
        """
        local_time = dt.as_local(stime)
        final_time = dt.as_local(ftime)

        current_time: datetime = None
        next_run: datetime = None
        advancement = timedelta(days=1)
        while True:
            if current_time is None:  # Initialise on first pass
                current_time = local_time
            else:
                current_time += advancement  # Advance to next day
            next_run = current_time

            # Sanity check. Note: Astral events like sunrise might be months
            # away i.e. Antarctica winter
            if next_run > final_time:
                return None

            # DOW filter
            if self._weekdays is not None and next_run.weekday() not in self._weekdays:
                continue

            # Month filter
            if self._months is not None and next_run.month not in self._months:
                continue

            # Day filter
            if self._days is not None:
                if self._days == CONF_ODD:
                    if next_run.day % 2 == 0:
                        continue
                elif self._days == CONF_EVEN:
                    if next_run.day % 2 != 0:
                        continue
                elif isinstance(self._days, dict) and CONF_EVERY_N_DAYS in self._days:
                    n_days: int = self._days[CONF_EVERY_N_DAYS]
                    start_date: date = self._days[CONF_START_N_DAYS]
                    if (next_run.date() - start_date).days % n_days != 0:
                        continue
                elif next_run.day not in self._days:
                    continue

            # From/Until filter
            if self._from is not None and self._until is not None:
                dts = datetime.combine(
                    self._from.replace(year=next_run.year),
                    datetime.min.time(),
                    next_run.tzinfo,
                )
                dte = datetime.combine(
                    self._until.replace(year=next_run.year),
                    datetime.max.time(),
                    next_run.tzinfo,
                )
                if dte < dts:
                    if next_run >= dts:
                        dte = dte.replace(year=dte.year + 1)
                    else:
                        dts = dts.replace(year=dts.year - 1)

                if not dts <= next_run <= dte:
                    continue

            # Adjust time component
            if isinstance(self._time, time):
                next_run = datetime.combine(
                    next_run.date(), self._time, next_run.tzinfo
                )
            elif isinstance(self._time, dict) and CONF_SUN in self._time:
                sun_event = sun.get_astral_event_date(
                    self._hass, self._time[CONF_SUN], next_run
                )
                if sun_event is None:
                    continue  # Astral event did not occur today

                next_run = dt.as_local(sun_event)
                if CONF_AFTER in self._time:
                    next_run += self._time[CONF_AFTER]
                if CONF_BEFORE in self._time:
                    next_run -= self._time[CONF_BEFORE]
            elif isinstance(self._time, dict) and CONF_CRON in self._time:
                try:
                    cron = CronTab(self._time[CONF_CRON])
                    cron_event = cron.next(
                        now=local_time, return_datetime=True, default_utc=True
                    )
                    if cron_event is None:
                        return None
                    next_run = dt.as_local(cron_event)
                except ValueError as error:
                    self._coordinator.logger.log_invalid_crontab(
                        stime, self, self._time[CONF_CRON], error
                    )
                    self._enabled = False  # Shutdown this schedule
                    return None
            else:  # Some weird error happened
                return None

            if self._anchor == CONF_FINISH:
                next_run -= adjusted_duration

            next_run = wash_dt(next_run)
            if (is_running and next_run > local_time) or (
                not is_running and next_run + adjusted_duration > local_time
            ):
                break

        return dt.as_utc(next_run)


class IUSwitch:
    """Manager for the phsical switch entity"""

    # pylint: disable=too-many-instance-attributes
    def __init__(
        self,
        hass: HomeAssistant,
        coordinator: "IUCoordinator",
        controller: "IUController",
        zone: "IUZone",
    ) -> None:
        # Passed paramaters
        self._hass = hass
        self._coordinator = coordinator
        self._controller = controller
        self._zone = zone
        # Config parameters
        self._switch_entity_id: list[str]
        self._check_back_states = "all"
        self._check_back_delay = timedelta(seconds=30)
        self._check_back_retries: int = 3
        self._check_back_resync: bool = True
        self._check_back_entity_id: str = None
        self._check_back_toggle: bool = False
        # private variables
        self._state: bool = None  # This parameter should mirror IUZone._is_on
        self._check_back_time: timedelta = None
        self._check_back_resync_count: int = 0

    @property
    def switch_entity_id(self) -> list[str] | None:
        """Return the switch entity"""
        return self._switch_entity_id

    def _state_name(self, entity_id: str, state: bool) -> str:
        domain = split_entity_id(entity_id)[0]
        match domain:
            case Platform.VALVE | Platform.COVER:
                return STATE_OPEN if state else STATE_CLOSED
            case _:
                return STATE_ON if state else STATE_OFF

    def _set_switch(self, entity_id: str | list[str], state: bool) -> None:
        """Make the HA call to physically turn the switch on/off"""

        def make_call(entity: str) -> None:
            domain = split_entity_id(entity)[0]
            match domain:
                case Platform.VALVE:
                    service = SERVICE_OPEN_VALVE if state else SERVICE_CLOSE_VALVE
                case Platform.COVER:
                    service = SERVICE_OPEN_COVER if state else SERVICE_CLOSE_COVER
                case _:
                    domain = HADOMAIN
                    service = SERVICE_TURN_ON if state else SERVICE_TURN_OFF
            self._hass.async_create_task(
                self._hass.services.async_call(
                    domain,
                    service,
                    {ATTR_ENTITY_ID: entity},
                )
            )

        if isinstance(entity_id, list):
            for ent in entity_id:
                make_call(ent)
        else:
            make_call(entity_id)

    def _check_back(self, atime: datetime) -> None:
        """Recheck the switch in HA to see if state concurs"""
        if (
            self._check_back_resync_count >= self._check_back_retries
            or not self._check_back_resync
        ):
            if entities := self.check_switch(atime, False, False):
                for entity in entities:
                    state = self._hass.states.get(entity)
                    found = state.state if state else None
                    expected = self._state_name(entity, self._state)
                    self._coordinator.logger.log_switch_error(
                        atime, expected, found, entity
                    )
                    self._coordinator.notify_switch(
                        EVENT_SWITCH_ERROR,
                        found,
                        expected,
                        entity,
                        self._controller,
                        self._zone,
                    )
            self._check_back_time = None
        else:
            if entities := self.check_switch(atime, self._check_back_resync, True):
                self._check_back_resync_count += 1
                self._check_back_time = atime + self._check_back_delay
            else:
                self._check_back_time = None

    def next_event(self) -> datetime:
        """Return the next time of interest"""
        if self._check_back_time is not None:
            return self._check_back_time
        return utc_eot()

    def clear(self) -> None:
        """Reset this object"""
        self._state = False

    def load(self, config: OrderedDict, all_zones: OrderedDict) -> "IUSwitch":
        """Load switch data from the configuration"""

        def load_params(config: OrderedDict) -> None:
            if config is None:
                return
            self._check_back_states = config.get(CONF_STATES, self._check_back_states)
            self._check_back_retries = config.get(
                CONF_RETRIES, self._check_back_retries
            )
            self._check_back_resync = config.get(CONF_RESYNC, self._check_back_resync)
            delay = config.get(CONF_DELAY, self._check_back_delay.total_seconds())
            self._check_back_delay = wash_td(timedelta(seconds=delay))
            self._check_back_entity_id = config.get(
                CONF_ENTITY_ID, self._check_back_entity_id
            )
            self._check_back_toggle = config.get(CONF_TOGGLE, self._check_back_toggle)

        self.clear()
        self._switch_entity_id = config.get(CONF_ENTITY_ID)
        if all_zones is not None:
            load_params(all_zones.get(CONF_CHECK_BACK))
        load_params(config.get(CONF_CHECK_BACK))
        return self

    def muster(self, stime: datetime) -> int:
        """Muster this switch"""
        if self._check_back_time is not None and stime >= self._check_back_time:
            self._check_back(stime)

    def check_switch(self, stime: datetime, resync: bool, log: bool) -> list[str]:
        """Check the linked entity is in sync. Returns a list of entities
        that are not in sync"""

        result: list[str] = []

        def _check_entity(entity_id: str, expected: str) -> bool:
            state = self._hass.states.get(entity_id)
            found = state.state if state else None
            is_valid = state is not None and state.state == expected
            if not is_valid:
                result.append(entity_id)
                if log:
                    self._coordinator.logger.log_sync_error(
                        stime, expected, found, entity_id
                    )
                    self._coordinator.notify_switch(
                        EVENT_SYNC_ERROR,
                        found,
                        expected,
                        entity_id,
                        self._controller,
                        self._zone,
                    )

            return is_valid

        def do_resync(entity_id: str) -> None:
            if self._check_back_toggle:
                self._set_switch(entity_id, not self._state)
            self._set_switch(entity_id, self._state)

        if self._switch_entity_id is not None:
            if self._check_back_entity_id is None:
                for entity_id in self._switch_entity_id:
                    expected = self._state_name(entity_id, self._state)
                    if not _check_entity(entity_id, expected):
                        if resync:
                            do_resync(entity_id)
            else:
                expected = self._state_name(self._check_back_entity_id, self._state)
                if not _check_entity(self._check_back_entity_id, expected):
                    if resync and len(self._switch_entity_id) == 1:
                        do_resync(self._switch_entity_id)
        return result

    def call_switch(self, state: bool, stime: datetime = None) -> None:
        """Turn the HA entity on or off"""
        # pylint: disable=too-many-boolean-expressions
        if self._switch_entity_id is not None:
            if self._check_back_time is not None:
                # Switch state was changed before the recheck. Check now.
                self.check_switch(stime, False, True)
                self._check_back_time = None
            self._state = state
            self._set_switch(self._switch_entity_id, state)
            if stime is not None and (
                self._check_back_states == "all"
                or (self._check_back_states == "on" and state)
                or (self._check_back_states == "off" and not state)
            ):
                self._check_back_resync_count = 0
                self._check_back_time = stime + self._check_back_delay


class IUVolumeSensorError(Exception):
    """Error reading sensor"""


class IUVolumeSensorReading(NamedTuple):
    """Irrigation Unlimited volume sensor reading class"""

    timestamp: datetime
    value: Decimal


class IUVolume:
    """Irrigation Unlimited Volume class"""

    # pylint: disable=too-many-instance-attributes

    MAX_READINGS = 20
    SMA_WINDOW = 10
    listeners: int = 0
    trackers: int = 0

    def __init__(
        self, hass: HomeAssistant, coordinator: "IUCoordinator", zone: "IUZone"
    ) -> None:
        # Passed parameters
        self._hass = hass
        self._coordinator = coordinator
        self._zone = zone
        # Config parameters
        self._sensor_id: str = None
        self._volume_precision: int = 3
        self._volume_scale: float = 1
        self._flow_rate_precision: int = 3
        self._flow_rate_scale: float = 3600
        # Private variables
        self._callback_remove: CALLBACK_TYPE = None
        self._start_volume: Decimal = None
        self._total_volume: Decimal = None
        self._start_time: datetime = None
        self._end_time: datetime = None
        self._listeners: dict[
            str, Callable[[datetime, "IUZone", Decimal, Decimal], None]
        ] = {}
        self._flow_rates: list[Decimal] = []
        self._flow_rate_sum = Decimal(0)
        self._flow_rate_sma: Decimal = None
        self._sensor_readings: list[IUVolumeSensorReading] = []

    @property
    def total(self) -> float | None:
        """Return the total value"""
        if self._total_volume is not None:
            return float(self._total_volume)
        return None

    @property
    def flow_rate(self) -> float | None:
        """Return the flow rate"""
        if self._flow_rate_sma is not None:
            return float(self._flow_rate_sma)
        return None

    def load(self, config: OrderedDict, all_zones: OrderedDict) -> "IUSwitch":
        """Load volume data from the configuration"""

        def load_params(config: OrderedDict) -> None:
            if config is None:
                return
            self._sensor_id = config.get(CONF_ENTITY_ID, self._sensor_id)
            self._volume_precision = config.get(
                CONF_VOLUME_PRECISION, self._volume_precision
            )
            self._volume_scale = config.get(CONF_VOLUME_SCALE, self._volume_scale)
            self._flow_rate_precision = config.get(
                CONF_FLOW_RATE_PRECISION, self._flow_rate_precision
            )
            self._flow_rate_scale = config.get(
                CONF_FLOW_RATE_SCALE, self._flow_rate_scale
            )

        if all_zones is not None:
            load_params(all_zones.get(CONF_VOLUME))
        load_params(config.get(CONF_VOLUME))

    def read_sensor(self, stime: datetime) -> Decimal:
        """Read the sensor data"""
        sensor = self._hass.states.get(self._sensor_id)
        if sensor is None:
            raise IUVolumeSensorError(f"Sensor not found: {self._sensor_id}")
        value = float(sensor.state)
        if value < 0:
            raise ValueError(f"Negative sensor value: {sensor.state}")

        volume = Decimal(f"{value * self._volume_scale:.{self._volume_precision}f}")

        if len(self._sensor_readings) > 0:
            last_reading = self._sensor_readings[-1]
            volume_delta = volume - last_reading.value
            time_delta = Decimal(
                f"{(stime - last_reading.timestamp).total_seconds():.6f}"
            )

            if time_delta == 0:
                raise ValueError(f"Sensor time has not advanced: {stime}")
            if time_delta < 0:
                raise ValueError(
                    "Sensor time has gone backwards: "
                    f"previous: {last_reading.timestamp}, "
                    f"current: {stime}"
                )
            if volume_delta < 0:
                raise ValueError(
                    "Sensor value has gone backwards: "
                    f"previous: {last_reading.value}, "
                    f"current: {volume}"
                )

            # Update moving average of the rate. Ignore initial reading.
            if len(self._sensor_readings) > 1:
                rate = volume_delta * Decimal(self._flow_rate_scale) / time_delta
                self._flow_rate_sum += rate
                self._flow_rates.append(rate)
                if len(self._flow_rates) > IUVolume.SMA_WINDOW:
                    self._flow_rate_sum -= self._flow_rates.pop(0)
                self._flow_rate_sma = (
                    self._flow_rate_sum / len(self._flow_rates)
                ).quantize(Decimal(10) ** -self._flow_rate_precision)

        # Update bookkeeping
        self._sensor_readings.append(IUVolumeSensorReading(stime, volume))
        if len(self._sensor_readings) > IUVolume.MAX_READINGS:
            self._sensor_readings.pop(0)

        return volume

    def event_hook(self, event: HAEvent) -> HAEvent:
        """A pass through place for testing to patch and update
        parameters in the event message"""
        return event

    def start_record(self, stime: datetime) -> None:
        """Start recording volume information"""

        def sensor_state_change(event: HAEvent):
            event = self.event_hook(event)
            try:
                value = self.read_sensor(event.time_fired)
            except ValueError as e:
                self._coordinator.logger.log_invalid_meter_value(stime, e)
            except IUVolumeSensorError:
                self._coordinator.logger.log_invalid_meter_id(stime, self._sensor_id)
            else:
                self._total_volume = value - self._start_volume

                # Notifiy our trackers
                for listener in list(self._listeners.values()):
                    listener(
                        event.time_fired,
                        self._zone,
                        self._total_volume,
                        self._flow_rate_sma,
                    )

        if self._sensor_id is None:
            return
        self._start_volume = self._total_volume = None
        self._start_time = stime
        self._sensor_readings.clear()
        self._flow_rates.clear()
        self._flow_rate_sum = 0
        self._flow_rate_sma = None

        try:
            self._start_volume = self.read_sensor(stime)
        except ValueError as e:
            self._coordinator.logger.log_invalid_meter_value(stime, e)
        except IUVolumeSensorError:
            self._coordinator.logger.log_invalid_meter_id(stime, self._sensor_id)
        else:
            self._callback_remove = async_track_state_change_event(
                self._hass, self._sensor_id, sensor_state_change
            )
            IUVolume.trackers += 1

    def end_record(self, stime: datetime) -> None:
        """Finish recording volume information"""
        self._end_time = stime
        if self._callback_remove is not None:
            self._callback_remove()
            self._callback_remove = None
            IUVolume.trackers -= 1

    def track_volume_change(
        self, uid: int, action: Callable[[datetime, "IUZone", float, float], None]
    ) -> CALLBACK_TYPE:
        """Track the volume"""

        def remove_listener() -> None:
            del self._listeners[uid]
            IUVolume.listeners -= 1

        self._listeners[uid] = action
        IUVolume.listeners += 1
        return remove_listener


class IURunStatus(Enum):
    """Flags for the status of IURun object"""

    UNKNOWN = 0
    FUTURE = auto()
    RUNNING = auto()
    EXPIRED = auto()
    PAUSED = auto()

    @staticmethod
    def status(
        stime: datetime, start_time: datetime, end_time: datetime, paused: datetime
    ) -> "IURunStatus":
        """Determine the state of this object"""
        if paused is not None:
            return IURunStatus.PAUSED
        if start_time > stime:
            return IURunStatus.FUTURE
        if start_time <= stime < end_time:
            return IURunStatus.RUNNING
        if stime >= end_time:
            return IURunStatus.EXPIRED
        return IURunStatus.UNKNOWN


class IURun(IUBase):
    """Irrigation Unlimited Run class. A run is an actual point
    in time. If schedule is None then it is a manual run.
    """

    # pylint: disable=too-many-instance-attributes
    # pylint: disable=too-many-public-methods

    def __init__(
        self,
        stime: datetime,
        start_time: datetime,
        duration: timedelta,
        zone: "IUZone",
        schedule: "IUSchedule",
        sequence_run: "IUSequenceRun",
        zone_run: "IURun",
    ) -> None:
        # pylint: disable=too-many-arguments
        super().__init__(None)
        # Passed parameters
        self._start_time: datetime = start_time
        self._duration: timedelta = duration
        self._zone = zone
        self._schedule = schedule
        self._sequence_run = sequence_run
        self._zone_run = zone_run
        # Private variables
        self._end_time: datetime = self._start_time + self._duration
        self._remaining_time: timedelta = self._end_time - self._start_time
        self._percent_complete: int = 0
        self._pause_time: datetime = None
        self._status = self._get_status(stime)
        self.master_run: "IURun" = None

    @property
    def expired(self) -> bool:
        """Indicate if run has expired"""
        return self._status == IURunStatus.EXPIRED

    @property
    def running(self) -> bool:
        """Indicate if run is running"""
        return self._status == IURunStatus.RUNNING

    @property
    def future(self) -> bool:
        """Indicate if run is in the future"""
        return self._status == IURunStatus.FUTURE

    @property
    def paused(self) -> bool:
        """Indicate if run is paused"""
        return self._status == IURunStatus.PAUSED

    @property
    def start_time(self) -> datetime:
        """Return the start time"""
        return self._start_time

    @start_time.setter
    def start_time(self, value: datetime) -> None:
        """Set the start time"""
        self._start_time = value

    @property
    def duration(self) -> timedelta:
        """Return the duration"""
        return self._duration

    @duration.setter
    def duration(self, value: timedelta) -> None:
        """Set the duration"""
        self._duration = value

    @property
    def zone(self) -> "IUZone":
        """Return the zone for this run"""
        return self._zone

    @property
    def schedule(self) -> "IUSchedule":
        """Return the schedule"""
        return self._schedule

    @property
    def schedule_name(self) -> str:
        """Return the name of the schedule"""
        if self._schedule is not None:
            return self._schedule.name
        return RES_MANUAL

    @property
    def adjustment(self) -> str:
        """Return the adjustment in play"""
        if self._schedule is None:
            return ""
        if self.sequence_has_adjustment(True):
            return self.sequence_adjustment()
        return str(self._zone.adjustment)

    @property
    def end_time(self) -> datetime:
        """Return the finish time"""
        return self._end_time

    @end_time.setter
    def end_time(self, value: datetime) -> None:
        """Set the end time"""
        self._end_time = value

    @property
    def time_remaining(self) -> timedelta:
        """Return the amount of time left to run"""
        return self._remaining_time

    @property
    def percent_complete(self) -> float:
        """Return the percentage completed"""
        return self._percent_complete

    @property
    def is_sequence(self) -> bool:
        """Return True if this run is a sequence"""
        return self._sequence_run is not None

    @property
    def sequence_run(self) -> "IUSequenceRun":
        """If a sequence then return the details"""
        return self._sequence_run

    @property
    def sequence(self) -> "IUSequence":
        """Return the associated sequence"""
        if self.is_sequence:
            return self._sequence_run.sequence
        return None

    @property
    def sequence_zone(self) -> "IUSequenceZone":
        """Return the sequence zone for this run"""
        if self.is_sequence:
            if self._zone_run is not None:
                return self._zone_run.sequence_zone
            return self._sequence_run.sequence_zone(self)
        return None

    @property
    def sequence_running(self) -> bool:
        """Return True if this run is a sequence and running"""
        return self.is_sequence and self._sequence_run.running

    @property
    def crumbs(self) -> str:
        """Return the debugging details for this run"""
        return self._crumbs()

    def _crumbs(self) -> str:
        def get_index(obj: IUBase) -> int:
            if obj is not None:
                return obj.index + 1
            return 0

        if self.is_sequence:
            sidx = self.sequence_run.run_index(self) + 1
        else:
            sidx = 0

        return (
            f"{get_index(self._zone)}"
            f".{get_index(self._schedule)}"
            f".{get_index(self.sequence)}"
            f".{get_index(self.sequence_zone)}"
            f".{sidx}"
        )

    def sequence_has_adjustment(self, deep: bool) -> bool:
        """Return True if this run is a sequence and has an adjustment"""
        if self.is_sequence:
            return self.sequence.has_adjustment(deep)
        return False

    def sequence_adjustment(self) -> str:
        """Return the adjustment for the sequence"""
        if self.is_sequence:
            result = str(self._sequence_run.sequence.adjustment)
            sequence_zone = self._sequence_run.sequence_zone(self)
            if sequence_zone is not None and sequence_zone.has_adjustment:
                result = f"{result},{str(sequence_zone.adjustment)}"
            return result
        return None

    def is_manual(self) -> bool:
        """Check if this is a manual run"""
        return self._schedule is None

    def _get_status(self, stime: datetime) -> IURunStatus:
        """Determine the state of this run"""
        return IURunStatus.status(
            stime, self._start_time, self._end_time, self._pause_time
        )

    def update_status(self, stime: datetime) -> None:
        """Update the status of the run"""
        self._status = self._get_status(stime)

    def update_time_remaining(self, stime: datetime) -> bool:
        """Update the count down timers"""
        if self.running:
            self._remaining_time = self._end_time - stime
            total_duration: timedelta = self._end_time - self._start_time
            time_elapsed: timedelta = stime - self._start_time
            self._percent_complete = int((time_elapsed / total_duration) * 100)
            return True
        return False

    def pause(self, stime: datetime) -> None:
        """Change the pause status of the run"""
        if self._pause_time is not None:
            return
        self._pause_time = stime
        self.update_status(stime)

    def resume(self, stime: datetime) -> None:
        """Resume a paused run"""
        if self._pause_time is None:
            return
        delta = stime - self._pause_time
        self._start_time += delta
        self._end_time += delta
        self._pause_time = None
        self.update_status(stime)

    def as_dict(self) -> OrderedDict:
        """Return this run as a dict"""
        result = OrderedDict()
        result[TIMELINE_START] = self._start_time
        result[TIMELINE_END] = self._end_time
        result[TIMELINE_SCHEDULE_NAME] = self.schedule_name
        result[TIMELINE_ADJUSTMENT] = self.adjustment
        return result


def calc_on_time(runs: list[IURun]) -> timedelta:
    """Return the total time this list of runs is on. Accounts for
    overlapping time periods"""
    result = timedelta(0)
    period_start: datetime = None
    period_end: datetime = None

    for run in runs:
        if period_end is None or run.start_time > period_end:
            if period_end is not None:
                result += period_end - period_start
            period_start = run.start_time
            period_end = run.end_time
        else:
            period_end = max(period_end, run.end_time)
    if period_end is not None:
        result += period_end - period_start
    return result


class IURunQueue(list[IURun]):
    """Irrigation Unlimited class to hold the upcoming runs"""

    # pylint: disable=too-many-public-methods

    DAYS_SPAN: int = 3

    def __init__(self) -> None:
        super().__init__()
        # Config parameters
        self._future_span = wash_td(timedelta(days=self.DAYS_SPAN))
        # Private variables
        self._current_run: IURun = None
        self._next_run: IURun = None
        self._sorted: bool = False
        self._cancel_request: datetime = None
        self._next_event: datetime = None

    @property
    def current_run(self) -> IURun:
        """Return the current run"""
        return self._current_run

    @property
    def next_run(self) -> IURun:
        """Return the next run"""
        return self._next_run

    @property
    def in_sequence(self) -> bool:
        """Return True if this run is part of a sequence"""
        for run in self:
            if run.sequence_running:
                return True
        return False

    def add(
        self,
        stime: datetime,
        start_time: datetime,
        duration: timedelta,
        zone: "IUZone",
        schedule: "IUSchedule",
        sequence_run: "IUSequenceRun",
        zone_run: IURun,
    ) -> IURun:
        # pylint: disable=too-many-arguments
        """Add a run to the queue"""
        run = IURun(stime, start_time, duration, zone, schedule, sequence_run, zone_run)
        self.append(run)
        self._sorted = False
        return run

    def cancel(self, stime: datetime) -> None:
        """Flag the current run to be cancelled"""
        self._cancel_request = stime

    def clear_all(self) -> None:
        """Clear out all runs"""
        self._current_run = None
        self._next_run = None
        super().clear()

    def clear_runs(self, include_sequence: bool) -> bool:
        """Clear out the queue except for manual and running schedules"""
        modified = False
        i = len(self) - 1
        while i >= 0:
            run = self[i]
            if (include_sequence or not run.is_sequence) and not (
                run.running or run.is_manual()
            ):
                self.pop_run(i)
                modified = True
            i -= 1
        if modified:
            self._next_run = None
        return modified

    def find_last_index(self, schedule: IUSchedule) -> int:
        """Return the index of the run that finishes last in the queue.
        This routine does not require the list to be sorted."""
        result: int = None
        last_time: datetime = None
        for i, run in enumerate(self):
            if run.schedule is not None and run.schedule == schedule:
                if last_time is None or run.end_time > last_time:
                    last_time = run.end_time
                    result = i
        return result

    def find_last_run(self, schedule: IUSchedule) -> IURun:
        """Find the last run for the matching schedule"""
        i = self.find_last_index(schedule)
        if i is not None:
            return self[i]
        return None

    def last_time(self, stime: datetime) -> datetime:
        """Return the further most look ahead date"""
        return stime + self._future_span

    def load(self, config: OrderedDict, all_zones: OrderedDict):
        """Load the config settings"""
        fsd = IURunQueue.DAYS_SPAN
        if all_zones is not None:
            fsd = all_zones.get(CONF_FUTURE_SPAN, fsd)
        fsd = max(config.get(CONF_FUTURE_SPAN, fsd), 1)
        self._future_span = wash_td(timedelta(days=fsd))
        return self

    def sort(self) -> bool:
        """Sort the run queue."""

        def sorter(run: IURun) -> datetime:
            """Sort call back routine. Items are sorted by start_time."""
            if run.is_manual():
                start = datetime.min  # Always put manual run at head
            else:
                start = run.start_time
            return start.replace(tzinfo=None).isoformat(timespec="seconds")

        modified: bool = False
        if not self._sorted:
            super().sort(key=sorter)
            self._current_run = None
            self._next_run = None
            self._sorted = True
            modified = True
        return modified

    def remove_expired(self, stime: datetime, postamble: timedelta) -> bool:
        """Remove any expired runs from the queue"""
        modified: bool = False
        if postamble is None:
            postamble = timedelta(0)
        i = len(self) - 1
        while i >= 0:
            run = self[i]
            if not run.is_sequence:
                if run.expired and stime > run.end_time + postamble:
                    self.pop_run(i)
                    modified = True
            else:
                if (
                    run.sequence_run.expired
                    and run.expired
                    and stime > run.end_time + postamble
                ):
                    self.pop_run(i)
                    modified = True
            i -= 1
        return modified

    def remove_current(self) -> bool:
        """Remove the current run or upcoming manual run"""
        modified: bool = False
        if self._current_run is not None or (
            self._next_run is not None and self._next_run.is_manual()
        ):
            if len(self) > 0:
                self.pop_run(0)
            self._current_run = None
            self._next_run = None
            modified = True
        return modified

    def pop_run(self, index) -> "IURun":
        """Remove run from queue by index"""
        run = self.pop(index)
        if run == self._current_run:
            self._current_run = None
            self._next_run = None
        if run == self._next_run:
            self._next_run = None
        if run.master_run is not None:
            run.zone.controller.runs.remove_run(run.master_run)
            run.master_run = None
        return run

    def remove_run(self, run: IURun) -> "IURun":
        """Remove the run from the queue"""
        return self.pop_run(self.index(run))

    def update_run_status(self, stime) -> None:
        """Update the status of the runs"""
        for run in self:
            run.update_status(stime)

    def update_queue(self) -> IURQStatus:
        """Update the run queue. Sort the queue, remove expired runs
        and set current and next runs. This is the final operation after
        all additions and deletions.

        Returns a bit field of changes to queue.
        """
        # pylint: disable=too-many-branches
        status = IURQStatus(0)

        if self.sort():
            status |= IURQStatus.SORTED

        if self._cancel_request is not None:
            if self.remove_current():
                status |= IURQStatus.CANCELED
            self._cancel_request = None

        # Try to find a running schedule
        if self._current_run is not None and self._current_run.expired:
            self._current_run = None
            status |= IURQStatus.UPDATED
        if self._current_run is None:
            for run in self:
                if run.running and run.duration != timedelta(0):
                    self._current_run = run
                    self._next_run = None
                    status |= IURQStatus.UPDATED
                    break

        # Try to find the next schedule
        if self._next_run is not None and self._next_run.expired:
            self._next_run = None
            status |= IURQStatus.UPDATED
        if self._next_run is None:
            for run in self:
                if run.future and run.duration != timedelta(0):
                    self._next_run = run
                    status |= IURQStatus.UPDATED
                    break

        # Figure out the next state change
        dates: list[datetime] = [utc_eot()]
        for run in self:
            if not (run.expired or run.paused):
                if run.running:
                    dates.append(run.end_time)
                else:
                    dates.append(run.start_time)
        self._next_event = min(dates)

        return status

    def update_sensor(self, stime: datetime) -> bool:
        """Update the count down timers"""
        if self._current_run is None:
            return False
        return self._current_run.update_time_remaining(stime)

    def next_event(self) -> datetime:
        """Return the time of the next state change"""
        dates: list[datetime] = [self._next_event]
        dates.append(self._cancel_request)
        return min(d for d in dates if d is not None)

    def as_list(self) -> list:
        """Return a list of runs"""
        result = []
        for run in self:
            result.append(run.as_dict())
        return result


class IUScheduleQueue(IURunQueue):
    """Class to hold the upcoming schedules to run"""

    def __init__(self) -> None:
        super().__init__()
        # Config variables
        self._minimum: timedelta = None
        self._maximum: timedelta = None

    def constrain(self, duration: timedelta) -> timedelta:
        """Impose constraints on the duration"""
        if self._minimum is not None:
            duration = max(duration, self._minimum)
        if self._maximum is not None:
            duration = min(duration, self._maximum)
        return duration

    def clear_runs(self) -> bool:
        """Clear out the queue except for manual and running schedules"""
        # pylint: disable=arguments-differ
        return super().clear_runs(False)

    def add(
        self,
        stime: datetime,
        start_time: datetime,
        duration: timedelta,
        zone: "IUZone",
        schedule: "IUSchedule",
        sequence_run: "IUSequenceRun",
    ) -> IURun:
        """Add a new run to the queue"""
        # pylint: disable=arguments-differ
        # pylint: disable=too-many-arguments
        return super().add(
            stime,
            start_time,
            self.constrain(duration),
            zone,
            schedule,
            sequence_run,
            None,
        )

    def add_manual(
        self,
        stime: datetime,
        start_time: datetime,
        duration: timedelta,
        zone: "IUZone",
        queue: bool,
    ) -> IURun:
        """Add a manual run to the queue. Cancel any existing
        manual or running schedule"""

        if self._current_run is not None:
            self.pop_run(0)

        # Remove any existing manual schedules
        if not queue:
            for manual in (run for run in self if run.is_manual()):
                self.remove_run(manual)

        self._current_run = None
        self._next_run = None
        duration = max(duration, granularity_time())
        return self.add(stime, start_time, duration, zone, None, None)

    def merge_one(
        self,
        stime: datetime,
        zone: "IUZone",
        schedule: IUSchedule,
        adjustment: IUAdjustment,
    ) -> bool:
        """Extend the run queue. Return True if it was extended"""
        modified: bool = False

        # See if schedule already exists in run queue. If so get
        # the finish time of the last entry.
        last_run = self.find_last_run(schedule)
        if last_run is not None:
            next_time = last_run.end_time + granularity_time()
            is_running = last_run.running
        else:
            next_time = stime
            is_running = False

        duration = self.constrain(adjustment.adjust(schedule.duration))
        next_run = schedule.get_next_run(
            next_time, self.last_time(stime), duration, is_running
        )

        if next_run is not None:
            self.add(stime, next_run, duration, zone, schedule, None)
            modified = True

        return modified

    def merge_fill(
        self,
        stime: datetime,
        zone: "IUZone",
        schedule: IUSchedule,
        adjustment: IUAdjustment,
    ) -> bool:
        """Merge the schedule into the run queue. Add as many until the span is
        reached. Return True if the schedule was added."""
        modified: bool = False

        while self.merge_one(stime, zone, schedule, adjustment):
            modified = True

        return modified

    def load(self, config: OrderedDict, all_zones: OrderedDict) -> "IUScheduleQueue":
        """Load the configuration settings"""
        self._minimum = None
        self._maximum = None

        super().load(config, all_zones)
        if all_zones is not None:
            self._minimum = wash_td(all_zones.get(CONF_MINIMUM, self._minimum))
            self._maximum = wash_td(all_zones.get(CONF_MAXIMUM, self._maximum))
        self._minimum = wash_td(config.get(CONF_MINIMUM, self._minimum))
        self._maximum = wash_td(config.get(CONF_MAXIMUM, self._maximum))
        return self


class IUZone(IUBase):
    """Irrigation Unlimited Zone class"""

    # pylint: disable=too-many-instance-attributes
    # pylint: disable=too-many-public-methods

    def __init__(
        self,
        hass: HomeAssistant,
        coordinator: "IUCoordinator",
        controller: "IUController",
        zone_index: int,
    ) -> None:
        super().__init__(zone_index)
        # Passed parameters
        self._hass = hass
        self._coordinator = coordinator
        self._controller = controller
        # Config parameters
        self._zone_id: str = None
        self._enabled: bool = True
        self._allow_manual: bool = False
        self._name: str = None
        self._show_config: bool = False
        self._show_timeline: bool = False
        self._duration: timedelta = None
        # Private variables
        self._initialised: bool = False
        self._finalised: bool = False
        self._schedules: list[IUSchedule] = []
        self._run_queue = IUScheduleQueue()
        self._adjustment = IUAdjustment()
        self._zone_sensor: Entity = None
        self._is_on: bool = False
        self._sensor_update_required: bool = False
        self._sensor_last_update: datetime = None
        self._suspend_until: datetime = None
        self._dirty: bool = True
        self._switch = IUSwitch(hass, coordinator, controller, self)
        self._volume = IUVolume(hass, coordinator, self)
        self._user = IUUser()

    @property
    def controller(self) -> "IUController":
        """Return our parent (controller)"""
        return self._controller

    @property
    def unique_id(self) -> str:
        """Return the HA unique_id for the zone"""
        return f"c{self._controller.index + 1}_z{self.index + 1}"

    @property
    def entity_base(self) -> str:
        """Return the base of the entity_id"""
        if self._coordinator.rename_entities:
            return f"{self._controller.controller_id}_{self._zone_id}"
        return self.unique_id

    @property
    def entity_id(self) -> str:
        """Return the HA entity_id for the zone"""
        return f"{BINARY_SENSOR}.{DOMAIN}_{self.entity_base}"

    @property
    def schedules(self) -> "list[IUSchedule]":
        """Return a list of schedules associated with this zone"""
        return self._schedules

    @property
    def runs(self) -> IUScheduleQueue:
        """Return the run queue for this zone"""
        return self._run_queue

    @property
    def adjustment(self) -> IUAdjustment:
        """Return the adjustment for this zone"""
        return self._adjustment

    @property
    def volume(self) -> IUVolume:
        """Return the volume for this zone"""
        return self._volume

    @property
    def zone_id(self) -> str:
        """Return the zone_id. Should match the zone_id used in sequences"""
        return self._zone_id

    @property
    def name(self) -> str:
        """Return the friendly name for this zone"""
        if self._name is not None:
            return self._name
        return f"Zone {self._index + 1}"

    @property
    def is_on(self) -> bool:
        """Indicate if zone is on or off"""
        return self._is_on

    @property
    def is_setup(self) -> bool:
        """Return True if this zone is setup and ready to go"""
        return self._is_setup()

    @property
    def is_enabled(self) -> bool:
        """Return true is this zone is enabled and not suspended"""
        return self._enabled and self._suspend_until is None

    @property
    def enabled(self) -> bool:
        """Return true if this zone is enabled"""
        return self._enabled

    @enabled.setter
    def enabled(self, value: bool) -> None:
        """Enable/disable zone"""
        if value != self._enabled:
            self._enabled = value
            self._dirty = True
            self.request_update()

    @property
    def suspended(self) -> datetime:
        """Return the suspend date"""
        return self._suspend_until

    @suspended.setter
    def suspended(self, value: datetime) -> None:
        """Set the suspend time"""
        if value != self._suspend_until:
            self._suspend_until = value
            self._dirty = True
            self.request_update()

    @property
    def allow_manual(self) -> bool:
        """Return True if manual overide allowed"""
        return self._allow_manual

    @property
    def zone_sensor(self) -> Entity:
        """Return the HA entity associated with this zone"""
        return self._zone_sensor

    @zone_sensor.setter
    def zone_sensor(self, value: Entity) -> None:
        self._zone_sensor = value

    @property
    def status(self) -> str:
        """Return the state of the zone"""
        return self._status()

    @property
    def show_config(self) -> bool:
        """Indicate if the config show be shown as an attribute"""
        return self._show_config

    @property
    def show_timeline(self) -> bool:
        """Indicate if the timeline (run queue) should be shown as an attribute"""
        return self._show_timeline

    @property
    def today_total(self) -> timedelta:
        """Return the total on time for today"""
        return self._coordinator.history.today_total(self.entity_id)

    @property
    def icon(self) -> str:
        """Return the icon to use in the frontend."""
        if self._controller.is_enabled:
            if self.enabled:
                if self.suspended is None:
                    if self.is_on:
                        return ICON_ZONE_ON
                    return ICON_ZONE_OFF
                return ICON_SUSPENDED
            return ICON_DISABLED
        return ICON_BLOCKED

    @property
    def configuration(self) -> str:
        """Return this zone as JSON"""
        return json.dumps(self.as_dict(), cls=IUJSONEncoder)

    @property
    def user(self) -> dict:
        """Return the arbitrary user information"""
        return self._user

    def _is_setup(self) -> bool:
        """Check if this object is setup"""
        self._initialised = self._zone_sensor is not None

        if self._initialised:
            for schedule in self._schedules:
                self._initialised = self._initialised and schedule.is_setup
        return self._initialised

    def _status(self) -> str:
        """Return status of zone"""
        if self._initialised:
            if self._controller.is_enabled:
                if self.enabled:
                    if self.suspended is None:
                        if self.is_on:
                            return STATE_ON
                        return STATE_OFF
                    return STATUS_SUSPENDED
                return STATUS_DISABLED
            return STATUS_BLOCKED
        return STATUS_INITIALISING

    def service_edt(
        self, data: MappingProxyType, stime: datetime, service: str
    ) -> bool:
        """Handler for enable/disable/toggle service call"""
        # pylint: disable=unused-argument

        if service == SERVICE_ENABLE:
            new_state = True
        elif service == SERVICE_DISABLE:
            new_state = False
        else:
            new_state = not self.enabled  # Must be SERVICE_TOGGLE

        result = self.enabled != new_state
        if result:
            self.enabled = new_state
        return result

    def service_suspend(self, data: MappingProxyType, stime: datetime) -> bool:
        """Handler for the suspend service call"""
        sequence_id = data.get(CONF_SEQUENCE_ID)
        if sequence_id is not None:
            self._coordinator.logger.log_sequence_entity(stime)
        suspend_time = suspend_until_date(data, stime)
        if suspend_time != self._suspend_until:
            self.suspended = suspend_time
            return True
        return False

    def service_adjust_time(self, data: MappingProxyType, stime: datetime) -> bool:
        """Adjust the scheduled run times. Return true if adjustment changed"""
        sequence_id = data.get(CONF_SEQUENCE_ID)
        if sequence_id is not None:
            self._coordinator.logger.log_sequence_entity(stime)
        return self._adjustment.load(data)

    def service_manual_run(self, data: MappingProxyType, stime: datetime) -> None:
        """Add a manual run."""
        if self._allow_manual or (self.is_enabled and self._controller.is_enabled):
            duration = wash_td(data.get(CONF_TIME))
            delay = wash_td(data.get(CONF_DELAY, timedelta(0)))
            queue = data.get(CONF_QUEUE, self._controller.queue_manual)
            if duration is None or duration == timedelta(0):
                duration = self._duration
                if duration is None:
                    return
                duration = self._adjustment.adjust(duration)
            self._run_queue.add_manual(
                stime,
                self._controller.manual_run_start(stime, delay, queue),
                duration,
                self,
                queue,
            )

    def service_cancel(self, data: MappingProxyType, stime: datetime) -> None:
        """Cancel the current running schedule"""
        # pylint: disable=unused-argument
        self._run_queue.cancel(stime)

    def add(self, schedule: IUSchedule) -> IUSchedule:
        """Add a new schedule to the zone"""
        self._schedules.append(schedule)
        return schedule

    def find_add(self, index: int) -> IUSchedule:
        """Look for and add if necessary a new schedule"""
        if index >= len(self._schedules):
            return self.add(IUSchedule(self._hass, self._coordinator, index))
        return self._schedules[index]

    def clear(self) -> None:
        """Reset this zone"""
        self._schedules.clear()
        self.controller.clear_zones([self])

    def load(self, config: OrderedDict, all_zones: OrderedDict) -> "IUZone":
        """Load zone data from the configuration"""
        self.clear()
        if all_zones is not None:
            self._allow_manual = all_zones.get(CONF_ALLOW_MANUAL, self._allow_manual)
            self._duration = all_zones.get(CONF_DURATION, self._duration)
            if CONF_SHOW in all_zones:
                self._show_config = all_zones[CONF_SHOW].get(
                    CONF_CONFIG, self._show_config
                )
                self._show_timeline = all_zones[CONF_SHOW].get(
                    CONF_TIMELINE, self._show_timeline
                )
        self._zone_id = config.get(CONF_ZONE_ID, str(self.index + 1))
        self._enabled = config.get(CONF_ENABLED, self._enabled)
        self._allow_manual = config.get(CONF_ALLOW_MANUAL, self._allow_manual)
        self._duration = config.get(CONF_DURATION, self._duration)
        self._name = config.get(CONF_NAME, None)
        self._run_queue.load(config, all_zones)
        if CONF_SHOW in config:
            self._show_config = config[CONF_SHOW].get(CONF_CONFIG, self._show_config)
            self._show_timeline = config[CONF_SHOW].get(
                CONF_TIMELINE, self._show_timeline
            )
        if CONF_SCHEDULES in config:
            for sidx, schedule_config in enumerate(config[CONF_SCHEDULES]):
                self.find_add(sidx).load(schedule_config)
        self._switch.load(config, all_zones)
        self._volume.load(config, all_zones)
        self._user.load(config, all_zones)
        self._dirty = True
        return self

    def finalise(self, turn_off: bool) -> None:
        """Shutdown the zone"""
        if not self._finalised:
            if turn_off and self._is_on:
                self.call_switch(False)
            self.clear()
            self._finalised = True

    def as_dict(self, extended=False) -> OrderedDict:
        """Return this zone as a dict"""
        result = OrderedDict()
        result[CONF_INDEX] = self._index
        result[CONF_NAME] = self.name
        result[CONF_STATE] = STATE_ON if self.is_on else STATE_OFF
        result[CONF_ENABLED] = self.enabled
        result[ATTR_SUSPENDED] = self.suspended
        result[ATTR_ADJUSTMENT] = str(self._adjustment)
        if extended:
            if self.runs.current_run is not None:
                current_duration = self.runs.current_run.duration
            else:
                current_duration = timedelta(0)
            result[CONF_ICON] = self.icon
            result[CONF_ZONE_ID] = self._zone_id
            result[CONF_ENTITY_BASE] = self.entity_base
            result[ATTR_STATUS] = self.status
            result[ATTR_CURRENT_DURATION] = current_duration
            result[CONF_SCHEDULES] = [sch.as_dict() for sch in self._schedules]
            result[ATTR_SWITCH_ENTITIES] = self._switch.switch_entity_id
        return result

    def timeline(self) -> list:
        """Return the on/off timeline. Merge history and future and add
        the status"""
        run_list = self._coordinator.history.timeline(self.entity_id)
        for run in run_list:
            run[TIMELINE_STATUS] = RES_TIMELINE_HISTORY

        for run in self._run_queue:
            dct = run.as_dict()
            if run.running:
                dct[TIMELINE_STATUS] = RES_TIMELINE_RUNNING
            elif run == self._run_queue.next_run:
                dct[TIMELINE_STATUS] = RES_TIMELINE_NEXT
            else:
                dct[TIMELINE_STATUS] = RES_TIMELINE_SCHEDULED
            run_list.append(dct)
        run_list.reverse()
        return run_list

    def muster(self, stime: datetime) -> IURQStatus:
        """Muster this zone"""
        # pylint: disable=unused-argument
        status = IURQStatus(0)

        if self._dirty:
            self.controller.clear_zones([self])
            status |= IURQStatus.CLEARED

        if self._suspend_until is not None and stime >= self._suspend_until:
            self._suspend_until = None
            status |= IURQStatus.CHANGED

        self._switch.muster(stime)

        self._dirty = False
        return status

    def muster_schedules(self, stime: datetime) -> IURQStatus:
        """Calculate run times for this zone"""
        status = IURQStatus(0)

        for schedule in self._schedules:
            if not schedule.enabled:
                continue
            if self._run_queue.merge_fill(stime, self, schedule, self._adjustment):
                status |= IURQStatus.EXTENDED

        if not status.is_empty():
            self.request_update()

        return status

    def check_run(self, parent_enabled: bool) -> bool:
        """Update the run status"""
        is_running: bool = False
        state_changed: bool = False

        is_running = parent_enabled and (
            (
                self.is_enabled
                and self._run_queue.current_run is not None
                and self._run_queue.current_run.running
            )
            or (
                self._allow_manual
                and self._run_queue.current_run is not None
                and self._run_queue.current_run.is_manual()
            )
        )

        state_changed = is_running ^ self._is_on
        if state_changed:
            self._is_on = not self._is_on
            self.request_update()

        return state_changed

    def request_update(self) -> None:
        """Flag the sensor needs an update"""
        self._sensor_update_required = True

    def update_sensor(self, stime: datetime, do_on: bool) -> bool:
        """Lazy sensor updater"""
        updated: bool = False
        do_update: bool = False

        if self._zone_sensor is not None:
            if do_on is False:
                updated |= self._run_queue.update_sensor(stime)
                if not self._is_on:
                    # Force a refresh at midnight for the total_today attribute
                    if (
                        self._sensor_last_update is not None
                        and dt.as_local(self._sensor_last_update).toordinal()
                        != dt.as_local(stime).toordinal()
                    ):
                        do_update = True
                    do_update |= self._sensor_update_required
            else:
                if self._is_on:
                    # If we are running then update sensor according to refresh_interval
                    if self._run_queue.current_run is not None:
                        do_update = (
                            self._sensor_last_update is None
                            or stime - self._sensor_last_update
                            >= self._coordinator.refresh_interval
                        )
                    do_update |= self._sensor_update_required
        else:
            do_update = False

        if do_update:
            self._zone_sensor.schedule_update_ha_state()
            self._sensor_update_required = False
            self._sensor_last_update = stime
            updated = True

        return updated

    def next_awakening(self) -> datetime:
        """Return the next event time"""
        dates: list[datetime] = [
            self._run_queue.next_event(),
            self._switch.next_event(),
        ]
        if self._is_on and self._sensor_last_update is not None:
            dates.append(self._sensor_last_update + self._coordinator.refresh_interval)
        dates.append(self._suspend_until)
        return min(d for d in dates if d is not None)

    def check_switch(self, resync: bool, stime: datetime) -> list[str]:
        """Check the linked entity is in sync"""
        return self._switch.check_switch(stime, resync, True)

    def call_switch(self, state: bool, stime: datetime = None) -> None:
        """Turn the HA entity on or off"""
        self._switch.call_switch(state, stime)
        self._coordinator.status_changed(stime, self._controller, self, state)


class IUZoneQueue(IURunQueue):
    """Class to hold the upcoming zones to run"""

    def add_zone(
        self,
        stime: datetime,
        zone_run: IURun,
        preamble: timedelta,
        postamble: timedelta,
    ) -> IURun:
        """Add a new master run to the queue"""
        start_time = zone_run.start_time
        duration = zone_run.duration
        if preamble is not None:
            start_time -= preamble
            duration += preamble
        if postamble is not None:
            duration += postamble
        run = self.add(
            stime,
            start_time,
            duration,
            zone_run.zone,
            zone_run.schedule,
            zone_run.sequence_run,
            zone_run,
        )
        return run

    def rebuild_schedule(
        self,
        stime: datetime,
        zones: list[IUZone],
        preamble: timedelta,
        postamble: timedelta,
    ) -> IURQStatus:
        """Create a superset of all the zones."""

        status = IURQStatus(0)
        for zone in zones:
            for run in zone.runs:
                if run.master_run is None:
                    run.master_run = self.add_zone(stime, run, preamble, postamble)
        status |= IURQStatus.EXTENDED | IURQStatus.REDUCED
        return status


class IUSequenceZone(IUBase):
    """Irrigation Unlimited Sequence Zone class"""

    # pylint: disable=too-many-instance-attributes

    ZONE_OFFSET: int = 1

    def __init__(
        self,
        controller: "IUController",
        sequence: "IUSequence",
        zone_index: int,
    ) -> None:
        super().__init__(zone_index)
        # Passed parameters
        self._controller = controller
        self._sequence = sequence
        # Config parameters
        self._zone_ids: list[str] = None
        self._zones: list[IUZone] = []
        self._delay: timedelta = None
        self._duration: timedelta = None
        self._repeat: int = None
        self._enabled: bool = True
        self._volume: float = None
        # Private variables
        self._adjustment = IUAdjustment()
        self._suspend_until: datetime = None

    @property
    def zone_ids(self) -> list[str]:
        """Returns a list of zone_id's"""
        return self._zone_ids

    @property
    def zones(self) -> list[IUZone]:
        """Return the list of associated zones"""
        return self._zones

    @property
    def duration(self) -> timedelta:
        """Returns the duration for this sequence"""
        return self._duration

    @property
    def delay(self) -> timedelta:
        """ "Returns the post delay for this sequence"""
        return self._delay

    @property
    def repeat(self) -> int:
        """Returns the number of repeats for this sequence"""
        return self._repeat

    @property
    def volume(self) -> float:
        """Return the volume limit for this sequence"""
        return self._volume

    @property
    def is_enabled(self) -> bool:
        """Return true if this sequence_zone is enabled and not suspended"""
        return self._enabled and self._suspend_until is None

    @property
    def enabled(self) -> bool:
        """Return if this sequence zone is enabled"""
        return self._enabled

    @enabled.setter
    def enabled(self, value: bool) -> None:
        """Set the enabled state"""
        self._enabled = value

    @property
    def suspended(self) -> datetime:
        """Return the suspend date"""
        return self._suspend_until

    @suspended.setter
    def suspended(self, value: datetime) -> None:
        self._suspend_until = value

    @property
    def adjustment(self) -> IUAdjustment:
        """Returns the adjustment for this sequence"""
        return self._adjustment

    @property
    def has_adjustment(self) -> bool:
        """Returns True if this sequence has an active adjustment"""
        return self._adjustment.has_adjustment

    @property
    def is_on(self) -> bool:
        """Return if this sequence zone is running"""
        active_zone = self._sequence.runs.active_zone
        return active_zone is not None and active_zone.sequence_zone == self

    def icon(self, is_on: bool = None) -> str:
        """Return the icon to use in the frontend."""
        if self._controller.is_enabled:
            if self._sequence.is_enabled:
                if self.suspended is None:
                    if self._sequence.zone_enabled(self):
                        if is_on is None:
                            is_on = self.is_on
                        if is_on:
                            return ICON_SEQUENCE_ZONE_ON
                        return ICON_SEQUENCE_ZONE_OFF
                    return ICON_DISABLED
                return ICON_SUSPENDED
        return ICON_BLOCKED

    def status(self, is_on: bool = None) -> str:
        """Return status of the sequence zone"""
        if self._controller.is_enabled:
            if self._sequence.is_enabled:
                if self.suspended is None:
                    if self._sequence.zone_enabled(self):
                        if is_on is None:
                            is_on = self.is_on
                        if is_on:
                            return STATE_ON
                        return STATE_OFF
                    return STATUS_DISABLED
                return STATUS_SUSPENDED
        return STATUS_BLOCKED

    def load(self, config: OrderedDict) -> "IUSequenceZone":
        """Load sequence zone data from the configuration"""

        def build_zones() -> None:
            """Construct a local list of IUZones"""
            self._zones.clear()
            for zone_id in self._zone_ids:
                if (zone := self._controller.find_zone_by_zone_id(zone_id)) is not None:
                    self._zones.append(zone)

        self._zone_ids = config[CONF_ZONE_ID]
        self._delay = wash_td(config.get(CONF_DELAY))
        self._duration = wash_td(config.get(CONF_DURATION))
        self._repeat = config.get(CONF_REPEAT, 1)
        self._enabled = config.get(CONF_ENABLED, self._enabled)
        self._volume = config.get(CONF_VOLUME, self._volume)
        build_zones()
        return self

    def as_dict(
        self, duration_factor: float, extended=False, sqr: "IUSequenceRun" = None
    ) -> dict:
        """Return this sequence zone as a dict"""
        result = OrderedDict()
        result[CONF_INDEX] = self._index
        result[CONF_STATE] = STATE_ON if self.is_on else STATE_OFF
        result[CONF_ENABLED] = self.enabled
        result[ATTR_SUSPENDED] = self.suspended
        result[ATTR_ADJUSTMENT] = str(self._adjustment)
        if extended:
            result[CONF_ICON] = self.icon()
            result[ATTR_STATUS] = self.status()
            result[CONF_DELAY] = self._sequence.zone_delay(self, sqr)
            result[ATTR_BASE_DURATION] = self._sequence.zone_duration_base(self, sqr)
            result[ATTR_ADJUSTED_DURATION] = self._sequence.zone_duration(self, sqr)
            result[ATTR_FINAL_DURATION] = self._sequence.zone_duration_final(
                self, duration_factor, sqr
            )
            result[CONF_ZONES] = list(
                zone.index + self.ZONE_OFFSET for zone in self._zones
            )
            result[ATTR_CURRENT_DURATION] = self._sequence.runs.active_zone_duration(
                self
            )
        return result

    def ha_attr(self) -> dict:
        """Return the HA attributes"""

        def zone_runs() -> list[IURun]:
            result: list[IURun] = []
            if (sqr := self._sequence.runs.current_run) is None:
                sqr = self._sequence.runs.next_run
            if sqr is not None:
                for run in sqr.zone_runs(self):
                    if not run.expired:
                        result.append(run)
            return result

        result = {}
        result[ATTR_INDEX] = self.index
        result[ATTR_ENABLED] = self.enabled
        result[ATTR_SUSPENDED] = self.suspended
        result[ATTR_STATUS] = self.status()
        result[ATTR_ICON] = self.icon()
        result[ATTR_ADJUSTMENT] = str(self.adjustment)
        result[ATTR_ZONE_IDS] = self.zone_ids
        result[ATTR_DURATION] = str(calc_on_time(zone_runs()))
        return result

    def muster(self, stime: datetime) -> IURQStatus:
        """Muster this sequence zone"""
        status = IURQStatus(0)
        if self._suspend_until is not None and stime >= self._suspend_until:
            self._suspend_until = None
            status |= IURQStatus.CHANGED
        return status

    def next_awakening(self) -> datetime:
        """Return the next event time"""
        result = utc_eot()
        if self._suspend_until is not None:
            result = min(self._suspend_until, result)
        return result


class IUSequenceZoneRun(NamedTuple):
    """Irrigation Unlimited sequence zone run class"""

    sequence_zone: IUSequenceZone
    sequence_repeat: int
    zone_repeat: int


class IUSequenceRunAllocation(NamedTuple):
    """Irrigation Unlimited sequence zone allocation class"""

    start: datetime
    duration: timedelta
    zone: IUZone
    sequence_zone_run: IUSequenceZoneRun


class IUSequenceRun(IUBase):
    """Irrigation Unlimited sequence run manager class. Ties together the
    individual sequence zones"""

    # pylint: disable=too-many-instance-attributes
    # pylint: disable=too-many-public-methods
    def __init__(
        self,
        coordinator: "IUCoordinator",
        controller: "IUController",
        sequence: "IUSequence",
        schedule: IUSchedule,
    ) -> None:
        # pylint: disable=too-many-arguments
        super().__init__(None)
        # Passed parameters
        self._coordinator = coordinator
        self._controller = controller
        self._sequence = sequence
        self._schedule = schedule
        # Private variables
        self._runs_pre_allocate: list[IUSequenceRunAllocation] = []
        self._runs: dict[IURun, IUSequenceZoneRun] = weakref.WeakKeyDictionary()
        self._active_zone: IUSequenceZoneRun = None
        self._current_zone: IUSequenceZoneRun = None
        self._start_time: datetime = None
        self._end_time: datetime = None
        self._accumulated_duration = timedelta(0)
        self._first_zone: IUZone = None
        self._status = IURunStatus.UNKNOWN
        self._paused: datetime = None
        self._last_pause: datetime = None
        self._volume_trackers: list[CALLBACK_TYPE] = []
        self._volume_stats: dict[IUSequenceZoneRun, dict[IUZone, Decimal]] = {}
        self._remaining_time = timedelta(0)
        self._percent_complete: int = 0

    @property
    def sequence(self) -> "IUSequence":
        """Return the sequence associated with this run"""
        return self._sequence

    @property
    def schedule(self) -> IUSchedule:
        """Return the schedule associated with this run"""
        return self._schedule

    @property
    def start_time(self) -> datetime:
        """Return the start time for this sequence"""
        return self._start_time

    @property
    def end_time(self) -> datetime:
        """Return the end time for this sequence"""
        return self._end_time

    @property
    def total_time(self) -> timedelta:
        """Return the total run time for this sequence"""
        return self._end_time - self._start_time

    @property
    def running(self) -> bool:
        """Indicate if this sequence is running"""
        return self._status == IURunStatus.RUNNING

    @property
    def expired(self) -> bool:
        """Indicate if this sequence has expired"""
        return self._status == IURunStatus.EXPIRED

    @property
    def future(self) -> bool:
        """Indicate if this sequence starts in the future"""
        return self._status == IURunStatus.FUTURE

    @property
    def paused(self) -> bool:
        """Indicate if this sequence is paused"""
        return self._status == IURunStatus.PAUSED

    @property
    def active_zone(self) -> IUSequenceZoneRun:
        """Return the active zone in the sequence"""
        return self._active_zone

    @property
    def runs(self) -> dict[IURun, IUSequenceZoneRun]:
        """Return the runs"""
        return self._runs

    @property
    def time_remaining(self) -> timedelta:
        """Return the amount of time left to run"""
        return self._remaining_time

    @property
    def percent_complete(self) -> float:
        """Return the percentage completed"""
        return self._percent_complete

    def is_manual(self) -> bool:
        """Check if this is a manual run"""
        return self._schedule is None

    def zone_enabled(self, zone: IUZone) -> bool:
        """Return true if the zone is enabled"""
        return zone is not None and (
            zone.is_enabled or (self.is_manual() and zone.allow_manual)
        )

    def calc_total_time(self, total_time: timedelta) -> timedelta:
        """Calculate the total duration of the sequence"""
        if total_time is None:
            if self._schedule is not None and self._schedule.duration is not None:
                return self._sequence.total_time_final(self._schedule.duration, self)
            return self.sequence.total_time_final(None, self)

        if self._schedule is not None:
            return self._sequence.total_time_final(total_time, self)
        return total_time

    def build(self, duration_factor: float) -> timedelta:
        """Build out the sequence. Pre allocate runs and determine
        the duration"""
        # pylint: disable=too-many-nested-blocks
        next_run = self._start_time = self._end_time = wash_dt(dt.utcnow())
        for sequence_repeat in range(self._sequence.repeat):
            for sequence_zone in self._sequence.zones:
                if not self._sequence.zone_enabled(sequence_zone, self):
                    continue
                duration = self._sequence.zone_duration_final(
                    sequence_zone, duration_factor, self
                )
                duration_max = timedelta(0)
                delay = self._sequence.zone_delay(sequence_zone, self)
                for zone in sequence_zone.zones:
                    if self.zone_enabled(zone):
                        # Don't adjust manual run and no adjustment on adjustment
                        # This code should not really be here. It would be a breaking
                        # change if removed.
                        if not self.is_manual() and not self._sequence.has_adjustment(
                            True
                        ):
                            duration_adjusted = zone.adjustment.adjust(duration)
                            duration_adjusted = zone.runs.constrain(duration_adjusted)
                        else:
                            duration_adjusted = duration

                        zone_run_time = next_run
                        for zone_repeat in range(sequence_zone.repeat):
                            self._runs_pre_allocate.append(
                                IUSequenceRunAllocation(
                                    zone_run_time,
                                    duration_adjusted,
                                    zone,
                                    IUSequenceZoneRun(
                                        sequence_zone, sequence_repeat, zone_repeat
                                    ),
                                )
                            )
                            if self._first_zone is None:
                                self._first_zone = zone
                            if zone_run_time + duration_adjusted > self._end_time:
                                self._end_time = zone_run_time + duration_adjusted
                            zone_run_time += duration_adjusted + delay
                        duration_max = max(duration_max, zone_run_time - next_run)
                next_run += duration_max

        self._remaining_time = self._end_time - self._start_time
        return self._remaining_time

    def allocate_runs(self, stime: datetime, start_time: datetime) -> None:
        """Allocate runs"""
        delta = start_time - self._start_time
        self._start_time += delta
        self._end_time += delta
        for item in self._runs_pre_allocate:
            zone = item.zone
            run = zone.runs.add(
                stime, item.start + delta, item.duration, zone, self._schedule, self
            )
            self._runs[run] = item.sequence_zone_run
            self._accumulated_duration += run.duration
            zone.request_update()
        self._runs_pre_allocate.clear()
        self._status = IURunStatus.status(
            stime, self.start_time, self.end_time, self._paused
        )

    def first_zone(self) -> IUZone:
        """Return the first zone"""
        return self._first_zone

    def on_time(self, include_expired=False) -> timedelta:
        """Return the total time this run is on"""
        return calc_on_time(
            run for run in self._runs if include_expired or not run.expired
        )

    def zone_runs(self, sequence_zone: IUSequenceZone) -> list[IURun]:
        """Get the list of runs associated with the sequence zone"""
        return [
            run
            for run, sqz in self._runs.items()
            if sqz is not None and sqz.sequence_zone == sequence_zone
        ]

    def run_index(self, run: IURun) -> int:
        """Extract the index from the supplied run"""
        for i, key in enumerate(self._runs):
            if key == run:
                return i
        return None

    def sequence_zone(self, run: IURun) -> IUSequenceZone:
        """Extract the sequence zone from the run"""
        szr = self._runs.get(run, None)
        return szr.sequence_zone if szr is not None else None

    def next_sequence_zone(
        self, sequence_zone_run: IUSequenceZoneRun
    ) -> IUSequenceZoneRun:
        """Return the next sequence zone run in the run queue"""
        result: IUSequenceZoneRun = None
        found = False
        for szr in self.runs.values():
            if szr is None:
                continue
            if not found and szr == sequence_zone_run:
                found = True
            if found and szr.sequence_zone != sequence_zone_run.sequence_zone:
                result = szr
                break
        return result

    def sequence_zone_runs(self, sequence_zone_run: IUSequenceZoneRun) -> list[IURun]:
        """Return all the run associated with the sequence zone"""
        result: list[IURun] = []
        found = False
        for run, szr in self.runs.items():
            if not found and szr == sequence_zone_run:
                found = True
            if found and szr.sequence_zone == sequence_zone_run.sequence_zone:
                result.append(run)
            if found and szr.sequence_zone != sequence_zone_run.sequence_zone:
                break
        return result

    def advance(
        self,
        stime: datetime,
        duration: timedelta,
        runs: list[IURun] = None,
        shift_start: bool = False,
    ) -> None:
        """Advance the sequence run. If duration is positive runs will be
        extended if running or delayed if in the future. If duration is
        negative runs will shortened or even skipped. The system will
        require a full muster as the status of runs, zones and sequences
        could have altered."""

        def update_run(stime: datetime, duration: timedelta, run: IURun) -> None:
            if run.expired:
                return
            if duration > timedelta(0):
                if run.running:
                    if shift_start:
                        run.start_time += duration
                    run.end_time += duration
                elif run.future:
                    run.start_time += duration
                    run.end_time += duration
            else:
                if run.running:
                    run.end_time = max(run.end_time + duration, run.start_time)
                elif run.future:
                    run.start_time = max(run.start_time + duration, stime)
                    run.end_time = max(run.end_time + duration, run.start_time)
            run.duration = run.end_time - run.start_time
            run.update_status(stime)
            run.update_time_remaining(stime)

        if self.running:
            if runs is None:
                runs = self.runs
            for run in runs:
                if not run.expired:
                    update_run(stime, duration, run)
                    if run.master_run is not None:
                        update_run(stime, duration, run.master_run)

            end_time: datetime = None
            for run in self.runs:
                if end_time is None or run.end_time > end_time:
                    end_time = run.end_time
            self._end_time = end_time

            self.update()

    def skip(self, stime: datetime) -> None:
        """Skip to the next sequence zone"""
        current_start: datetime = None
        current_end: datetime = None
        current_runs = self.sequence_zone_runs(self._current_zone)
        for run in current_runs:
            if current_start is None or run.start_time < current_start:
                current_start = run.start_time
            if current_end is None or run.end_time > current_end:
                current_end = run.end_time

        next_start: datetime = None
        next_end: datetime = None
        nsz = self.next_sequence_zone(self._current_zone)
        if nsz is not None:
            for run in self.sequence_zone_runs(nsz):
                if next_start is None or run.start_time < next_start:
                    next_start = run.start_time
                if next_end is None or run.end_time > next_end:
                    next_end = run.end_time
            duration = next_start - stime
            if self._active_zone is not None:
                delay = max(next_start - current_end, timedelta(0))
            else:
                delay = max(current_start - stime, timedelta(0))
        else:
            duration = current_end - stime
            delay = timedelta(0)

        # Next zone is overlapped with current
        if next_start is not None and next_start < current_end:
            self.advance(stime, -(current_end - stime), current_runs)
        self.advance(stime, -(duration - delay), self._runs)

    def pause(self, stime: datetime) -> None:
        """Pause the sequence run"""

        def pause_run(stime: datetime, runs: list[IURun]) -> None:
            """Put the run and master into pause state"""
            for run in runs:
                run.pause(stime)
                if run.master_run is not None:
                    run.master_run.pause(stime)

        def run_state(run: IURun) -> int:
            """Return the state of the run.
            1 = expired
            2 = preamble (positive)
            3 = running
            4 = postamble (positive)
            5 = preamble (negative)
            6 = postamble (negative)
            7 = future
            """
            if run.expired and run.master_run.expired:
                return 1
            if run.future and run.master_run.running:
                return 2
            if run.expired and run.master_run.running:
                return 4
            if run.running and run.master_run.future:
                return 5
            if run.running and run.master_run.expired:
                return 6
            if run.future and run.master_run.future:
                return 7
            if run.running:
                return 3
            return 0

        def split_run(run: IURun, start: datetime, duration=timedelta(0)) -> None:
            split = run.zone.runs.add(
                stime,
                start,
                duration,
                run.zone,
                run.schedule,
                self,
            )
            self._runs[split] = None

        if self._paused is not None:
            return
        runs = self._runs.copy()
        pause_list = self._runs.copy()
        over_run = timedelta(0)
        for run in runs:
            state = run_state(run)
            if state == 2:
                split_run(run, stime - self._controller.postamble)
            elif state == 3:
                # Create a master postamble run out
                if (
                    self._controller.postamble is not None
                    and self._controller.postamble < timedelta(0)
                ):
                    over_run = -self._controller.postamble
                    run.master_run.start_time = stime + over_run
                run.start_time = stime
                split_run(run, stime, over_run)
            elif state == 6:
                pause_list.pop(run)
            elif state == 5:
                split_run(run, stime)
                run.start_time = stime
                run.master_run.start_time = stime - self._controller.preamble
            elif state == 4:
                split_run(run, run.master_run.end_time - self._controller.postamble)
        if over_run != timedelta(0):
            self.advance(stime, -over_run, runs)
        pause_run(stime, pause_list)
        self._paused = stime
        self._status = IURunStatus.PAUSED

    def resume(self, stime: datetime) -> None:
        """Resume the sequence run"""

        def resume_run(stime: datetime, runs: list[IURun]) -> None:
            """Take the run and master out of pause state"""
            for run in runs:
                run.resume(stime)
                if run.master_run is not None:
                    run.master_run.resume(stime)

        if self._paused is None:
            return
        resume_run(stime, self._runs)
        self._end_time += stime - self._paused
        self._paused = None
        self._status = IURunStatus.status(
            stime, self._start_time, self._end_time, self._paused
        )

        next_start = min(
            (run.start_time for run in self._runs if not run.expired), default=None
        )
        if next_start is not None:
            offset = stime - next_start
            if (
                self._controller.preamble is not None
                and self._controller.preamble > timedelta(0)
            ):
                offset += self._controller.preamble
            self.advance(stime, offset, self._runs, shift_start=True)

    def cancel(self, stime: datetime) -> None:
        """Cancel the sequence run"""
        self.advance(stime, -(self._end_time - stime))

    def update(self) -> bool:
        """Update the status of the sequence"""

        def update_volume(
            stime: datetime, zone: IUZone, volume: Decimal, rate: Decimal
        ) -> None:
            # pylint: disable=unused-argument
            if self._active_zone not in self._volume_stats:
                self._volume_stats[self._active_zone] = {}
            self._volume_stats[self._active_zone][zone] = volume
            self._sequence.volume = sum(
                sum(sta.values()) for sta in self._volume_stats.values()
            )
            if (limit := self._active_zone.sequence_zone.volume) is not None:
                current_vol = sum(self._volume_stats[self._active_zone].values())
                if current_vol >= limit:
                    self._coordinator.service_call(
                        SERVICE_SKIP, self._controller, None, self._sequence, {}
                    )

        def enable_trackers(sequence_zone: IUSequenceZone) -> None:
            for zone in sequence_zone.zones:
                self._volume_trackers.append(
                    zone.volume.track_volume_change(self.uid, update_volume)
                )

        def remove_trackers() -> None:
            for tracker in self._volume_trackers:
                tracker()
            self._volume_trackers.clear()

        if self.paused:
            return False
        result = False
        for run, sequence_zone_run in self._runs.items():
            if sequence_zone_run is None:
                continue
            if run.running and not self.running:
                # Sequence/sequence zone is starting
                self._status = IURunStatus.RUNNING
                self._active_zone = sequence_zone_run
                self._current_zone = sequence_zone_run
                self._coordinator.notify_sequence(
                    EVENT_START,
                    self._controller,
                    self._sequence,
                    self._schedule,
                    self,
                )
                enable_trackers(sequence_zone_run.sequence_zone)
                self._sequence.volume = None
                result |= True

            elif run.running and sequence_zone_run != self._active_zone:
                # Sequence zone is changing
                self._active_zone = sequence_zone_run
                self._current_zone = sequence_zone_run
                remove_trackers()
                enable_trackers(sequence_zone_run.sequence_zone)
                result |= True

            elif not run.running and sequence_zone_run == self._active_zone:
                # Sequence zone is finishing
                self._active_zone = None
                remove_trackers()
                self._current_zone = self.next_sequence_zone(sequence_zone_run)
                if self.run_index(run) == len(self._runs) - 1:
                    # Sequence is finishing
                    self._status = IURunStatus.EXPIRED
                    self._coordinator.notify_sequence(
                        EVENT_FINISH,
                        self._controller,
                        self._sequence,
                        self._schedule,
                        self,
                    )
                result |= True

        return result

    def update_time_remaining(self, stime: datetime) -> bool:
        """Update the count down timers"""
        if not self.running:
            return False
        self._remaining_time = self._end_time - stime
        elapsed = stime - self._start_time
        duration = self._end_time - self._start_time
        self._percent_complete = (
            int((elapsed / duration) * 100) if duration > timedelta(0) else 0
        )
        return True

    def as_dict(self, include_expired=False) -> dict:
        """Return this sequence run as a dict"""
        result = {}
        result[ATTR_INDEX] = self._sequence.index
        result[ATTR_NAME] = self._sequence.name
        result[ATTR_ENABLED] = self._sequence.enabled
        result[ATTR_SUSPENDED] = self._sequence.suspended
        result[ATTR_STATUS] = self._sequence.status
        result[ATTR_ICON] = self._sequence.icon
        result[ATTR_START] = dt.as_local(self._start_time)
        result[ATTR_DURATION] = to_secs(self.on_time(include_expired))
        result[ATTR_ADJUSTMENT] = str(self._sequence.adjustment)
        if not self.is_manual():
            result[ATTR_SCHEDULE] = {
                ATTR_INDEX: self._schedule.index,
                ATTR_NAME: self._schedule.name,
            }
        else:
            result[ATTR_SCHEDULE] = {
                ATTR_INDEX: None,
                ATTR_NAME: RES_MANUAL,
            }
        result[ATTR_ZONES] = []
        for zone in self._sequence.zones:
            runs = (
                run
                for run in self.zone_runs(zone)
                if include_expired or not run.expired
            )
            sqr = {}
            sqr[ATTR_INDEX] = zone.index
            sqr[ATTR_ENABLED] = zone.enabled
            sqr[ATTR_SUSPENDED] = zone.suspended
            sqr[ATTR_STATUS] = zone.status()
            sqr[ATTR_ICON] = zone.icon()
            sqr[ATTR_DURATION] = to_secs(calc_on_time(runs))
            sqr[ATTR_ADJUSTMENT] = str(zone.adjustment)
            sqr[ATTR_ZONE_IDS] = zone.zone_ids
            result[ATTR_ZONES].append(sqr)
        return result

    @staticmethod
    def skeleton(sequence: "IUSequence") -> dict:
        """Return a skeleton dict for when no sequence run is
        active. Must match the as_dict method"""

        result = {}
        result[ATTR_INDEX] = sequence.index
        result[ATTR_NAME] = sequence.name
        result[ATTR_ENABLED] = sequence.enabled
        result[ATTR_SUSPENDED] = sequence.suspended
        result[ATTR_STATUS] = sequence.status
        result[ATTR_ICON] = sequence.icon
        result[ATTR_START] = None
        result[ATTR_DURATION] = 0
        result[ATTR_ADJUSTMENT] = str(sequence.adjustment)
        result[ATTR_SCHEDULE] = {
            ATTR_INDEX: None,
            ATTR_NAME: None,
        }
        result[ATTR_ZONES] = []
        for zone in sequence.zones:
            sqr = {}
            sqr[ATTR_INDEX] = zone.index
            sqr[ATTR_ENABLED] = zone.enabled
            sqr[ATTR_SUSPENDED] = zone.suspended
            sqr[ATTR_STATUS] = zone.status(False)
            sqr[ATTR_ICON] = zone.icon(False)
            sqr[ATTR_DURATION] = 0
            sqr[ATTR_ADJUSTMENT] = str(zone.adjustment)
            sqr[ATTR_ZONE_IDS] = zone.zone_ids
            result[ATTR_ZONES].append(sqr)
        return result


class IUSequenceQueue(list[IUSequenceRun]):
    """Irrigation Unlimited class to hold the upcoming sequences"""

    DAYS_SPAN: int = 3

    def __init__(self) -> None:
        super().__init__()
        # Config parameters
        self._future_span = timedelta(days=self.DAYS_SPAN)
        # Private variables
        self._current_run: IUSequenceRun = None
        self._next_run: IUSequenceRun = None
        self._sorted: bool = False
        self._next_event: datetime = None

    @property
    def current_run(self) -> IUSequenceRun | None:
        """Return the current sequence run"""
        return self._current_run

    @property
    def next_run(self) -> IUSequenceRun | None:
        """Return the next sequence run"""
        return self._next_run

    @property
    def current_duration(self) -> timedelta:
        """Return the current active duration"""
        if self._current_run is not None:
            return self._current_run.total_time
        return timedelta(0)

    @property
    def active_zone(self) -> IUSequenceZoneRun | None:
        """Return the current active sequence zone"""
        if self._current_run is not None:
            return self._current_run.active_zone
        return None

    def active_zone_duration(self, sequence_zone: IUSequenceZone) -> timedelta:
        """Return the current duration for the specified sequence zone"""
        if self._current_run is not None:
            for run in self._current_run.zone_runs(sequence_zone):
                return run.duration
        return timedelta(0)

    def add(self, run: IUSequenceRun) -> IUSequenceRun:
        """Add a sequence run to the queue"""
        self.append(run)
        self._sorted = False
        return run

    def clear_all(self) -> None:
        """Clear out all runs"""
        self._current_run = None
        super().clear()

    def clear_runs(self) -> bool:
        """Clear out the queue except for manual and running schedules."""
        modified = False
        i = len(self) - 1
        while i >= 0:
            sqr = self[i]
            if not (sqr.is_manual() or sqr.running):
                for run in sqr.runs:
                    run.zone.runs.remove_run(run)
                self.pop(i)
                modified = True
            i -= 1
        if modified:
            self._next_run = None
        return modified

    def sort(self) -> bool:
        """Sort the run queue"""

        def sorter(run: IUSequenceRun) -> datetime:
            """Sort call back routine. Items are sorted by start_time"""
            if run.is_manual():
                return datetime.min.replace(tzinfo=dt.UTC)
            return run.start_time

        if self._sorted:
            return False
        super().sort(key=sorter)
        self._current_run = None
        self._next_run = None
        self._sorted = True
        return True

    def remove_expired(self, stime: datetime, postamble: timedelta) -> bool:
        """Remove any expired sequence runs from the queue"""
        if postamble is None:
            postamble = timedelta(0)
        modified: bool = False
        i = len(self) - 1
        while i >= 0:
            sqr = self[i]
            if sqr.expired and stime > sqr.end_time + postamble:
                self._current_run = None
                self._next_run = None
                self.pop(i)
                modified = True
            i -= 1
        return modified

    def update_queue(self) -> IURQStatus:
        """Update the run queue"""
        # pylint: disable=too-many-branches
        status = IURQStatus(0)

        if self.sort():
            status |= IURQStatus.SORTED

        for run in self:
            if run.update():
                self._current_run = None
                self._next_run = None
                status |= IURQStatus.CHANGED

        if self._current_run is None:
            for run in self:
                if run.running and run.on_time() != timedelta(0):
                    self._current_run = run
                    self._next_run = None
                    status |= IURQStatus.UPDATED
                    break

        if self._next_run is None:
            for run in self:
                if not run.running and run.on_time() != timedelta(0):
                    self._next_run = run
                    status |= IURQStatus.UPDATED
                    break

        dates: list[datetime] = [utc_eot()]
        for run in self:
            if run.running:
                dates.append(run.end_time)
            else:
                dates.append(run.start_time)
        self._next_event = min(dates)

        return status

    def update_sensor(self, stime: datetime) -> bool:
        """Update the count down timers"""
        result = False
        for run in self:
            result |= run.update_time_remaining(stime)
        return result

    def as_list(self) -> list:
        """Return a list of runs"""
        return [run.as_dict() for run in self]


class IUSequence(IUBase):
    """Irrigation Unlimited Sequence class"""

    # pylint: disable=too-many-instance-attributes
    # pylint: disable=too-many-public-methods

    def __init__(
        self,
        hass: HomeAssistant,
        coordinator: "IUCoordinator",
        controller: "IUController",
        sequence_index: int,
    ) -> None:
        super().__init__(sequence_index)
        # Passed parameters
        self._hass = hass
        self._coordinator = coordinator
        self._controller = controller
        # Config parameters
        self._name: str = None
        self._delay: timedelta = None
        self._duration: timedelta = None
        self._repeat: int = None
        self._enabled: bool = True
        # Private variables
        self._is_on = False
        self._is_in_delay = False
        self._paused = False
        self._run_queue = IUSequenceQueue()
        self._schedules: list[IUSchedule] = []
        self._zones: list[IUSequenceZone] = []
        self._adjustment = IUAdjustment()
        self._suspend_until: datetime = None
        self._sensor_update_required: bool = False
        self._sensor_last_update: datetime = None
        self._initialised: bool = False
        self._finalised: bool = False
        self._sequence_sensor: Entity = None
        self._volume: Decimal = None
        self._dirty = True

    @property
    def unique_id(self) -> str:
        """Return the HA unique_id for the sequence"""
        return f"c{self._controller.index + 1}_s{self.index + 1}"

    @property
    def entity_base(self) -> str:
        """Return the base of the entity_id. Entity rename
        not currently supported"""
        return self.unique_id

    @property
    def entity_id(self) -> str:
        """Return the HA entity_id for the sequence"""
        return f"{BINARY_SENSOR}.{DOMAIN}_{self.entity_base}"

    @property
    def runs(self) -> IUSequenceQueue:
        """Return the run queue for this sequence"""
        return self._run_queue

    @property
    def sequence_sensor(self) -> Entity:
        """Return the HA entity associated with this sequence"""
        return self._sequence_sensor

    @sequence_sensor.setter
    def sequence_sensor(self, value: Entity) -> None:
        self._sequence_sensor = value

    @property
    def volume(self) -> float:
        """Return the volume consumption"""
        if self._volume is not None:
            return float(self._volume)
        return None

    @volume.setter
    def volume(self, value: Decimal) -> None:
        self._volume = value

    @property
    def is_setup(self) -> bool:
        """Return True if this sequence is setup and ready to go"""
        self._initialised = self._sequence_sensor is not None

        if self._initialised:
            for schedule in self._schedules:
                self._initialised = self._initialised and schedule.is_setup
        return self._initialised

    @property
    def schedules(self) -> list[IUSchedule]:
        """Return the list of schedules for this sequence"""
        return self._schedules

    @property
    def zones(self) -> list[IUSequenceZone]:
        """Return the list of sequence zones"""
        return self._zones

    @property
    def name(self) -> str:
        """Return the friendly name of this sequence"""
        return self._name

    @property
    def delay(self) -> timedelta:
        """Returns the default inter zone delay"""
        return self._delay

    @property
    def duration(self) -> timedelta:
        """Returns the default zone duration"""
        return self._duration

    @property
    def repeat(self) -> int:
        """Returns the number of times to repeat this sequence"""
        return self._repeat

    @property
    def is_enabled(self) -> bool:
        """Return true is this sequence is enabled and not suspended"""
        return self._enabled and self._suspend_until is None

    @property
    def enabled(self) -> bool:
        """Return if this sequence is enabled"""
        return self._enabled

    @enabled.setter
    def enabled(self, value: bool) -> None:
        """Set the enabled state"""
        if value != self._enabled:
            self._enabled = value
            self._dirty = True
            self.request_update()

    @property
    def suspended(self) -> datetime:
        """Return the suspend date"""
        return self._suspend_until

    @suspended.setter
    def suspended(self, value: datetime) -> None:
        """Set the suspend date for this sequence"""
        if value != self._suspend_until:
            self._suspend_until = value
            self._dirty = True
            self.request_update()

    @property
    def adjustment(self) -> IUAdjustment:
        """Returns the sequence adjustment"""
        return self._adjustment

    @property
    def is_on(self) -> bool:
        """Return if the sequence is on or off"""
        return self._is_on

    @property
    def is_in_delay(self) -> bool:
        """Return is the sequence is waiting between zones"""
        return self._is_in_delay

    @property
    def is_paused(self) -> bool:
        """Return true is the sequence is paused"""
        return self._paused

    @property
    def icon(self) -> str:
        """Return the icon to use in the frontend."""
        if self._controller.is_enabled:
            if self.enabled:
                if self.suspended is None:
                    if self.is_on:
                        if self.is_paused:
                            return ICON_SEQUENCE_PAUSED
                        if self.is_in_delay:
                            return ICON_SEQUENCE_DELAY
                        return ICON_SEQUENCE_ON
                    return ICON_SEQUENCE_OFF
                return ICON_SUSPENDED
            return ICON_DISABLED
        return ICON_BLOCKED

    @property
    def status(self) -> str:
        """Return status of the sequence"""
        if self._controller.is_enabled:
            if self.enabled:
                if self.suspended is None:
                    if self.is_on:
                        if self.is_paused:
                            return STATUS_PAUSED
                        if self.is_in_delay:
                            return STATUS_DELAY
                        return STATE_ON
                    return STATE_OFF
                return STATUS_SUSPENDED
            return STATUS_DISABLED
        return STATUS_BLOCKED

    def has_adjustment(self, deep: bool) -> bool:
        """Indicates if this sequence has an active adjustment"""
        if self._adjustment.has_adjustment:
            return True
        if deep:
            for sequence_zone in self._zones:
                if sequence_zone.is_enabled and sequence_zone.adjustment.has_adjustment:
                    return True
        return False

    def zone_enabled(
        self, sequence_zone: IUSequenceZone, sqr: IUSequenceRun = None
    ) -> bool:
        """Return True if at least one real zone referenced by the
        sequence_zone is enabled"""
        if (
            (self._controller.is_enabled or (sqr is not None and sqr.is_manual()))
            and self.is_enabled
            and sequence_zone.is_enabled
        ):
            for zone in sequence_zone.zones:
                if zone.is_enabled or (sqr is not None and sqr.zone_enabled(zone)):
                    return True
        return False

    def constrain(
        self, sequence_zone: IUSequenceZone, duration: timedelta
    ) -> timedelta:
        """Apply the zone entity constraints"""
        for zone in sequence_zone.zones:
            duration = zone.runs.constrain(duration)
        return duration

    def zone_delay_config(self, sequence_zone: IUSequenceZone) -> timedelta:
        """Return the configured (static) delay"""
        if sequence_zone.delay is not None:
            delay = sequence_zone.delay
        else:
            delay = self._delay
        if delay is None:
            delay = timedelta(0)
        return delay

    def zone_delay(
        self, sequence_zone: IUSequenceZone, sqr: IUSequenceRun
    ) -> timedelta:
        """Return the delay for the specified zone"""
        if self.zone_enabled(sequence_zone, sqr):
            return self.zone_delay_config(sequence_zone)
        return timedelta(0)

    def total_delay(self, sqr: IUSequenceRun) -> timedelta:
        """Return the total delay for all the zones"""
        delay = timedelta(0)
        last_zone: IUSequenceZone = None
        if len(self._zones) > 0:
            for zone in self._zones:
                if self.zone_enabled(zone, sqr):
                    delay += self.zone_delay(zone, sqr) * zone.repeat
                    last_zone = zone
            delay *= self._repeat
            if last_zone is not None:
                delay -= self.zone_delay(last_zone, sqr)
        return delay

    def zone_duration_config(self, sequence_zone: IUSequenceZone) -> timedelta:
        """Return the configured (static) duration for the specified zone"""
        if sequence_zone.duration is not None:
            duration = sequence_zone.duration
        else:
            duration = self._duration
        if duration is None:
            duration = granularity_time()
        return duration

    def zone_duration_base(
        self, sequence_zone: IUSequenceZone, sqr: IUSequenceRun
    ) -> timedelta:
        """Return the base duration for the specified zone"""
        if self.zone_enabled(sequence_zone, sqr):
            return self.zone_duration_config(sequence_zone)
        return timedelta(0)

    def zone_duration(
        self, sequence_zone: IUSequenceZone, sqr: IUSequenceRun
    ) -> timedelta:
        """Return the duration for the specified zone including adjustments
        and constraints"""
        if self.zone_enabled(sequence_zone, sqr):
            duration = self.zone_duration_base(sequence_zone, sqr)
            duration = sequence_zone.adjustment.adjust(duration)
            return self.constrain(sequence_zone, duration)
        return timedelta(0)

    def zone_duration_final(
        self,
        sequence_zone: IUSequenceZone,
        duration_factor: float,
        sqr: IUSequenceRun,
    ) -> timedelta:
        """Return the final zone time after the factor has been applied"""
        duration = self.zone_duration(sequence_zone, sqr) * duration_factor
        duration = self.constrain(sequence_zone, duration)
        return round_td(duration)

    def total_duration(self, sqr: IUSequenceRun) -> timedelta:
        """Return the total duration for all the zones"""
        duration = timedelta(0)
        for zone in self._zones:
            duration += self.zone_duration(zone, sqr) * zone.repeat
        duration *= self._repeat
        return duration

    def total_duration_adjusted(self, total_duration, sqr: IUSequenceRun) -> timedelta:
        """Return the adjusted duration"""
        if total_duration is None:
            total_duration = self.total_duration(sqr)
        if self.has_adjustment(False):
            total_duration = self.adjustment.adjust(total_duration)
            total_duration = max(total_duration, timedelta(0))
        return total_duration

    def total_time_final(self, total_time: timedelta, sqr: IUSequenceRun) -> timedelta:
        """Return the adjusted total time for the sequence"""
        if total_time is not None and self.has_adjustment(False):
            total_delay = self.total_delay(sqr)
            total_duration = self.total_duration_adjusted(total_time - total_delay, sqr)
            return total_duration + total_delay

        if total_time is None:
            return self.total_duration_adjusted(None, sqr) + self.total_delay(sqr)

        return total_time

    def duration_factor(self, total_time: timedelta, sqr: IUSequenceRun) -> float:
        """Given a new total run time, calculate how much to shrink or expand each
        zone duration. Final time will be approximate as the new durations must
        be rounded to internal boundaries"""
        total_duration = self.total_duration(sqr)
        if total_time is not None and total_duration != timedelta(0):
            total_delay = self.total_delay(sqr)
            if total_time > total_delay:
                return (total_time - total_delay) / total_duration
            return 0.0
        return 1.0

    def clear(self) -> None:
        """Reset this sequence"""
        self._schedules.clear()

    def add_schedule(self, schedule: IUSchedule) -> IUSchedule:
        """Add a new schedule to the sequence"""
        self._schedules.append(schedule)
        return schedule

    def find_add_schedule(self, index: int) -> IUSchedule:
        """Look for and create if required a schedule"""
        if index >= len(self._schedules):
            return self.add_schedule(IUSchedule(self._hass, self._coordinator, index))
        return self._schedules[index]

    def add_zone(self, zone: IUSequenceZone) -> IUSequenceZone:
        """Add a new zone to the sequence"""
        self._zones.append(zone)
        return zone

    def get_zone(self, index: int) -> IUSequenceZone:
        """Return the specified zone object"""
        if index is not None and index >= 0 and index < len(self._zones):
            return self._zones[index]
        return None

    def find_add_zone(self, index: int) -> IUSequenceZone:
        """Look for and create if required a zone"""
        result = self.get_zone(index)
        if result is None:
            result = self.add_zone(IUSequenceZone(self._controller, self, index))
        return result

    def zone_list(self) -> Iterator[list[IUZone]]:
        """Generator to return all referenced zones"""
        result: set[IUZone] = set()
        for sequence_zone in self._zones:
            result.update(sequence_zone.zones)
        for zone in result:
            yield zone

    def load(self, config: OrderedDict) -> "IUSequence":
        """Load sequence data from the configuration"""
        self.clear()
        self._name = config.get(CONF_NAME, f"Run {self.index + 1}")
        self._delay = wash_td(config.get(CONF_DELAY))
        self._duration = wash_td(config.get(CONF_DURATION))
        self._repeat = config.get(CONF_REPEAT, 1)
        self._enabled = config.get(CONF_ENABLED, self._enabled)
        zidx: int = 0
        for zidx, zone_config in enumerate(config[CONF_ZONES]):
            self.find_add_zone(zidx).load(zone_config)
        while zidx < len(self._zones) - 1:
            self._zones.pop()
        if CONF_SCHEDULES in config:
            for sidx, schedule_config in enumerate(config[CONF_SCHEDULES]):
                self.find_add_schedule(sidx).load(schedule_config)
        self._dirty = True
        return self

    def as_dict(self, extended=False, sqr: IUSequenceRun = None) -> OrderedDict:
        """Return this sequence as a dict"""
        total_delay = self.total_delay(sqr)
        total_duration = self.total_duration(sqr)
        total_duration_adjusted = self.total_duration_adjusted(total_duration, sqr)
        duration_factor = self.duration_factor(
            total_duration_adjusted + total_delay, sqr
        )
        result = OrderedDict()
        result[CONF_INDEX] = self._index
        result[CONF_NAME] = self._name
        result[CONF_STATE] = STATE_ON if self.is_on else STATE_OFF
        result[CONF_ENABLED] = self._enabled
        result[ATTR_SUSPENDED] = self.suspended
        result[ATTR_ADJUSTMENT] = str(self._adjustment)
        result[CONF_SEQUENCE_ZONES] = [
            szn.as_dict(duration_factor, extended) for szn in self._zones
        ]
        if extended:
            result[ATTR_ICON] = self.icon
            result[ATTR_STATUS] = self.status
            result[ATTR_DEFAULT_DURATION] = self._duration
            result[ATTR_DEFAULT_DELAY] = self._delay
            result[ATTR_DURATION_FACTOR] = duration_factor
            result[ATTR_TOTAL_DELAY] = total_delay
            result[ATTR_TOTAL_DURATION] = total_duration
            result[ATTR_ADJUSTED_DURATION] = total_duration_adjusted
            result[ATTR_CURRENT_DURATION] = self.runs.current_duration
            result[CONF_SCHEDULES] = [sch.as_dict() for sch in self._schedules]
        return result

    def ha_zone_attr(self) -> list[dict]:
        """Return the HA zone attributes"""
        return [szn.ha_attr() for szn in self._zones]

    def muster(self, stime: datetime) -> IURQStatus:
        """Muster this sequence"""
        status = IURQStatus(0)

        if self._dirty:
            self._run_queue.clear_all()
            status |= IURQStatus.CLEARED

        if self._suspend_until is not None and stime >= self._suspend_until:
            self._suspend_until = None
            status |= IURQStatus.CHANGED

        for sequence_zone in self._zones:
            status |= sequence_zone.muster(stime)

        self._dirty = False
        return status

    def check_run(self, stime: datetime, parent_enabled: bool) -> bool:
        """Update the run status"""
        # pylint: disable=unused-argument
        is_running = parent_enabled and (
            self.is_enabled
            and self._run_queue.current_run is not None
            and (
                self._run_queue.current_run.running
                or self._run_queue.current_run.paused
            )
        )

        state_changed = is_running ^ self._is_on
        if state_changed:
            self._is_on = not self._is_on
            self.request_update()

        is_in_delay = is_running and self._run_queue.current_run.active_zone is None
        if is_in_delay ^ self._is_in_delay:
            self._is_in_delay = not self._is_in_delay
            self.request_update()

        is_paused = is_running and self._run_queue.current_run.paused
        if is_paused ^ self._paused:
            self._paused = not self._paused
            self.request_update()

        return state_changed

    def request_update(self) -> None:
        """Flag the sensor needs an update"""
        self._sensor_update_required = True

    def update_sensor(self, stime: datetime, do_on: bool) -> bool:
        """Lazy sensor updater"""
        updated: bool = False
        do_update: bool = False

        if self._sequence_sensor is not None:
            if do_on is False:
                updated |= self._run_queue.update_sensor(stime)
                do_update = not self.is_on and self._sensor_update_required
            else:
                if self.is_on:
                    # If we are running then update sensor according to refresh_interval
                    do_update = (
                        self._sensor_last_update is None
                        or stime - self._sensor_last_update
                        >= self._coordinator.refresh_interval
                    )
                    do_update |= self._sensor_update_required
        else:
            do_update = False

        if do_update:
            self._sequence_sensor.schedule_update_ha_state()
            self._sensor_update_required = False
            self._sensor_last_update = stime
            updated = True

        return updated

    def next_awakening(self) -> datetime:
        """Return the next event time"""
        dates: list[datetime] = [utc_eot()]
        dates.append(self._suspend_until)
        dates.extend(sqz.next_awakening() for sqz in self._zones)
        return min(d for d in dates if d is not None)

    def finalise(self) -> None:
        """Shutdown the sequence"""
        if not self._finalised:
            self._finalised = True

    def service_edt(
        self, data: MappingProxyType, stime: datetime, service: str
    ) -> bool:
        """Service handler for enable/disable/toggle"""
        # pylint: disable=unused-argument
        changed = False
        zone_list: list[int] = data.get(CONF_ZONES)
        if zone_list is None:
            new_state = s2b(self.enabled, service)
            if self.enabled != new_state:
                self.enabled = new_state
                changed = True
        else:
            for sequence_zone in self.zones:
                if check_item(sequence_zone.index, zone_list):
                    new_state = s2b(sequence_zone.enabled, service)
                    if sequence_zone.enabled != new_state:
                        sequence_zone.enabled = new_state
                        changed = True
        if changed:
            self._run_queue.clear_runs()
        return changed

    def service_suspend(self, data: MappingProxyType, stime: datetime) -> bool:
        """Service handler for suspend"""
        changed = False
        suspend_time = suspend_until_date(data, stime)
        zone_list: list[int] = data.get(CONF_ZONES)
        if zone_list is None:
            if self.suspended != suspend_time:
                self.suspended = suspend_time
                changed = True
        else:
            for sequence_zone in self.zones:
                if check_item(sequence_zone.index, zone_list):
                    if sequence_zone.suspended != suspend_time:
                        sequence_zone.suspended = suspend_time
                        changed = True
        if changed:
            self._run_queue.clear_runs()
        return changed

    def service_adjust_time(self, data: MappingProxyType, stime: datetime) -> bool:
        """Service handler for adjust_time"""
        # pylint: disable=unused-argument
        changed = False
        zone_list: list[int] = data.get(CONF_ZONES)
        if zone_list is None:
            changed = self.adjustment.load(data)
        else:
            for sequence_zone in self.zones:
                if check_item(sequence_zone.index, zone_list):
                    changed |= sequence_zone.adjustment.load(data)
        if changed:
            self._run_queue.clear_runs()
        return changed

    def service_manual_run(self, data: MappingProxyType, stime: datetime) -> None:
        """Service handler for manual_run"""
        duration = wash_td(data.get(CONF_TIME))
        delay = wash_td(data.get(CONF_DELAY, timedelta(0)))
        queue = data.get(CONF_QUEUE, self._controller.queue_manual)
        if duration is not None and duration == timedelta(0):
            duration = None
        self._controller.muster_sequence(
            self._controller.manual_run_start(stime, delay, queue), self, None, duration
        )

    def service_cancel(self, data: MappingProxyType, stime: datetime) -> bool:
        """Cancel the sequence"""
        # pylint: disable=unused-argument
        changed = False
        for sqr in self._run_queue:
            if sqr.running:
                sqr.cancel(stime)
                changed = True
        return changed

    def service_skip(self, data: MappingProxyType, stime: datetime) -> bool:
        """Skip to the next sequence zone"""
        # pylint: disable=unused-argument
        changed = False
        for sqr in self._run_queue:
            if sqr.running:
                sqr.skip(stime)
                changed = True
        return changed

    def service_pause(self, data: MappingProxyType, stime: datetime) -> bool:
        """Pause the sequence"""
        # pylint: disable=unused-argument

        def is_preamble(sqr: IUSequenceRun) -> bool:
            return (
                self._controller.preamble is not None
                and sqr.future
                and stime > sqr.start_time - self._controller.preamble
            )

        changed = False
        for sqr in self._run_queue:
            if not sqr.paused and (
                is_preamble(sqr) or (sqr.end_time >= stime and sqr.running)
            ):
                sqr.pause(stime)
                changed = True
        return changed

    def service_resume(self, data: MappingProxyType, stime: datetime) -> bool:
        """Resume the sequence"""
        # pylint: disable=unused-argument
        changed = False
        for sqr in self._run_queue:
            if sqr.paused:
                sqr.resume(stime)
                changed = True
        return changed


class IUController(IUBase):
    """Irrigation Unlimited Controller (Master) class"""

    # pylint: disable=too-many-instance-attributes
    # pylint: disable=too-many-public-methods
    def __init__(
        self, hass: HomeAssistant, coordinator: "IUCoordinator", controller_index: int
    ) -> None:
        # Passed parameters
        super().__init__(controller_index)
        self._hass = hass
        self._coordinator = coordinator  # Parent
        # Config parameters
        self._enabled: bool = True
        self._name: str = None
        self._controller_id: str = None
        self._preamble: timedelta = None
        self._postamble: timedelta = None
        self._queue_manual: bool = False
        # Private variables
        self._initialised: bool = False
        self._finalised: bool = False
        self._zones: list[IUZone] = []
        self._sequences: list[IUSequence] = []
        self._run_queue = IUZoneQueue()
        self._switch = IUSwitch(hass, coordinator, self, None)
        self._master_sensor: Entity = None
        self._is_on: bool = False
        self._sensor_update_required: bool = False
        self._sensor_last_update: datetime = None
        self._suspend_until: datetime = None
        self._volume = IUVolume(hass, coordinator, None)
        self._user = IUUser()
        self._dirty: bool = True

    @property
    def controller_id(self) -> str:
        """Return the controller_id for the controller entity"""
        return self._controller_id

    @property
    def unique_id(self) -> str:
        """Return the HA unique_id for the controller entity"""
        return f"c{self.index + 1}_m"

    @property
    def entity_base(self) -> str:
        """Return the base entity_id"""
        if self._coordinator.rename_entities:
            return self._controller_id
        return self.unique_id

    @property
    def entity_id(self) -> str:
        """Return the HA entity_id for the controller entity"""
        return f"{BINARY_SENSOR}.{DOMAIN}_{self.entity_base}"

    @property
    def zones(self) -> "list[IUZone]":
        """Return a list of children zones"""
        return self._zones

    @property
    def sequences(self) -> "list[IUSequence]":
        """Return a list of children sequences"""
        return self._sequences

    @property
    def runs(self) -> IUZoneQueue:
        """Return the master run queue"""
        return self._run_queue

    @property
    def name(self) -> str:
        """Return the friendly name for the controller"""
        return self._name

    @property
    def is_on(self) -> bool:
        """Return if the controller is on or off"""
        return self._is_on

    @property
    def is_setup(self) -> bool:
        """Indicate if the controller is setup and ready to go"""
        return self._is_setup()

    @property
    def is_enabled(self) -> bool:
        """Return true if this controller is enabled and not suspended"""
        return self._enabled and self._suspend_until is None

    @property
    def enabled(self) -> bool:
        """Return true is this controller is enabled"""
        return self._enabled

    @enabled.setter
    def enabled(self, value: bool) -> None:
        """Enable/disable this controller"""
        if value != self._enabled:
            self._enabled = value
            self._dirty = True
            self.request_update(True)

    @property
    def suspended(self) -> datetime:
        """Return the suspend date"""
        return self._suspend_until

    @suspended.setter
    def suspended(self, value: datetime) -> None:
        """Set the suspend date for this controller"""
        if value != self._suspend_until:
            self._suspend_until = value
            self._dirty = True
            self.request_update(True)

    @property
    def master_sensor(self) -> Entity:
        """Return the associated HA entity"""
        return self._master_sensor

    @master_sensor.setter
    def master_sensor(self, value: Entity) -> None:
        self._master_sensor = value

    @property
    def preamble(self) -> timedelta:
        """Return the preamble time for the controller"""
        return self._preamble

    @property
    def postamble(self) -> timedelta:
        """Return the postamble time for the controller"""
        return self._postamble

    @property
    def queue_manual(self) -> bool:
        """Return if manual runs should be queue"""
        return self._queue_manual

    @property
    def status(self) -> str:
        """Return the state of the controller"""
        return self._status()

    @property
    def is_paused(self) -> bool:
        """Returns True if the controller is running a sequence. The
        controller may be off because of a delay between sequence zones"""
        return self._run_queue.in_sequence

    @property
    def icon(self) -> str:
        """Return the icon to use in the frontend."""
        if self.enabled:
            if self.suspended is None:
                if self.is_on:
                    return ICON_CONTROLLER_ON
                if self.is_paused:
                    return ICON_CONTROLLER_DELAY
                return ICON_CONTROLLER_OFF
            return ICON_SUSPENDED
        return ICON_DISABLED

    @property
    def user(self) -> dict:
        """Return the arbitrary user information"""
        return self._user

    @property
    def volume(self) -> IUVolume:
        """Return the volume for this zone"""
        return self._volume

    def _status(self) -> str:
        """Return status of the controller"""
        if self._initialised:
            if self.enabled:
                if self.suspended is None:
                    if self._is_on:
                        return STATE_ON
                    if self._run_queue.in_sequence:
                        return STATUS_DELAY
                    return STATE_OFF
                return STATUS_SUSPENDED
            return STATUS_DISABLED
        return STATUS_INITIALISING

    def _is_setup(self) -> bool:
        self._initialised = self._master_sensor is not None

        if self._initialised:
            for zone in self._zones:
                self._initialised = self._initialised and zone.is_setup
            for sequence in self._sequences:
                self._initialised = self._initialised and sequence.is_setup
        return self._initialised

    def add_zone(self, zone: IUZone) -> IUZone:
        """Add a new zone to the controller"""
        self._zones.append(zone)
        return zone

    def get_zone(self, index: int) -> IUZone:
        """Return the zone by index"""
        if index is not None and index >= 0 and index < len(self._zones):
            return self._zones[index]
        return None

    def find_add_zone(
        self, coordinator: "IUCoordinator", controller: "IUController", index: int
    ) -> IUZone:
        """Locate and create if required the zone object"""
        if index >= len(self._zones):
            return self.add_zone(IUZone(self._hass, coordinator, controller, index))
        return self._zones[index]

    def add_sequence(self, sequence: IUSequence) -> IUSequence:
        """Add a new sequence to the controller"""
        self._sequences.append(sequence)
        return sequence

    def get_sequence(self, index: int) -> IUSequence:
        """Locate the sequence by index"""
        if index is not None and index >= 0 and index < len(self._sequences):
            return self._sequences[index]
        return None

    def find_add_sequence(self, index: int) -> IUSequence:
        """Locate and create if required a sequence"""
        if index >= len(self._sequences):
            return self.add_sequence(
                IUSequence(self._hass, self._coordinator, self, index)
            )
        return self._sequences[index]

    def find_zone_by_zone_id(self, zone_id: str) -> IUZone:
        """Find the real zone from a zone_id"""
        for zone in self._zones:
            if zone.zone_id == zone_id:
                return zone
        return None

    def clear(self) -> None:
        """Clear out the controller"""
        # Don't clear zones
        # self._zones.clear()
        self.clear_zones(None)

    def clear_zones(self, zones: list[IUZone]) -> None:
        """Clear out the specified zone run queues"""
        if zones is None:
            self.runs.clear_all()
            for sequence in self._sequences:
                sequence.runs.clear_all()
            for zone in self._zones:
                zone.runs.clear_all()
        else:
            for run in self.runs:
                if run.zone in zones:
                    self.runs.remove(run)
            for sequence in self._sequences:
                for zone in sequence.zone_list():
                    if zone in zones:
                        sequence.runs.clear_all()
            for zone in zones:
                zone.runs.clear_all()

    def clear_zone_runs(self, zone: IUZone) -> None:
        """Clear out zone run queues"""
        zone.runs.clear_runs()
        for sequence in self._sequences:
            if zone in sequence.zone_list():
                sequence.runs.clear_runs()

    def load(self, config: OrderedDict) -> "IUController":
        """Load config data for the controller"""
        self.clear()
        self._enabled = config.get(CONF_ENABLED, self._enabled)
        self._name = config.get(CONF_NAME, f"Controller {self.index + 1}")
        self._controller_id = config.get(CONF_CONTROLLER_ID, str(self.index + 1))
        self._preamble = wash_td(config.get(CONF_PREAMBLE))
        self._postamble = wash_td(config.get(CONF_POSTAMBLE))
        self._queue_manual = config.get(CONF_QUEUE_MANUAL, self._queue_manual)
        all_zones = config.get(CONF_ALL_ZONES_CONFIG)
        zidx: int = 0
        for zidx, zone_config in enumerate(config[CONF_ZONES]):
            self.find_add_zone(self._coordinator, self, zidx).load(
                zone_config, all_zones
            )
        while zidx < len(self._zones) - 1:
            self._zones.pop().finalise(True)

        if CONF_SEQUENCES in config:
            qidx: int = 0
            for qidx, sequence_config in enumerate(config[CONF_SEQUENCES]):
                self.find_add_sequence(qidx).load(sequence_config)
            while qidx < len(self._sequences) - 1:
                self._sequences.pop()
        else:
            self._sequences.clear()

        self._switch.load(config, None)
        self._volume.load(config, None)
        self._user.load(config, None)
        self._dirty = True
        return self

    def finalise(self, turn_off: bool) -> None:
        """Shutdown this controller"""
        if not self._finalised:
            for zone in self._zones:
                zone.finalise(turn_off)
            if turn_off and self._is_on:
                self.call_switch(False)
            self.clear()
            self._finalised = True

    def as_dict(self, extended=False) -> OrderedDict:
        """Return this controller as a dict"""
        result = OrderedDict()
        result[CONF_INDEX] = self._index
        result[CONF_NAME] = self._name
        result[CONF_ENABLED] = self._enabled
        result[ATTR_SUSPENDED] = self.suspended
        result[CONF_ZONES] = [zone.as_dict(extended) for zone in self._zones]
        result[CONF_SEQUENCES] = [seq.as_dict(extended) for seq in self._sequences]
        result[CONF_STATE] = STATE_ON if self.is_on else STATE_OFF
        if extended:
            result[CONF_CONTROLLER_ID] = self._controller_id
            result[CONF_ENTITY_BASE] = self.entity_base
            result[CONF_ICON] = self.icon
            result[ATTR_STATUS] = self.status
        return result

    def sequence_runs(self) -> list[IUSequenceRun]:
        """Gather all the sequence runs"""
        result: list[IUSequenceRun] = []
        for sequence in self._sequences:
            for run in sequence.runs:
                result.append(run)
        return result

    def up_next(self) -> dict[IUSequence, IUSequenceRun]:
        """Return a list of sequences and their next start times filtered"""
        sequences: dict[IUSequence, IUSequenceRun] = {}
        for run in self.sequence_runs():
            if not run.expired:
                sample = sequences.get(run.sequence)
                if sample is None or run.start_time < sample.start_time:
                    sequences[run.sequence] = run
        return sequences

    def sequence_status(self, include_expired=False) -> list[dict]:
        """Return the sequence status or run information"""
        result: list[dict] = []
        runs = self.up_next()
        for sequence in self._sequences:
            run = runs.get(sequence)
            if run is not None:
                result.append(run.as_dict(include_expired))
            else:
                result.append(IUSequenceRun.skeleton(sequence))
        return result

    def muster_sequence(
        self,
        stime: datetime,
        sequence: IUSequence,
        schedule: IUSchedule,
        total_time: timedelta = None,
    ) -> IURQStatus:
        # pylint: disable=too-many-locals, too-many-statements
        """Muster the sequences for the controller"""

        def init_run_time(
            stime: datetime,
            sequence: IUSequence,
            schedule: IUSchedule,
            zone: IUZone,
            total_duration: timedelta,
        ) -> datetime:
            def is_running(sequence: IUSequence, schedule: IUSchedule) -> bool:
                """Return True is this sequence & schedule is currently running"""
                for srn in sequence.runs:
                    if srn.schedule == schedule and srn.running:
                        return True
                return False

            def find_last_run(sequence: IUSequence, schedule: IUSchedule) -> IURun:
                result: IUSequenceRun = None
                next_time: datetime = None
                for sqr in sequence.runs:
                    if sqr.schedule == schedule:
                        for run in sqr.runs:
                            if next_time is None or run.end_time > next_time:
                                next_time = run.start_time
                                result = run
                return result

            if schedule is not None:
                last_run = find_last_run(sequence, schedule)
                if last_run is not None:
                    next_time = last_run.sequence_run.end_time
                else:
                    next_time = stime
                next_run = schedule.get_next_run(
                    next_time,
                    zone.runs.last_time(stime),
                    total_duration,
                    is_running(sequence, schedule),
                )
            else:
                next_run = stime
            return next_run

        status = IURQStatus(0)
        sequence_run = IUSequenceRun(self._coordinator, self, sequence, schedule)
        total_time = sequence_run.calc_total_time(total_time)
        duration_factor = sequence.duration_factor(total_time, sequence_run)

        total_time = sequence_run.build(duration_factor)
        if total_time > timedelta(0):
            start_time = init_run_time(
                stime, sequence, schedule, sequence_run.first_zone(), total_time
            )
            if start_time is not None:
                sequence_run.allocate_runs(stime, start_time)
                sequence.runs.add(sequence_run)
                status |= IURQStatus.EXTENDED
        return status

    def muster(self, stime: datetime, force: bool) -> IURQStatus:
        """Calculate run times for this controller. This is where most of the hard yakka
        is done."""
        # pylint: disable=too-many-branches
        status = IURQStatus(0)

        if self._dirty or force:
            self.clear_zones(None)
            status |= IURQStatus.CLEARED
        else:
            for zone in self._zones:
                zone.runs.update_run_status(stime)
            self._run_queue.update_run_status(stime)

        if self._suspend_until is not None and stime >= self._suspend_until:
            self._suspend_until = None
            status |= IURQStatus.CHANGED

        self._switch.muster(stime)

        zone_status = IURQStatus(0)

        # Handle initialisation
        for zone in self._zones:
            zone_status |= zone.muster(stime)

        for sequence in self._sequences:
            sms = sequence.muster(stime)
            if not sms.is_empty():
                sequence.runs.clear_runs()
                zone_status |= sms

        if not self._coordinator.tester.enabled or self._coordinator.tester.is_testing:
            # pylint: disable=too-many-nested-blocks
            # Process sequence schedules
            for sequence in self._sequences:
                if sequence.is_enabled:
                    for schedule in sequence.schedules:
                        if not schedule.enabled:
                            continue
                        next_time = stime
                        while True:
                            if self.muster_sequence(
                                next_time, sequence, schedule, None
                            ).is_empty():
                                break
                            zone_status |= IURQStatus.EXTENDED

            # Process zone schedules
            for zone in self._zones:
                if zone.is_enabled:
                    zone_status |= zone.muster_schedules(stime)

        # Post processing
        for sequence in self._sequences:
            zone_status |= sequence.runs.update_queue()

        for zone in self._zones:
            zts = zone.runs.update_queue()
            if IURQStatus.CANCELED in zts:
                zone.request_update()
            zone_status |= zts

        if zone_status.has_any(
            IURQStatus.CLEARED
            | IURQStatus.EXTENDED
            | IURQStatus.SORTED
            | IURQStatus.CANCELED
            | IURQStatus.CHANGED
        ):
            status |= self._run_queue.rebuild_schedule(
                stime, self._zones, self._preamble, self._postamble
            )
        status |= self._run_queue.update_queue()

        # Purge expired runs
        for sequence in self._sequences:
            if sequence.runs.remove_expired(stime, self._postamble):
                zone_status |= IURQStatus.REDUCED
        for zone in self._zones:
            if zone.runs.remove_expired(stime, self._postamble):
                status |= IURQStatus.REDUCED

        if not status.is_empty():
            self.request_update(False)

        self._dirty = False
        return status | zone_status

    def check_run(self, stime: datetime) -> bool:
        """Check the run status and update sensors. Return flag
        if anything has changed."""

        for sequence in self._sequences:
            sequence.check_run(stime, self.is_enabled)

        zones_changed: list[int] = []

        run = self._run_queue.current_run
        is_enabled = self.is_enabled or (run is not None and run.is_manual())
        is_running = is_enabled and run is not None
        state_changed = is_running ^ self._is_on

        # Gather zones that have changed status
        for zone in self._zones:
            if zone.check_run(is_enabled):
                zones_changed.append(zone.index)

        # Handle off zones before master
        for zone in (self._zones[i] for i in zones_changed):
            if not zone.is_on:
                zone.call_switch(zone.is_on, stime)
                zone.volume.end_record(stime)

        # Check if master has changed and update
        if state_changed:
            self._is_on = not self._is_on
            self.request_update(False)
            self.call_switch(self._is_on, stime)
            if self._is_on:
                self._volume.start_record(stime)
            else:
                self._volume.end_record(stime)

        # Handle on zones after master
        for zone in (self._zones[i] for i in zones_changed):
            if zone.is_on:
                zone.call_switch(zone.is_on, stime)
                zone.volume.start_record(stime)

        return state_changed

    def check_links(self) -> bool:
        """Check inter object links"""

        result = True
        zone_ids = set()
        for zone in self._zones:
            if zone.zone_id in zone_ids:
                self._coordinator.logger.log_duplicate_id(self, zone, None)
                result = False
            else:
                zone_ids.add(zone.zone_id)

        for sequence in self._sequences:
            for sequence_zone in sequence.zones:
                for zone_id in sequence_zone.zone_ids:
                    if zone_id not in zone_ids:
                        self._coordinator.logger.log_orphan_id(
                            self, sequence, sequence_zone, zone_id
                        )
                        result = False
        return result

    def request_update(self, deep: bool) -> None:
        """Flag the sensor needs an update. The actual update is done
        in update_sensor"""
        self._sensor_update_required = True
        if deep:
            for zone in self._zones:
                zone.request_update()

            for sequence in self._sequences:
                sequence.request_update()

    def update_sensor(self, stime: datetime) -> None:
        """Lazy sensor updater."""
        self._run_queue.update_sensor(stime)

        for zone in self._zones:
            zone.update_sensor(stime, False)

        for sequence in self._sequences:
            sequence.update_sensor(stime, False)

        if self._master_sensor is not None:
            do_update: bool = self._sensor_update_required

            # If we are running then update sensor according to refresh_interval
            if self._run_queue.current_run is not None:
                do_update = (
                    do_update
                    or self._sensor_last_update is None
                    or stime - self._sensor_last_update
                    >= self._coordinator.refresh_interval
                )

            if do_update:
                self._master_sensor.schedule_update_ha_state()
                self._sensor_update_required = False
                self._sensor_last_update = stime

        for zone in self._zones:
            zone.update_sensor(stime, True)

        for sequence in self._sequences:
            sequence.update_sensor(stime, True)

    def next_awakening(self) -> datetime:
        """Return the next event time"""
        dates: list[datetime] = [
            self._run_queue.next_event(),
            self._switch.next_event(),
            self._suspend_until,
        ]
        dates.extend(zone.next_awakening() for zone in self._zones)
        dates.extend(seq.next_awakening() for seq in self._sequences)
        if self._is_on and self._sensor_last_update is not None:
            dates.append(self._sensor_last_update + self._coordinator.refresh_interval)
        return min(d for d in dates if d is not None)

    def check_switch(self, resync: bool, stime: datetime) -> list[str]:
        """Check the linked entity is in sync"""
        result = self._switch.check_switch(stime, resync, True)
        for zone in self._zones:
            result.extend(zone.check_switch(resync, stime))

        return result

    def call_switch(self, state: bool, stime: datetime = None) -> None:
        """Update the linked entity if enabled"""
        self._switch.call_switch(state, stime)
        self._coordinator.status_changed(stime, self, None, state)

    def decode_sequence_id(
        self, stime: datetime, sequence_id: int | None
    ) -> list[int] | None:
        """Convert supplied 1's based id into a list of sequence
        indexes and validate"""
        if sequence_id is None:
            return None
        sequence_list: list[int] = []
        if sequence_id == 0:
            sequence_list.extend(sequence.index for sequence in self._sequences)
        else:
            if self.get_sequence(sequence_id - 1) is not None:
                sequence_list.append(sequence_id - 1)
            else:
                self._coordinator.logger.log_invalid_sequence(stime, self, sequence_id)
        return sequence_list

    def manual_run_start(
        self, stime: datetime, delay: timedelta, queue: bool
    ) -> datetime:
        """Determine the next available start time for a manual run"""
        nst = wash_dt(stime)
        if not self._is_on:
            nst += granularity_time()
        if (
            self.preamble is not None
            and self.preamble > timedelta(0)
            and not self.is_on
        ):
            nst += self.preamble
        if queue:
            end_times: list[datetime] = []
            for zone in self._zones:
                end_times.extend(run.end_time for run in zone.runs if run.is_manual())
            if len(end_times) > 0:
                nst = max(end_times) + delay
        return nst

    def service_edt(
        self, data: MappingProxyType, stime: datetime, service: str
    ) -> bool:
        """Handler for enable/disable/toggle service call"""
        # pylint: disable=too-many-branches, too-many-nested-blocks
        changed = False
        sequence_list = self.decode_sequence_id(stime, data.get(CONF_SEQUENCE_ID))
        if sequence_list is None:
            new_state = s2b(self.enabled, service)
            if self.enabled != new_state:
                self.enabled = new_state
                changed = True
        else:
            for sequence in (self.get_sequence(sqid) for sqid in sequence_list):
                changed |= sequence.service_edt(data, stime, service)
            if changed:
                self.request_update(True)
        return changed

    def service_suspend(self, data: MappingProxyType, stime: datetime) -> bool:
        """Handler for the suspend service call"""
        # pylint: disable=too-many-nested-blocks
        changed = False
        suspend_time = suspend_until_date(data, stime)
        sequence_list = self.decode_sequence_id(stime, data.get(CONF_SEQUENCE_ID))
        if sequence_list is None:
            if suspend_time != self._suspend_until:
                self.suspended = suspend_time
                changed = True
        else:
            for sequence in (self.get_sequence(sqid) for sqid in sequence_list):
                changed |= sequence.service_suspend(data, stime)
            if changed:
                self.request_update(True)
        return changed

    def service_adjust_time(self, data: MappingProxyType, stime: datetime) -> bool:
        """Handler for the adjust_time service call"""
        # pylint: disable=too-many-nested-blocks
        changed = False
        zone_list: list[int] = data.get(CONF_ZONES)
        sequence_list = self.decode_sequence_id(stime, data.get(CONF_SEQUENCE_ID))
        if sequence_list is None:
            for zone in self._zones:
                if check_item(zone.index, zone_list):
                    if zone.service_adjust_time(data, stime):
                        self.clear_zone_runs(zone)
                        changed = True
        else:
            for sequence in (self.get_sequence(sqid) for sqid in sequence_list):
                changed |= sequence.service_adjust_time(data, stime)
            if changed:
                self.request_update(True)
        return changed

    def service_manual_run(self, data: MappingProxyType, stime: datetime) -> None:
        """Handler for the manual_run service call"""
        sequence_id = data.get(CONF_SEQUENCE_ID, None)
        if sequence_id is None:
            zone_list: list[int] = data.get(CONF_ZONES, None)
            for zone in self._zones:
                if zone_list is None or zone.index + 1 in zone_list:
                    zone.service_manual_run(data, stime)
        else:
            if (sequence := self.get_sequence(sequence_id - 1)) is not None:
                sequence.service_manual_run(data, stime)
            else:
                self._coordinator.logger.log_invalid_sequence(stime, self, sequence_id)

    def service_cancel(self, data: MappingProxyType, stime: datetime) -> bool:
        """Handler for the cancel service call"""
        changed = False
        zone_list: list[int] = data.get(CONF_ZONES, None)
        sequence_list = self.decode_sequence_id(stime, data.get(CONF_SEQUENCE_ID))
        if sequence_list is None:
            for zone in self._zones:
                if zone_list is None or zone.index + 1 in zone_list:
                    zone.service_cancel(data, stime)
                    changed = True
        else:
            for sequence in (self.get_sequence(sqid) for sqid in sequence_list):
                changed |= sequence.service_cancel(data, stime)
            if changed:
                self.request_update(True)
        return changed

    def service_pause(self, data: MappingProxyType, stime: datetime) -> bool:
        """Handler for the pause service call"""
        changed = False
        sequence_list = self.decode_sequence_id(stime, data.get(CONF_SEQUENCE_ID))
        if sequence_list is not None:
            for sequence in (self.get_sequence(sqid) for sqid in sequence_list):
                changed |= sequence.service_pause(data, stime)
        return changed

    def service_resume(self, data: MappingProxyType, stime: datetime) -> bool:
        """Handler for the resume service call"""
        changed = False
        sequence_list = self.decode_sequence_id(stime, data.get(CONF_SEQUENCE_ID))
        if sequence_list is not None:
            for sequence in (self.get_sequence(sqid) for sqid in sequence_list):
                changed |= sequence.service_resume(data, stime)
        return changed


class IUEvent:
    """This class represents a single event"""

    def __init__(self) -> None:
        # Private variables
        self._time: datetime = None
        self._controller: int = None
        self._zone: int = None
        self._state: bool = None
        self._crumbs: str = None

    def __eq__(self, other: "IUEvent") -> bool:
        return (
            self._time == other._time
            and self._controller == other.controller
            and self._zone == other.zone
            and self._state == other.state
        )

    def __str__(self) -> str:
        return (
            f"- {{t: '{dt2lstr(self._time)}', "
            f"c: {self._controller}, "
            f"z: {self._zone}, "
            f"s: {str(int(self._state))}}}"
        )

    @property
    def time(self) -> datetime:
        """Return the time property"""
        return self._time

    @property
    def controller(self) -> int:
        """Return the controller property"""
        return self._controller

    @property
    def zone(self) -> int:
        """Return the zone property"""
        return self._zone

    @property
    def state(self) -> bool:
        """Return the state property"""
        return self._state

    @property
    def crumbs(self) -> str:
        """Return the tracking information"""
        return self._crumbs

    def load(self, config: OrderedDict) -> "IUEvent":
        """Initialise from a config"""
        self._time: datetime = wash_dt(dt.as_utc(config["t"]))
        self._controller: int = config["c"]
        self._zone: int = config["z"]
        self._state: bool = config["s"]
        return self

    def load2(
        self, stime: datetime, controller: int, zone: int, state: bool, crumbs: str
    ) -> "IUEvent":
        """Initialise from individual components"""
        # pylint: disable=too-many-arguments
        self._time = stime
        self._controller = controller
        self._zone = zone
        self._state = state
        self._crumbs = crumbs
        return self


class IUTest(IUBase):
    """This class represents a single test. Contains a list of
    expected results."""

    # pylint: disable=too-many-instance-attributes
    def __init__(self, test_index: int, speed: float) -> None:
        # Passed parameters
        super().__init__(test_index)
        self._speed = speed
        # Config parameters
        self._name: str = None
        self._start: datetime = None
        self._end: datetime = None
        self._results: list[IUEvent] = []
        # Private variables
        self._current_result: int = 0
        self._events: int = 0
        self._checks: int = 0
        self._errors: int = 0
        self._perf_mon: int = 0
        self._delta: timedelta = None
        self._test_time: float = 0

    @property
    def name(self) -> str:
        """Return the friendly name for this test"""
        return self._name

    @property
    def start(self) -> datetime:
        """Return the start time for this test"""
        return self._start

    @property
    def end(self) -> datetime:
        """Return the end time for this test"""
        return self._end

    @property
    def events(self) -> int:
        """Return the number of events received"""
        return self._events

    @property
    def checks(self) -> int:
        """Return the number of checks performed"""
        return self._checks

    @property
    def errors(self) -> int:
        """Return the number of errors identified"""
        return self._errors

    @property
    def test_time(self) -> float:
        """Return the test run time"""
        return self._test_time

    @property
    def virtual_duration(self) -> timedelta:
        """Return the real duration"""
        return (self._end - self._start) / self._speed

    @property
    def total_results(self) -> int:
        """Return the number of expected results from the test"""
        return len(self._results)

    def is_finished(self, atime) -> bool:
        """Indicate if this test has finished"""
        return self.virtual_time(atime) >= self._end

    def next_result(self) -> IUEvent:
        """Return the next result"""
        if self._current_result < len(self._results):
            result = self._results[self._current_result]
            self._current_result += 1
            return result
        return None

    def check_result(self, result: IUEvent, event: IUEvent) -> bool:
        """Compare the expected result and the event"""
        self._events += 1
        if result is not None:
            self._checks += 1
            if result != event:
                self._errors += 1
                return False
        else:
            return False
        return True

    def clear(self) -> None:
        """Remove all the results"""
        self._results.clear()

    def load(self, config: OrderedDict):
        """Load the configuration"""
        self.clear()
        self._start = wash_dt(dt.as_utc(config[CONF_START]))
        self._end = wash_dt(dt.as_utc(config[CONF_END]))
        self._name = config.get(CONF_NAME, None)
        if CONF_RESULTS in config:
            for result in config[CONF_RESULTS]:
                self._results.append(IUEvent().load(result))
        return self

    def begin_test(self, atime: datetime) -> None:
        """Start test"""
        self._delta = atime - self._start
        self._perf_mon = tm.perf_counter()
        self._current_result = 0
        self._events = 0
        self._checks = 0
        self._errors = 0
        self._test_time = 0

    def end_test(self) -> None:
        """Finalise test"""
        self._test_time = tm.perf_counter() - self._perf_mon

    def virtual_time(self, atime: datetime) -> datetime:
        """Return the virtual clock. For testing we can speed
        up time. This routine will return a virtual time based
        on the real time and the duration from start. It is in
        effect a test warp speed"""
        virtual_start: datetime = atime - self._delta
        actual_duration: float = (virtual_start - self._start).total_seconds()
        virtual_duration: float = actual_duration * self._speed
        vtime = self._start + timedelta(seconds=virtual_duration)
        # The conversion may not be exact due to the internal precision
        # of the compiler particularly at high speed values. Compensate
        # by rounding if the value is very close to an internal boundary
        vtime_rounded = round_dt(vtime)
        if abs(vtime - vtime_rounded) < timedelta(microseconds=100000):
            return vtime_rounded
        return vtime

    def actual_time(self, stime: datetime) -> datetime:
        """Return the actual time from the virtual time"""
        virtual_duration = (stime - self._start).total_seconds()
        actual_duration = virtual_duration / self._speed
        return self._start + self._delta + timedelta(seconds=actual_duration)


class IUTester:
    """Irrigation Unlimited testing class"""

    # pylint: disable=too-many-instance-attributes, too-many-public-methods

    def __init__(self, coordinator: "IUCoordinator") -> None:
        # Passed parameters
        self._coordinator = coordinator
        # Config parameters
        self._enabled: bool = False
        self._speed: float = None
        self._output_events: bool = None
        self._show_log: bool = None
        self._autoplay: bool = None
        # Private variables
        self._tests: list[IUTest] = []
        self._test_initialised = False
        self._running_test: int = None
        self._last_test: int = None
        self._autoplay_initialised: bool = False
        self._ticker: datetime = None
        self._tests_completed: set[int] = set()
        self.load(None)

    @property
    def enabled(self) -> bool:
        """Return the enabled property"""
        return self._enabled

    @property
    def speed(self) -> float:
        """Return the test speed"""
        return self._speed

    @property
    def is_testing(self) -> bool:
        """Indicate if this test is running"""
        return self._is_testing()

    @property
    def tests(self) -> "list[IUTest]":
        """Return the list of tests to perform"""
        return self._tests

    @property
    def current_test(self) -> IUTest:
        """Returns the current test"""
        if self._running_test is not None and self._running_test < len(self._tests):
            return self._tests[self._running_test]
        return None

    @property
    def last_test(self) -> IUTest:
        """Returns the last test that was run"""
        if self._last_test is not None and self._last_test < len(self._tests):
            return self._tests[self._last_test]
        return None

    @property
    def total_events(self) -> int:
        """Returns the total number of events received"""
        result: int = 0
        for test in self._tests:
            result += test.events
        return result

    @property
    def total_checks(self) -> int:
        """Returns the total number of checks performed"""
        result: int = 0
        for test in self._tests:
            result += test.checks
        return result

    @property
    def total_errors(self) -> int:
        """Returns the number of errors detected"""
        result: int = 0
        for test in self._tests:
            result += test.errors
        return result

    @property
    def total_time(self) -> float:
        """Returns the total amount of time to run tests"""
        result: float = 0
        for test in self._tests:
            result += test.test_time
        return result

    @property
    def total_tests(self) -> int:
        """Returns the number of tests to run"""
        return len(self._tests)

    @property
    def total_virtual_duration(self) -> timedelta:
        """Returns the real time duration of the tests"""
        result = timedelta(0)
        for test in self._tests:
            result += test.virtual_duration
        return result

    @property
    def total_results(self) -> int:
        """Returns the total number of results expected"""
        result: int = 0
        for test in self._tests:
            result += test.total_results
        return result

    @property
    def tests_completed(self) -> int:
        """Return the number of tests completed"""
        return len(self._tests_completed)

    @property
    def ticker(self) -> datetime:
        """Return the tester clock"""
        return self._ticker

    @ticker.setter
    def ticker(self, value: datetime) -> None:
        """Set the tester clock"""
        self._ticker = value

    def virtual_time(self, atime: datetime) -> datetime:
        """Convert actual time to virtual time"""
        if self.is_testing:
            return self.current_test.virtual_time(atime)
        return atime

    def actual_time(self, stime: datetime) -> datetime:
        """Convert virtual time to actual time"""
        if self.is_testing:
            return self.current_test.actual_time(stime)
        return stime

    def start_test(self, test_no: int, atime: datetime) -> IUTest:
        """Start the test"""
        self._ticker = atime
        if 0 < test_no <= len(self._tests):
            self._running_test = test_no - 1  # 0-based
            test = self._tests[self._running_test]
            test.begin_test(atime)
            if self._show_log:
                self._coordinator.logger.log_test_start(
                    test.virtual_time(atime), test, INFO
                )
            else:
                self._coordinator.logger.log_test_start(test.virtual_time(atime), test)
            self._test_initialised = False
        else:
            self._running_test = None
        return self.current_test

    def end_test(self, atime: datetime) -> None:
        """Finish the test"""
        test = self.current_test
        if test is not None:
            test.end_test()
            if self._show_log:
                self._coordinator.logger.log_test_end(
                    test.virtual_time(atime), test, INFO
                )
            else:
                self._coordinator.logger.log_test_end(test.virtual_time(atime), test)
            self._tests_completed.add(self._running_test)
        self._last_test = self._running_test
        self._running_test = None

    def next_test(self, atime: datetime) -> IUTest:
        """Run the next test"""
        current = self._running_test  # This is 0-based
        self.end_test(atime)
        return self.start_test(current + 2, atime)  # This takes 1-based

    def _is_testing(self) -> bool:
        return self._enabled and self._running_test is not None

    def clear(self) -> None:
        """Reset the tester"""
        # Private variables
        self._tests.clear()
        self._test_initialised = False
        self._running_test = None
        self._last_test = None
        self._autoplay_initialised = False
        self._ticker: datetime = None
        self._tests_completed.clear()

    def load(self, config: OrderedDict) -> "IUTester":
        """Load config data for the tester"""
        # Config parameters
        self.clear()
        if config is None:
            config = {}
        self._enabled: bool = config.get(CONF_ENABLED, False)
        self._speed: float = config.get(CONF_SPEED, DEFAULT_TEST_SPEED)
        self._output_events: bool = config.get(CONF_OUTPUT_EVENTS, False)
        self._show_log: bool = config.get(CONF_SHOW_LOG, True)
        self._autoplay: bool = config.get(CONF_AUTOPLAY, True)
        if CONF_TIMES in config:
            for tidx, test in enumerate(config[CONF_TIMES]):
                self._tests.append(IUTest(tidx, self._speed).load(test))
        return self

    def poll_test(self, atime: datetime, poll_func) -> None:
        """Polling is diverted here when testing is enabled. atime is the actual time
        but is converted to a virtual time for testing. The main polling function
        is then called with the modified time"""
        if self._autoplay and not self._autoplay_initialised:
            self.start_test(1, atime)
            self._autoplay_initialised = True

        test = self.current_test
        if test is not None:
            if not self._test_initialised:
                poll_func(test.start, True)
                self._test_initialised = True
            elif test.is_finished(atime):  # End of current test
                poll_func(test.end, False)
                if self._autoplay:
                    test = self.next_test(atime)
                    if test is not None:
                        poll_func(test.start, True)
                        self._test_initialised = True
                    else:  # All tests finished
                        if self._show_log:
                            self._coordinator.logger.log_test_completed(
                                self.total_checks, self.total_errors, INFO
                            )
                        else:
                            self._coordinator.logger.log_test_completed(
                                self.total_checks, self.total_errors
                            )
                        poll_func(atime, True)
                else:  # End single test
                    self.end_test(atime)
            else:  # Continue existing test
                poll_func(test.virtual_time(atime))
        else:  # Out of tests to run
            poll_func(atime)

    def entity_state_changed(self, event: IUEvent) -> None:
        """Called when an entity has changed state"""

        def check_state(event: IUEvent):
            """Check the event against the next result"""
            test = self.current_test
            if test is not None:
                result = test.next_result()
                if not test.check_result(result, event):
                    if not self._output_events:
                        if self._show_log:
                            self._coordinator.logger.log_test_error(
                                test, event, result, ERROR
                            )
                        else:
                            self._coordinator.logger.log_test_error(test, event, result)

        if not self._output_events:
            if self._show_log:
                self._coordinator.logger.log_event(event, INFO)
            else:
                self._coordinator.logger.log_event(event)
        else:
            print(str(event))
        check_state(event)


class IULogger:
    """Irrigation Unlimited logger class"""

    # pylint: disable=too-many-public-methods

    def __init__(self, logger: Logger) -> None:
        # Passed parameters
        self._logger = logger
        # Config parameters
        # Private variables

    def load(self, config: OrderedDict) -> "IULogger":
        """Load config data for the tester"""
        if config is None:
            config = {}
        return self

    def _output(self, level: int, msg: str) -> None:
        """Send out the message"""
        self._logger.log(level, msg)

    def _format(
        self, level, area: str, stime: datetime = None, data: str = None
    ) -> None:
        """Format and send out message"""
        msg = area
        if stime is not None:
            msg += f" [{dt2lstr(stime)}]"
        if data is not None:
            msg += " " + data
        self._output(level, msg)

    def log_start(self, stime: datetime) -> None:
        """Message for system clock starting"""
        self._format(DEBUG, "START", stime)

    def log_stop(self, stime: datetime) -> None:
        """Message for system clock stopping"""
        self._format(DEBUG, "STOP", stime)

    def log_load(self, data: OrderedDict) -> None:
        """Message that the config is loaded"""
        # pylint: disable=unused-argument
        self._format(DEBUG, "LOAD")

    def log_initialised(self) -> None:
        """Message that the system is initialised and ready to go"""
        self._format(DEBUG, "INITIALISED")

    def log_event(self, event: IUEvent, level=DEBUG) -> None:
        """Message that an event has occurred - controller or zone turning on or off"""
        if len(event.crumbs) != 0:
            result = (
                f"controller: {event.controller:d}, "
                f"zone: {event.zone:d}, "
                f"state: {str(int(event.state))}, "
                f"data: {event.crumbs}"
            )
        else:
            result = (
                f"controller: {event.controller:d}, "
                f"zone: {event.zone:d}, "
                f"state: {str(int(event.state))}"
            )
        self._format(level, "EVENT", event.time, result)

    def log_service_call(
        self,
        service: str,
        stime: datetime,
        controller: IUController,
        zone: IUZone,
        sequence: IUSequence,
        data: MappingProxyType,
        level=INFO,
    ) -> None:
        """Message that we have received a service call"""
        # pylint: disable=too-many-arguments
        idl = IUBase.idl([controller, zone, sequence], "0", 1)
        self._format(
            level,
            "CALL",
            stime,
            f"service: {service}, "
            f"controller: {idl[0]}, "
            f"zone: {idl[1]}, "
            f"sequence: {idl[2]}, "
            f"data: {json.dumps(data, default=str)}",
        )

    def log_register_entity(
        # pylint: disable=too-many-arguments
        self,
        stime: datetime,
        controller: IUController,
        zone: IUZone,
        sequence: IUSequence,
        entity: Entity,
    ) -> None:
        """Message that HA has registered an entity"""
        idl = IUBase.idl([controller, zone, sequence], "0", 1)
        self._format(
            DEBUG,
            "REGISTER",
            stime,
            f"controller: {idl[0]}, "
            f"zone: {idl[1]}, "
            f"sequence: {idl[2]}, "
            f"entity: {entity.entity_id}",
        )

    def log_deregister_entity(
        # pylint: disable=too-many-arguments
        self,
        stime: datetime,
        controller: IUController,
        zone: IUZone,
        sequence: IUSequence,
        entity: Entity,
    ) -> None:
        """Message that HA is removing an entity"""
        idl = IUBase.idl([controller, zone, sequence], "0", 1)
        self._format(
            DEBUG,
            "DEREGISTER",
            stime,
            f"controller: {idl[0]}, "
            f"zone: {idl[1]}, "
            f"sequence: {idl[2]}, "
            f"entity: {entity.entity_id}",
        )

    def log_test_start(self, vtime: datetime, test: IUTest, level=DEBUG) -> None:
        """Message that a test is starting"""
        self._format(
            level,
            "TEST_START",
            vtime,
            f"test: {test.index + 1:d}, "
            f"start: {dt2lstr(test.start)}, "
            f"end: {dt2lstr(test.end)}",
        )

    def log_test_end(self, vtime: datetime, test: IUTest, level=DEBUG) -> None:
        """Message that a test has finished"""
        self._format(level, "TEST_END", vtime, f"test: {test.index + 1:d}")

    def log_test_error(
        self, test: IUTest, actual: IUEvent, expected: IUEvent, level=DEBUG
    ) -> None:
        """Message that an event did not meet expected result"""
        self._format(
            level,
            "TEST_ERROR",
            None,
            f"test: {test.index + 1:d}, "
            f"actual: {str(actual)}, "
            f"expected: {str(expected)}",
        )

    def log_test_completed(self, checks: int, errors: int, level=DEBUG) -> None:
        """Message that all tests have been completed"""
        self._format(
            level,
            "TEST_COMPLETED",
            None,
            f"(Idle): checks: {checks:d}, errors: {errors:d}",
        )

    def log_sequence_entity(self, vtime: datetime, level=WARNING) -> None:
        """Warn that a service call involved a sequence but was not directed
        at the controller"""
        self._format(level, "ENTITY", vtime, "Sequence specified but entity_id is zone")

    def log_invalid_sequence(
        self, vtime: datetime, controller: IUController, sequence_id: int, level=WARNING
    ) -> None:
        """Warn that a service call with a sequence_id is invalid"""
        self._format(
            level,
            "SEQUENCE_ID",
            vtime,
            f"Invalid sequence id: "
            f"controller: {IUBase.ids(controller, '0', 1)}, "
            f"sequence: {sequence_id}",
        )

    def log_invalid_restore_data(self, msg: str, data: str, level=WARNING) -> None:
        """Warn invalid restore data"""
        self._format(level, "RESTORE", None, f"Invalid data: msg: {msg}, data: {data}")

    def log_incomplete_cycle(
        self,
        controller: IUController,
        zone: IUZone,
        sequence: IUSequence,
        sequence_zone: IUSequenceZone,
        level=WARNING,
    ) -> None:
        """Warn that a cycle did not complete"""
        # pylint: disable=too-many-arguments
        idl = IUBase.idl([controller, zone, sequence, sequence_zone], "-", 1)
        self._format(
            level,
            "INCOMPLETE",
            None,
            f"Cycle did not complete: "
            f"controller: {idl[0]}, "
            f"zone: {idl[1]}, "
            f"sequence: {idl[2]}, "
            f"sequence_zone: {idl[3]}",
        )

    def log_sync_error(
        self,
        vtime: datetime,
        expected: str,
        found: str,
        switch_entity_id: str,
        level=WARNING,
    ) -> None:
        """Warn that switch and entity are out of sync"""
        self._format(
            level,
            "SYNCHRONISATION",
            vtime,
            f"Switch does not match current state: "
            f"expected: {expected}, "
            f"found: {found}, "
            f"switch: {switch_entity_id}",
        )

    def log_switch_error(
        self,
        vtime: datetime,
        expected: str,
        found: str,
        switch_entity_id: str,
        level=ERROR,
    ) -> None:
        """Warn that switch(s) was unable to be set"""
        self._format(
            level,
            "SWITCH_ERROR",
            vtime,
            f"Unable to set switch state: "
            f"expected: {expected}, "
            f"found: {found}, "
            f"switch: {switch_entity_id}",
        )

    def log_duplicate_id(
        self,
        controller: IUController,
        zone: IUZone,
        schedule: IUSchedule,
        level=WARNING,
    ) -> None:
        """Warn a controller/zone/schedule has a duplicate id"""
        idl = IUBase.idl([controller, zone, schedule], "0", 1)
        if not zone and not schedule:
            self._format(
                level,
                "DUPLICATE_ID",
                None,
                f"Duplicate Controller ID: "
                f"controller: {idl[0]}, "
                f"controller_id: {controller.controller_id}",
            )
        elif zone and not schedule:
            self._format(
                level,
                "DUPLICATE_ID",
                None,
                f"Duplicate Zone ID: "
                f"controller: {idl[0]}, "
                f"controller_id: {controller.controller_id}, "
                f"zone: {idl[1]}, "
                f"zone_id: {zone.zone_id if zone else ''}",
            )
        elif zone and schedule:
            self._format(
                level,
                "DUPLICATE_ID",
                None,
                f"Duplicate Schedule ID (zone): "
                f"controller: {idl[0]}, "
                f"controller_id: {controller.controller_id}, "
                f"zone: {idl[1]}, "
                f"zone_id: {zone.zone_id if zone else ''}, "
                f"schedule: {idl[2]}, "
                f"schedule_id: {schedule.schedule_id if schedule else ''}",
            )
        else:  # not zone and schedule
            self._format(
                level,
                "DUPLICATE_ID",
                None,
                f"Duplicate Schedule ID (sequence): "
                f"controller: {idl[0]}, "
                f"controller_id: {controller.controller_id}, "
                f"schedule: {idl[2]}, "
                f"schedule_id: {schedule.schedule_id if schedule else ''}",
            )

    def log_orphan_id(
        self,
        controller: IUController,
        sequence: IUSequence,
        sequence_zone: IUSequenceZone,
        zone_id: str,
        level=WARNING,
    ) -> None:
        # pylint: disable=too-many-arguments
        """Warn a zone_id reference is orphaned"""
        idl = IUBase.idl([controller, sequence, sequence_zone], "0", 1)
        self._format(
            level,
            "ORPHAN_ID",
            None,
            f"Invalid reference ID: "
            f"controller: {idl[0]}, "
            f"sequence: {idl[1]}, "
            f"sequence_zone: {idl[2]}, "
            f"zone_id: {zone_id}",
        )

    def log_invalid_crontab(
        self,
        stime: datetime,
        schedule: IUSchedule,
        expression: str,
        msg: str,
        level=ERROR,
    ) -> None:
        # pylint: disable=too-many-arguments
        """Warn that a crontab expression in the schedule is invalid"""
        self._format(
            level,
            "CRON",
            stime,
            f"schedule: {schedule.name}, "
            f"expression: {expression}, "
            f"error: {msg}",
        )

    def log_invalid_meter_id(
        self, stime: datetime, entity_id: str, level=ERROR
    ) -> None:
        """Warn the volume meter is invalid"""
        self._format(level, "VOLUME_SENSOR", stime, f"entity_id: {entity_id}")

    def log_invalid_meter_value(self, stime: datetime, value: str, level=ERROR) -> None:
        """Warn the volume meter value is invalid"""
        self._format(level, "VOLUME_VALUE", stime, f"{value}")


class IUClock:
    """Irrigation Unlimited Clock class"""

    # pylint: disable=too-many-instance-attributes

    def __init__(
        self,
        hass: HomeAssistant,
        coordinator: "IUCoordinator",
        action: Callable[[datetime], Awaitable[None]],
    ) -> None:
        # Pass parameters
        self._hass = hass
        self._coordinator = coordinator
        self._action = action
        # Private variables
        self._listener_job = HassJob(self._listener)
        self._remove_timer_listener: CALLBACK_TYPE = None
        self._tick_log = deque["datetime"](maxlen=DEFAULT_MAX_LOG_ENTRIES)
        self._next_tick: datetime = None
        self._fixed_clock = False
        self._show_log = False
        self._finalised = False

    @property
    def is_fixed(self) -> bool:
        """Return if the clock is fixed or variable"""
        return self._fixed_clock

    @property
    def next_tick(self) -> datetime:
        """Return the next anticipated scheduled tick. It
        may however be cancelled due to a service call"""
        return self._next_tick

    @property
    def tick_log(self) -> deque["datetime"]:
        """Return the tick history log"""
        return self._tick_log

    @property
    def show_log(self) -> bool:
        """Indicate if we should show the tick log"""
        return self._show_log

    def track_interval(self) -> timedelta:
        """Returns the system clock time interval"""
        track_time = SYSTEM_GRANULARITY / self._coordinator.tester.speed
        track_time *= 0.95  # Run clock slightly ahead of required to avoid skipping
        return min(timedelta(seconds=track_time), self._coordinator.refresh_interval)

    def start(self) -> None:
        """Start the system clock"""
        self.stop()
        now = dt.utcnow()
        self._schedule_next_poll(now)
        self._coordinator.logger.log_start(now)

    def stop(self) -> None:
        """Stop the system clock"""
        if self._remove_timer():
            self._coordinator.logger.log_stop(dt.utcnow())

    def next_awakening(self, atime: datetime) -> datetime:
        """Return the time for the next event"""
        if self._finalised:
            return utc_eot()
        if not self._coordinator.initialised:
            return atime + timedelta(seconds=5)
        if self._fixed_clock:
            return atime + self.track_interval()

        # Handle testing
        if self._coordinator.tester.is_testing:
            next_stime = self._coordinator.next_awakening()
            next_stime = min(next_stime, self._coordinator.tester.current_test.end)
            result = self._coordinator.tester.actual_time(next_stime)
        else:
            result = self._coordinator.next_awakening()

        # Midnight rollover
        if result == utc_eot() or (
            dt.as_local(self._coordinator.tester.virtual_time(atime)).toordinal()
            != dt.as_local(self._coordinator.tester.virtual_time(result)).toordinal()
        ):
            local_tomorrow = dt.as_local(
                self._coordinator.tester.virtual_time(atime)
            ) + timedelta(days=1)
            local_midnight = local_tomorrow.replace(
                hour=0, minute=0, second=0, microsecond=0
            )
            result = dt.as_utc(self._coordinator.tester.actual_time(local_midnight))

        # Sanity check
        if result < atime:
            result = atime + granularity_time()

        return result

    def _update_next_tick(self, atime: datetime) -> bool:
        """Update the next_tick variable"""
        if atime != self._next_tick:
            self._next_tick = atime
            if self._show_log:
                self._coordinator.request_update(False)
            return True
        return False

    def _add_to_log(self, atime: datetime) -> bool:
        """Add a time to the head of tick log"""
        if not self._fixed_clock and atime is not None:
            self._tick_log.appendleft(atime)
            if self._show_log:
                self._coordinator.request_update(False)
            return True
        return False

    def _remove_timer(self) -> None:
        """Remove the current timer. Return False if there is
        no timer currently active (clock is stopped)"""
        if self._remove_timer_listener is not None:
            self._remove_timer_listener()
            self._remove_timer_listener = None
            return True
        return False

    def _schedule_next_poll(self, atime: datetime) -> None:
        """Set the timer for the next update"""
        self._update_next_tick(self.next_awakening(atime))

        self._remove_timer_listener = async_track_point_in_utc_time(
            self._hass, self._listener_job, self._next_tick
        )

    async def _listener(self, atime: datetime) -> None:
        """Listener for the timer event"""
        self._add_to_log(atime)
        try:
            await self._action(atime)
        finally:
            self._schedule_next_poll(atime)
        if self._show_log:
            self._coordinator.update_sensor(atime, False)

    def test_ticker_update(self, atime: datetime) -> bool:
        """Interface for testing unit when starting tick"""
        if self._update_next_tick(atime) and self._show_log:
            self._coordinator.update_sensor(atime, False)
            return True
        return False

    def test_ticker_fired(self, atime: datetime) -> bool:
        """Interface for testing unit when finishing tick"""
        if self._add_to_log(atime) and self._show_log:
            self._coordinator.update_sensor(atime, False)
            return True
        return False

    def rearm(self, atime: datetime) -> None:
        """Rearm the timer"""
        if not self._fixed_clock and self._remove_timer():
            self._schedule_next_poll(atime)
            if self._show_log:
                self._coordinator.update_sensor(atime, False)

    def load(self, config: OrderedDict) -> "IUClock":
        """Load config data"""
        if config is not None and CONF_CLOCK in config:
            clock_conf: dict = config[CONF_CLOCK]
            self._fixed_clock = clock_conf[CONF_MODE] == CONF_FIXED
            self._show_log = clock_conf[CONF_SHOW_LOG]
            if (
                max_entries := clock_conf.get(CONF_MAX_LOG_ENTRIES)
            ) is not None and max_entries != self._tick_log.maxlen:
                self._tick_log = deque["datetime"](maxlen=max_entries)

        if not self._fixed_clock:
            global SYSTEM_GRANULARITY  # pylint: disable=global-statement
            SYSTEM_GRANULARITY = 1

        return self

    def finalise(self):
        """finalise this unit"""
        if not self._finalised:
            self._remove_timer()
            self._finalised = True


class IUCoordinator:
    """Irrigation Unlimited Coordinator class"""

    # pylint: disable=too-many-instance-attributes, too-many-public-methods

    def __init__(self, hass: HomeAssistant) -> None:
        # Passed parameters
        self._hass = hass
        # Config parameters
        self._refresh_interval: timedelta = None
        self._sync_switches: bool = True
        self._rename_entities = False
        self._extended_config = False
        # Private variables
        self._controllers: list[IUController] = []
        self._is_on: bool = False
        self._sensor_update_required: bool = False
        self._sensor_last_update: datetime = None
        self._dirty: bool = True
        self._component: Entity = None
        self._initialised: bool = False
        self._last_tick: datetime = None
        self._last_muster: datetime = None
        self._muster_required: bool = False
        self._remove_shutdown_listener: CALLBACK_TYPE = None
        self._logger = IULogger(_LOGGER)
        self._tester = IUTester(self)
        self._clock = IUClock(self._hass, self, self._async_timer)
        self._history = IUHistory(self._hass, self.service_history)
        self._restored_from_configuration: bool = False
        self._finalised = False

    @property
    def entity_id(self) -> str:
        """Return the entity_id for the coordinator"""
        return f"{DOMAIN}.{COORDINATOR}"

    @property
    def controllers(self) -> "list[IUController]":
        """Return the list of controllers"""
        return self._controllers

    @property
    def clock(self) -> IUClock:
        """Return the clock object"""
        return self._clock

    @property
    def tester(self) -> IUTester:
        """Return the tester object"""
        return self._tester

    @property
    def logger(self) -> IULogger:
        """Return the logger object"""
        return self._logger

    @property
    def history(self) -> IUHistory:
        """Return the history object"""
        return self._history

    @property
    def is_setup(self) -> bool:
        """Indicate if system is setup"""
        return self._is_setup()

    @property
    def component(self) -> Entity:
        """Return the HA integration entity"""
        return self._component

    @property
    def refresh_interval(self) -> timedelta:
        """Returns the refresh_interval property"""
        return self._refresh_interval

    @property
    def initialised(self) -> bool:
        """Return True if we are initialised"""
        return self._initialised

    @property
    def finalised(self) -> bool:
        """Return True if we have been finalised"""
        return self._finalised

    @property
    def configuration(self) -> str:
        """Return the system configuration as JSON"""
        return json.dumps(self.as_dict(self._extended_config), cls=IUJSONEncoder)

    @property
    def restored_from_configuration(self) -> bool:
        """Return if the system has been restored from coordinator date"""
        return self._restored_from_configuration

    @restored_from_configuration.setter
    def restored_from_configuration(self, value: bool) -> None:
        """Flag the system has been restored from coordinator data"""
        self._restored_from_configuration = value

    @property
    def rename_entities(self) -> bool:
        """Indicate if entity renaming is allowed"""
        return self._rename_entities

    def _is_setup(self) -> bool:
        """Wait for sensors to be setup"""
        all_setup: bool = self._hass.is_running and self._component is not None
        for controller in self._controllers:
            all_setup = all_setup and controller.is_setup
        return all_setup

    def initialise(self) -> None:
        """Flag we need to re-initialise. Called by the testing unit when
        starting a new test"""
        self._initialised = False

    def add(self, controller: IUController) -> IUController:
        """Add a new controller to the system"""
        self._controllers.append(controller)
        return controller

    def get(self, index: int) -> IUController:
        """Return the controller by index"""
        if index is not None and index >= 0 and index < len(self._controllers):
            return self._controllers[index]
        return None

    def find_add(self, coordinator: "IUCoordinator", index: int) -> IUController:
        """Locate and create if required a controller"""
        if index >= len(self._controllers):
            return self.add(IUController(self._hass, coordinator, index))
        return self._controllers[index]

    def clear(self) -> None:
        """Reset the coordinator"""
        # Don't clear controllers
        # self._controllers.clear()
        self._is_on: bool = False

    def load(self, config: OrderedDict) -> "IUCoordinator":
        """Load config data for the system"""
        self.clear()

        global SYSTEM_GRANULARITY  # pylint: disable=global-statement
        SYSTEM_GRANULARITY = config.get(CONF_GRANULARITY, DEFAULT_GRANULARITY)
        self._clock.load(config)

        self._refresh_interval = timedelta(
            seconds=config.get(CONF_REFRESH_INTERVAL, DEFAULT_REFRESH_INTERVAL)
        )
        self._sync_switches = config.get(CONF_SYNC_SWITCHES, True)
        self._rename_entities = config.get(CONF_RENAME_ENTITIES, self._rename_entities)
        self._extended_config = config.get(CONF_EXTENDED_CONFIG, self._extended_config)

        cidx: int = 0
        for cidx, controller_config in enumerate(config[CONF_CONTROLLERS]):
            self.find_add(self, cidx).load(controller_config)
        while cidx < len(self._controllers) - 1:
            self._controllers.pop().finalise(True)

        self._tester.load(config.get(CONF_TESTING))
        self._logger.load(config.get(CONF_LOGGING))

        self._dirty = True
        self._muster_required = True
        self.request_update(False)
        self._logger.log_load(config)
        self._history.load(config, self._clock.is_fixed)

        self.check_links()

        return self

    def as_dict(self, extended=False) -> OrderedDict:
        """Returns the coordinator as a dict"""
        result = OrderedDict()
        result[CONF_VERSION] = "1.0.1"
        result[CONF_CONTROLLERS] = [ctr.as_dict(extended) for ctr in self._controllers]
        return result

    def muster(self, stime: datetime, force: bool) -> IURQStatus:
        """Calculate run times for system"""
        status = IURQStatus(0)

        self._history.muster(stime, force)

        for controller in self._controllers:
            status |= controller.muster(stime, force)
        self._dirty = False

        return status

    def check_run(self, stime: datetime) -> bool:
        """Update run status. Return True if any entities in
        the tree have changed."""
        status_changed: bool = False

        for controller in self._controllers:
            status_changed |= controller.check_run(stime)

        return status_changed

    def check_links(self) -> bool:
        """Check inter object links"""
        # pylint: disable=too-many-branches
        result = True

        controller_ids = set()
        for controller in self._controllers:
            if controller.controller_id in controller_ids:
                self._logger.log_duplicate_id(controller, None, None)
                result = False
            else:
                controller_ids.add(controller.controller_id)
            if not controller.check_links():
                result = False

        schedule_ids = set()
        for controller in self._controllers:
            for zone in controller.zones:
                for schedule in zone.schedules:
                    if schedule.schedule_id is not None:
                        if schedule.schedule_id in schedule_ids:
                            self._logger.log_duplicate_id(controller, zone, schedule)
                            result = False
                        else:
                            schedule_ids.add(schedule.schedule_id)
            for sequence in controller.sequences:
                for schedule in sequence.schedules:
                    if schedule.schedule_id is not None:
                        if schedule.schedule_id in schedule_ids:
                            self._logger.log_duplicate_id(controller, None, schedule)
                            result = False
                        else:
                            schedule_ids.add(schedule.schedule_id)

        return result

    def request_update(self, deep: bool) -> None:
        """Flag the sensor needs an update. The actual update is done
        in update_sensor"""
        self._sensor_update_required = True
        if deep:
            for controller in self._controllers:
                controller.request_update(True)

    def update_sensor(self, stime: datetime, deep: bool = True) -> None:
        """Update home assistant sensors if required"""
        stime = wash_dt(stime, 1)
        if deep:
            for controller in self._controllers:
                controller.update_sensor(stime)

        if self._component is not None and self._sensor_update_required:
            self._component.schedule_update_ha_state()
            self._sensor_update_required = False
            self._sensor_last_update = stime

    def poll(self, vtime: datetime, force: bool = False) -> None:
        """Poll the system for changes, updates and refreshes.
        vtime is the virtual time if in testing mode, if not then
        it is the actual time"""
        wtime: datetime = wash_dt(vtime)
        if (wtime != self._last_muster) or self._muster_required or force:
            if not self.muster(wtime, force).is_empty():
                self.check_run(wtime)
            self._muster_required = False
            self._last_muster = wtime
        self.update_sensor(vtime)

    def poll_main(self, atime: datetime, force: bool = False) -> None:
        """Post initialisation worker. Divert to testing unit if
        enabled. atime (actual time) is the real world clock"""
        if self._tester.enabled:
            self._tester.poll_test(atime, self.poll)
        else:
            self.poll(atime, force)

    def timer(self, atime: datetime) -> None:
        """System clock entry point"""
        self._last_tick = atime
        if self._initialised:
            self.poll_main(atime)
        else:
            self._initialised = self.is_setup
            if self._initialised:
                self._logger.log_initialised()
                self.check_switches(self._sync_switches, atime)
                self.request_update(True)
                self.poll_main(atime)

    async def _async_timer(self, atime: datetime) -> None:
        """Timer callback"""
        self.timer(atime)

    def finalise(self, turn_off: bool) -> None:
        """Tear down the system and clean up"""
        if not self._finalised:
            for controller in self._controllers:
                controller.finalise(turn_off)
            self._clock.finalise()
            self._history.finalise()
            self._finalised = True

    async def _async_shutdown_listener(self, event: HAEvent) -> None:
        """Home Assistant is shutting down. Attempting to turn off any running
        valves is unlikely to work as the underlying libraries are also in a
        state of shutdown (zwave, zigbee, WiFi). Should this situation change
        then set the following to True."""
        # pylint: disable=unused-argument
        self.finalise(False)

    def listen(self) -> None:
        """Listen for events. This appears to be the earliest signal HA
        sends to tell us we are going down. It would be nice to have some sort
        of heads up while everything is still running. This would enable us to
        tidy up."""
        self._remove_shutdown_listener = self._hass.bus.async_listen_once(
            EVENT_HOMEASSISTANT_STOP, self._async_shutdown_listener
        )

    def _replay_last_timer(self, atime: datetime) -> None:
        """Update after a service call"""
        self.request_update(False)
        self._muster_required = True
        if self._tester.is_testing:
            tick = self._tester.ticker
        elif self._last_tick is not None:
            tick = self._last_tick
        else:
            return
        self.timer(tick)
        self._clock.rearm(atime)

    def next_awakening(self) -> datetime:
        """Return the next event time"""
        dates: list[datetime] = [utc_eot()]
        dates.extend(ctr.next_awakening() for ctr in self._controllers)
        return min(d for d in dates if d is not None)

    def check_switches(self, resync: bool, stime: datetime) -> list[str]:
        """Check if entities match current status"""
        result: list[str] = []
        for controller in self._controllers:
            result.extend(controller.check_switch(resync, stime))
        return result

    def notify_sequence(
        self,
        event_type: str,
        controller: IUController,
        sequence: IUSequence,
        schedule: IUSchedule,
        sequence_run: IUSequenceRun,
    ) -> None:
        """Send out a notification for start/finish to a sequence"""
        # pylint: disable=too-many-arguments
        data = {
            CONF_CONTROLLER: {CONF_INDEX: controller.index, CONF_NAME: controller.name},
            CONF_SEQUENCE: {CONF_INDEX: sequence.index, CONF_NAME: sequence.name},
            CONF_RUN: {CONF_DURATION: round(sequence_run.total_time.total_seconds())},
        }
        if schedule is not None:
            data[CONF_SCHEDULE] = {CONF_INDEX: schedule.index, CONF_NAME: schedule.name}
        else:
            data[CONF_SCHEDULE] = {CONF_INDEX: None, CONF_NAME: RES_MANUAL}
        self._hass.bus.fire(f"{DOMAIN}_{event_type}", data)

    def notify_switch(
        self,
        event_type: str,
        found: str,
        expected: str,
        entity_id: str,
        controller: IUController,
        zone: IUZone,
    ) -> None:
        """Send out notification about switch resync event"""
        # pylint: disable=too-many-arguments
        data = {
            CONF_EXPECTED: expected,
            CONF_FOUND: found,
            CONF_ENTITY_ID: entity_id,
            CONF_CONTROLLER: {CONF_INDEX: controller.index, CONF_NAME: controller.name},
        }
        if zone is not None:
            data[CONF_ZONE] = {CONF_INDEX: zone.index, CONF_NAME: zone.name}
        else:
            data[CONF_ZONE] = {CONF_INDEX: None, CONF_NAME: None}
        self._hass.bus.fire(f"{DOMAIN}_{event_type}", data)

    def register_entity(
        self,
        controller: IUController,
        zone: IUZone,
        sequence: IUSequence,
        entity: Entity,
    ) -> None:
        """A HA entity has been registered"""
        stime = self.service_time()
        if sequence is not None:
            sequence.sequence_sensor = entity
        elif zone is not None:
            zone.zone_sensor = entity
        elif controller is not None:
            controller.master_sensor = entity
        else:
            self._component = entity
        self._logger.log_register_entity(stime, controller, zone, sequence, entity)

    def deregister_entity(
        self,
        controller: IUController,
        zone: IUZone,
        sequence: IUSequence,
        entity: Entity,
    ) -> None:
        """A HA entity has been removed"""
        stime = self.service_time()
        if sequence is not None:
            sequence.finalise()
            sequence.sequence_sensor = None
        elif zone is not None:
            zone.finalise(True)
            zone.zone_sensor = None
        elif controller is not None:
            controller.finalise(True)
            controller.master_sensor = None
        else:
            self.finalise(True)
            self._component = None
        self._logger.log_deregister_entity(stime, controller, zone, sequence, entity)

    def service_time(self) -> datetime:
        """Return a time midway between last and next future tick"""
        if self._tester.is_testing:
            result = self._tester.ticker
            result = self._tester.virtual_time(result)
        elif self._clock.is_fixed and self._last_tick is not None:
            result = self._last_tick + self._clock.track_interval() / 2
        else:
            result = dt.utcnow()
        return wash_dt(result)

    def service_load_schedule(self, data: MappingProxyType) -> None:
        """Handle the load_schedule service call"""
        # pylint: disable=too-many-nested-blocks
        for controller in self._controllers:
            for zone in controller.zones:
                for schedule in zone.schedules:
                    if schedule.schedule_id == data[CONF_SCHEDULE_ID]:
                        schedule.load(data, True)
                        runs: list[IURun] = []
                        for run in zone.runs:
                            if not run.is_sequence and run.schedule == schedule:
                                runs.append(run)
                        for run in runs:
                            zone.runs.remove_run(run)
                        return

            for sequence in controller.sequences:
                for schedule in sequence.schedules:
                    if schedule.schedule_id == data[CONF_SCHEDULE_ID]:
                        schedule.load(data, True)
                        sequence.runs.clear_runs()
                        return

    def service_call(
        self,
        service: str,
        controller: IUController,
        zone: IUZone,
        sequence: IUSequence,
        data: MappingProxyType,
    ) -> None:
        """Entry point for all service calls."""
        # pylint: disable=too-many-branches,too-many-arguments,too-many-statements
        changed = True
        stime = self.service_time()

        data1 = dict(data)

        if service in [SERVICE_ENABLE, SERVICE_DISABLE, SERVICE_TOGGLE]:
            if sequence is not None:
                changed = sequence.service_edt(data1, stime, service)
            elif zone is not None:
                if changed := zone.service_edt(data1, stime, service):
                    controller.clear_zone_runs(zone)
            else:
                changed = controller.service_edt(data1, stime, service)
        elif service == SERVICE_SUSPEND:
            render_positive_time_period(data1, CONF_FOR)
            if sequence is not None:
                changed = sequence.service_suspend(data1, stime)
            elif zone is not None:
                if changed := zone.service_suspend(data1, stime):
                    controller.clear_zone_runs(zone)
            else:
                changed = controller.service_suspend(data1, stime)
        elif service == SERVICE_CANCEL:
            if sequence is not None:
                changed = sequence.service_cancel(data1, stime)
            elif zone is not None:
                zone.service_cancel(data1, stime)
            else:
                changed = controller.service_cancel(data1, stime)
        elif service == SERVICE_TIME_ADJUST:
            render_positive_time_period(data1, CONF_ACTUAL)
            render_positive_time_period(data1, CONF_INCREASE)
            render_positive_time_period(data1, CONF_DECREASE)
            render_positive_time_period(data1, CONF_MINIMUM)
            render_positive_time_period(data1, CONF_MAXIMUM)
            render_positive_float(self._hass, data1, CONF_PERCENTAGE)
            if sequence is not None:
                changed = sequence.service_adjust_time(data1, stime)
            elif zone is not None:
                if changed := zone.service_adjust_time(data1, stime):
                    controller.clear_zone_runs(zone)
            else:
                changed = controller.service_adjust_time(data1, stime)
        elif service == SERVICE_MANUAL_RUN:
            render_positive_time_period(data1, CONF_TIME)
            if sequence is not None:
                sequence.service_manual_run(data1, stime)
            elif zone is not None:
                zone.service_manual_run(data1, stime)
            else:
                controller.service_manual_run(data1, stime)
        elif service == SERVICE_SKIP:
            if sequence is not None:
                sequence.service_skip(data1, stime)
        elif service == SERVICE_PAUSE:
            if sequence is not None:
                changed = sequence.service_pause(data1, stime)
            elif zone is not None:
                pass
            else:
                controller.service_pause(data1, stime)
        elif service == SERVICE_RESUME:
            if sequence is not None:
                changed = sequence.service_resume(data1, stime)
            elif zone is not None:
                pass
            else:
                controller.service_resume(data1, stime)
        elif service == SERVICE_LOAD_SCHEDULE:
            render_positive_time_period(data1, CONF_DURATION)
            self.service_load_schedule(data1)
        else:
            return

        if changed:
            self._last_tick = stime
            self._logger.log_service_call(
                service, stime, controller, zone, sequence, data1
            )
            self._replay_last_timer(stime)
        else:
            self._logger.log_service_call(
                service, stime, controller, zone, sequence, data1, DEBUG
            )

    def service_history(self, entity_ids: set[str]) -> None:
        """History service call entry point. The history has changed
        and the sensors require an update"""
        for controller in self._controllers:
            if controller.entity_id in entity_ids:
                controller.request_update(False)
            for zone in controller.zones:
                if zone.entity_id in entity_ids:
                    zone.request_update()
        self.update_sensor(self.service_time())

    def start_test(self, test_no: int) -> datetime:
        """Main entry to start a test"""
        self._last_tick = None
        self._last_muster = None
        next_time = dt.utcnow()
        if self._tester.start_test(test_no, next_time) is not None:
            self.timer(next_time)
            return next_time
        return None

    def status_changed(
        self, stime: datetime, controller: IUController, zone: IUZone, state: bool
    ) -> None:
        """Collection point for entities that have changed state"""
        if stime is None:
            stime = self.service_time()
        crumbs: str = ""
        if zone is not None:
            zone_id = zone.index + 1
            if state is True:
                crumbs = zone.runs.current_run.crumbs
        else:
            zone_id = 0
        event = IUEvent().load2(stime, controller.index + 1, zone_id, state, crumbs)
        self._tester.entity_state_changed(event)
        self.request_update(False)
