"""Utilities"""
import time
import logging
import random
import re
import string
from datetime import timedelta, datetime
import elasticsearch8
from voluptuous import Schema
from es_client.helpers.utils import get_version
from curator import exceptions
from curator.defaults import settings
from curator.validators import SchemaCheck, actions, options
from curator.validators.filter_functions import validfilters
from curator._version import __version__
LOGGER = logging.getLogger(__name__)

def rollable_alias(client, alias):
    """
    Ensure that `alias` is an alias, and points to an index that can use the
    ``_rollover`` API.

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg alias: An Elasticsearch alias
    """
    try:
        response = client.indices.get_alias(name=alias)
    except elasticsearch8.exceptions.NotFoundError:
        LOGGER.error('alias "%s" not found.', alias)
        return False
    # Response should be like:
    # {'there_should_be_only_one': {u'aliases': {'value of "alias" here': {}}}}
    # Where 'there_should_be_only_one' is a single index name that ends in a
    # number, and 'value of "alias" here' reflects the value of the passed
    # parameter, except in versions 6.5.0+ where the ``is_write_index`` setting
    # makes it possible to have more than one index associated with a rollover index
    for idx in response:
        if 'is_write_index' in response[idx]['aliases'][alias]:
            if response[idx]['aliases'][alias]['is_write_index']:
                return True
    # implied `else` here: If not version 6.5.0+ with `is_write_index`, it has to fit the
    # following criteria:
    if len(response) > 1:
        LOGGER.error('"alias" must only reference one index: %s', response)
    else:
        index = list(response.keys())[0]
        rollable = False
        # In order for `rollable` to be True, the last 2 digits of the index
        # must be digits, or a hyphen followed by a digit.
        # NOTE: This is not a guarantee that the rest of the index name is
        # necessarily correctly formatted.
        if index[-2:][1].isdigit():
            if index[-2:][0].isdigit():
                rollable = True
            elif index[-2:][0] == '-':
                rollable = True
        return rollable

def verify_client_object(test):
    """
    Test if `test` is a proper :class:`elasticsearch8.Elasticsearch` client
    object and raise an exception if it is not.

    :arg test: The variable or object to test
    :rtype: None
    """
    # Ignore mock type for testing
    if str(type(test)) == "<class 'mock.Mock'>" or \
        str(type(test)) == "<class 'mock.mock.Mock'>":
        pass
    elif not isinstance(test, elasticsearch8.Elasticsearch):
        raise TypeError(f'Not a client object. Type: {type(test)}')

def verify_index_list(test):
    """
    Test if `test` is a proper :class:`curator.indexlist.IndexList` object and
    raise an exception if it is not.

    :arg test: The variable or object to test
    :rtype: None
    """
    # It breaks if this import isn't local to this function
    # pylint: disable=import-outside-toplevel
    from .indexlist import IndexList
    if not isinstance(test, IndexList):
        raise TypeError(f'Not an IndexList object. Type: {type(test)}.')

def verify_snapshot_list(test):
    """
    Test if `test` is a proper :class:`curator.snapshotlist.SnapshotList`
    object and raise an exception if it is not.

    :arg test: The variable or object to test
    :rtype: None
    """
    # It breaks if this import isn't local to this function
    # pylint: disable=import-outside-toplevel
    from .snapshotlist import SnapshotList
    if not isinstance(test, SnapshotList):
        raise TypeError(f'Not an SnapshotList object. Type: {type(test)}.')

def report_failure(exception):
    """
    Raise a `exceptions.FailedExecution` exception and include the original error message.

    :arg exception: The upstream exception.
    :rtype: None
    """
    raise exceptions.FailedExecution(
        f'Exception encountered.  Rerun with loglevel DEBUG and/or check '
        f'Elasticsearch logs for more information. '
        f'Exception: {exception}'
    )

def get_date_regex(timestring):
    """
    Return a regex string based on a provided strftime timestring.

    :arg timestring: An strftime pattern
    :rtype: str
    """
    prev, regex = ('', '')
    LOGGER.debug('Provided timestring = "%s"', timestring)
    for idx, char in enumerate(timestring):
        LOGGER.debug('Current character: %s Array position: %s', char, idx)
        if char == '%':
            pass
        elif char in settings.date_regex() and prev == '%':
            regex += r'\d{' + settings.date_regex()[char] + '}'
        elif char in ['.', '-']:
            regex += "\\" + char
        else:
            regex += char
        prev = char
    LOGGER.debug('regex = %s', regex)
    return regex

def get_datetime(index_timestamp, timestring):
    """
    Return the datetime extracted from the index name, which is the index
    creation time.

    :arg index_timestamp: The timestamp extracted from an index name
    :arg timestring: An strftime pattern
    :rtype: :py:class:`datetime.datetime`
    """
    # Compensate for week of year by appending '%w' to the timestring
    # and '1' (Monday) to index_timestamp
    iso_week_number = False
    if '%W' in timestring or '%U' in timestring or '%V' in timestring:
        timestring += '%w'
        index_timestamp += '1'
        if '%V' in timestring and '%G' in timestring:
            iso_week_number = True
            # Fake as so we read Greg format instead. We will process it later
            timestring = timestring.replace("%G", "%Y").replace("%V", "%W")
    elif '%m' in timestring:
        if not '%d' in timestring:
            timestring += '%d'
            index_timestamp += '1'

    mydate = datetime.strptime(index_timestamp, timestring)

    # Handle ISO time string
    if iso_week_number:
        mydate = _handle_iso_week_number(mydate, timestring, index_timestamp)

    return mydate

def fix_epoch(epoch):
    """
    Fix value of `epoch` to be epoch, which should be 10 or fewer digits long.

    :arg epoch: An epoch timestamp, in epoch + milliseconds, or microsecond, or
        even nanoseconds.
    :rtype: int
    """
    try:
        # No decimals allowed
        epoch = int(epoch)
    except Exception as err:
        raise ValueError(
            f'Invalid epoch received, unable to convert {epoch} to int. {err}') from err

    # If we're still using this script past January, 2038, we have bigger
    # problems than my hacky math here...
    if len(str(epoch)) <= 10:
        # Epoch is fine, no changes
        pass
    elif len(str(epoch)) > 10 and len(str(epoch)) <= 13:
        epoch = int(epoch/1000)
    else:
        orders_of_magnitude = len(str(epoch)) - 10
        powers_of_ten = 10**orders_of_magnitude
        epoch = int(epoch/powers_of_ten)
    return epoch

def _handle_iso_week_number(mydate, timestring, index_timestamp):
    date_iso = mydate.isocalendar()
    # iso_week_str = "{Y:04d}{W:02d}".format(Y=date_iso[0], W=date_iso[1])
    iso_week_str = f'{date_iso[0]:04d}{date_iso[1]:02d}'
    greg_week_str = datetime.strftime(mydate, "%Y%W")

    # Edge case 1: ISO week number is bigger than Greg week number.
    # Ex: year 2014, all ISO week numbers were 1 more than in Greg.
    if (iso_week_str > greg_week_str or
            # Edge case 2: 2010-01-01 in ISO: 2009.W53, in Greg: 2010.W00
            # For Greg converting 2009.W53 gives 2010-01-04, converting back
            # to same timestring gives: 2010.W01.
            datetime.strftime(mydate, timestring) != index_timestamp):

        # Remove one week in this case
        mydate = mydate - timedelta(days=7)
    return mydate

def datetime_to_epoch(mydate):
    """Convert datetime into epoch seconds"""
    # I would have used `total_seconds`, but apparently that's new
    # to Python 2.7+, and due to so many people still using
    # RHEL/CentOS 6, I need this to support Python 2.6.
    tdelta = (mydate - datetime(1970, 1, 1))
    return tdelta.seconds + tdelta.days * 24 * 3600

class TimestringSearch(object):
    """
    An object to allow repetitive search against a string, `searchme`, without
    having to repeatedly recreate the regex.

    :arg timestring: An strftime pattern
    """
    def __init__(self, timestring):
        # pylint: disable=consider-using-f-string
        regex = r'(?P<date>{0})'.format(get_date_regex(timestring))
        self.pattern = re.compile(regex)
        self.timestring = timestring

    def get_epoch(self, searchme):
        """
        Return the epoch timestamp extracted from the `timestring` appearing in
        `searchme`.

        :arg searchme: A string to be searched for a date pattern that matches
            `timestring`
        :rtype: int
        """
        match = self.pattern.search(searchme)
        if match:
            if match.group("date"):
                timestamp = match.group("date")
                return datetime_to_epoch(get_datetime(timestamp, self.timestring))

def get_point_of_reference(unit, count, epoch=None):
    """
    Get a point-of-reference timestamp in epoch + milliseconds by deriving
    from a `unit` and a `count`, and an optional reference timestamp, `epoch`

    :arg unit: One of ``seconds``, ``minutes``, ``hours``, ``days``, ``weeks``,
        ``months``, or ``years``.
    :arg unit_count: The number of ``units``. ``unit_count`` * ``unit`` will
        be calculated out to the relative number of seconds.
    :arg epoch: An epoch timestamp used in conjunction with ``unit`` and
        ``unit_count`` to establish a point of reference for calculations.
    :rtype: int
    """
    if unit == 'seconds':
        multiplier = 1
    elif unit == 'minutes':
        multiplier = 60
    elif unit == 'hours':
        multiplier = 3600
    elif unit == 'days':
        multiplier = 3600*24
    elif unit == 'weeks':
        multiplier = 3600*24*7
    elif unit == 'months':
        multiplier = 3600*24*30
    elif unit == 'years':
        multiplier = 3600*24*365
    else:
        raise ValueError(f'Invalid unit: {unit}.')
    # Use this moment as a reference point, if one is not provided.
    if not epoch:
        epoch = time.time()
    epoch = fix_epoch(epoch)
    return epoch - multiplier * count

def get_unit_count_from_name(index_name, pattern):
    """Derive the unit_count from the index name"""
    if pattern is None:
        return None
    match = pattern.search(index_name)
    if match:
        try:
            return int(match.group(1))
        # pylint: disable=broad-except
        except Exception:
            return None
    else:
        return None

def date_range(unit, range_from, range_to, epoch=None, week_starts_on='sunday'):
    """
    Get the epoch start time and end time of a range of ``unit``s, reckoning the
    start of the week (if that's the selected unit) based on ``week_starts_on``,
    which can be either ``sunday`` or ``monday``.

    :arg unit: One of ``hours``, ``days``, ``weeks``, ``months``, or ``years``.
    :arg range_from: How many ``unit`` (s) in the past/future is the origin?
    :arg range_to: How many ``unit`` (s) in the past/future is the end point?
    :arg epoch: An epoch timestamp used to establish a point of reference for
        calculations.
    :arg week_starts_on: Either ``sunday`` or ``monday``. Default is ``sunday``
    :rtype: tuple
    """
    acceptable_units = ['hours', 'days', 'weeks', 'months', 'years']
    if unit not in acceptable_units:
        raise exceptions.ConfigurationError(f'"unit" must be one of: {acceptable_units}')
    if not range_to >= range_from:
        raise exceptions.ConfigurationError(
            '"range_to" must be greater than or equal to "range_from"')
    if not epoch:
        epoch = time.time()
    epoch = fix_epoch(epoch)
    raw_point_of_ref = datetime.utcfromtimestamp(epoch)
    LOGGER.debug('Raw point of Reference = %s', raw_point_of_ref)
    # Reverse the polarity, because -1 as last week makes sense when read by
    # humans, but datetime timedelta math makes -1 in the future.
    origin = range_from * -1
    # These if statements help get the start date or start_delta
    if unit == 'hours':
        point_of_ref = datetime(
            raw_point_of_ref.year, raw_point_of_ref.month, raw_point_of_ref.day,
            raw_point_of_ref.hour, 0, 0
        )
        start_delta = timedelta(hours=origin)
    if unit == 'days':
        point_of_ref = datetime(
            raw_point_of_ref.year, raw_point_of_ref.month,
            raw_point_of_ref.day, 0, 0, 0
            )
        start_delta = timedelta(days=origin)
    if unit == 'weeks':
        point_of_ref = datetime(
            raw_point_of_ref.year, raw_point_of_ref.month, raw_point_of_ref.day, 0, 0, 0)
        sunday = False
        if week_starts_on.lower() == 'sunday':
            sunday = True
        weekday = point_of_ref.weekday()
        # Compensate for ISO week starting on Monday by default
        if sunday:
            weekday += 1
        LOGGER.debug('Weekday = %s', weekday)
        start_delta = timedelta(days=weekday, weeks=origin)
    if unit == 'months':
        point_of_ref = datetime(raw_point_of_ref.year, raw_point_of_ref.month, 1, 0, 0, 0)
        year = raw_point_of_ref.year
        month = raw_point_of_ref.month
        if origin > 0:
            for _ in range(0, origin):
                if month == 1:
                    year -= 1
                    month = 12
                else:
                    month -= 1
        else:
            for _ in range(origin, 0):
                if month == 12:
                    year += 1
                    month = 1
                else:
                    month += 1
        start_date = datetime(year, month, 1, 0, 0, 0)
    if unit == 'years':
        point_of_ref = datetime(raw_point_of_ref.year, 1, 1, 0, 0, 0)
        start_date = datetime(raw_point_of_ref.year - origin, 1, 1, 0, 0, 0)
    if unit not in ['months', 'years']:
        start_date = point_of_ref - start_delta
    # By this point, we know our start date and can convert it to epoch time
    start_epoch = datetime_to_epoch(start_date)
    LOGGER.debug('Start ISO8601 = %s', datetime.utcfromtimestamp(start_epoch).isoformat())
    # This is the number of units we need to consider.
    count = (range_to - range_from) + 1
    # We have to iterate to one more month, and then subtract a second to get
    # the last day of the correct month
    if unit == 'months':
        month = start_date.month
        year = start_date.year
        for _ in range(0, count):
            if month == 12:
                year += 1
                month = 1
            else:
                month += 1
        end_date = datetime(year, month, 1, 0, 0, 0)
        end_epoch = datetime_to_epoch(end_date) - 1
    # Similarly, with years, we need to get the last moment of the year
    elif unit == 'years':
        end_date = datetime((raw_point_of_ref.year - origin) + count, 1, 1, 0, 0, 0)
        end_epoch = datetime_to_epoch(end_date) - 1
    # It's not months or years, which have inconsistent reckoning...
    else:
        # This lets us use an existing method to simply add unit * count seconds
        # to get hours, days, or weeks, as they don't change
        end_epoch = get_point_of_reference(
            unit, count * -1, epoch=start_epoch) -1
    LOGGER.debug('End ISO8601 = %s', datetime.utcfromtimestamp(end_epoch).isoformat())
    return (start_epoch, end_epoch)

def absolute_date_range(
        unit, date_from, date_to,
        date_from_format=None, date_to_format=None
    ):
    """
    Get the epoch start time and end time of a range of ``unit``s, reckoning the
    start of the week (if that's the selected unit) based on ``week_starts_on``,
    which can be either ``sunday`` or ``monday``.

    :arg unit: One of ``hours``, ``days``, ``weeks``, ``months``, or ``years``.
    :arg date_from: The simplified date for the start of the range
    :arg date_to: The simplified date for the end of the range.  If this value
        is the same as ``date_from``, the full value of ``unit`` will be
        extrapolated for the range.  For example, if ``unit`` is ``months``,
        and ``date_from`` and ``date_to`` are both ``2017.01``, then the entire
        month of January 2017 will be the absolute date range.
    :arg date_from_format: The strftime string used to parse ``date_from``
    :arg date_to_format: The strftime string used to parse ``date_to``
    :rtype: tuple
    """
    acceptable_units = ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months', 'years']
    if unit not in acceptable_units:
        raise exceptions.ConfigurationError(
            '"unit" must be one of: {0}'.format(acceptable_units))
    if not date_from_format or not date_to_format:
        raise exceptions.ConfigurationError('Must provide "date_from_format" and "date_to_format"')
    try:
        start_epoch = datetime_to_epoch(get_datetime(date_from, date_from_format))
        LOGGER.debug(
            'Start ISO8601 = %s', datetime.utcfromtimestamp(start_epoch).isoformat())
    except Exception as err:
        raise exceptions.ConfigurationError(
            f'Unable to parse "date_from" {date_from} and "date_from_format" {date_from_format}. '
            f'Error: {err}'
        )
    try:
        end_date = get_datetime(date_to, date_to_format)
    except Exception as err:
        raise exceptions.ConfigurationError(
            f'Unable to parse "date_to" {date_to} and "date_to_format" {date_to_format}. '
            f'Error: {err}'
        )
    # We have to iterate to one more month, and then subtract a second to get
    # the last day of the correct month
    if unit == 'months':
        month = end_date.month
        year = end_date.year
        if month == 12:
            year += 1
            month = 1
        else:
            month += 1
        new_end_date = datetime(year, month, 1, 0, 0, 0)
        end_epoch = datetime_to_epoch(new_end_date) - 1
    # Similarly, with years, we need to get the last moment of the year
    elif unit == 'years':
        new_end_date = datetime(end_date.year + 1, 1, 1, 0, 0, 0)
        end_epoch = datetime_to_epoch(new_end_date) - 1
    # It's not months or years, which have inconsistent reckoning...
    else:
        # This lets us use an existing method to simply add 1 more unit's worth
        # of seconds to get hours, days, or weeks, as they don't change
        # We use -1 as point of reference normally subtracts from the epoch
        # and we need to add to it, so we'll make it subtract a negative value.
        # Then, as before, subtract 1 to get the end of the period
        end_epoch = get_point_of_reference(
            unit, -1, epoch=datetime_to_epoch(end_date)) -1

    LOGGER.debug('End ISO8601 = %s', datetime.utcfromtimestamp(end_epoch).isoformat())
    return (start_epoch, end_epoch)

def byte_size(num, suffix='B'):
    """
    Return a formatted string indicating the size in bytes, with the proper
    unit, e.g. KB, MB, GB, TB, etc.

    :arg num: The number of byte
    :arg suffix: An arbitrary suffix, like `Bytes`
    :rtype: float
    """
    for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']:
        if abs(num) < 1024.0:
            return f'{num:3.1f}{unit}{suffix}'
        num /= 1024.0
    return f'{num:.1f}Y{suffix}'

def ensure_list(indices):
    """
    Return a list, even if indices is a single value

    :arg indices: A list of indices to act upon
    :rtype: list
    """
    if not isinstance(indices, list): # in case of a single value passed
        indices = [indices]
    return indices

def to_csv(indices):
    """
    Return a csv string from a list of indices, or a single value if only one
    value is present

    :arg indices: A list of indices to act on, or a single value, which could be
        in the format of a csv string already.
    :rtype: str
    """
    indices = ensure_list(indices) # in case of a single value passed
    if indices:
        return ','.join(sorted(indices))
    else:
        return None

def check_csv(value):
    """
    Some of the curator methods should not operate against multiple indices at
    once.  This method can be used to check if a list or csv has been sent.

    :arg value: The value to test, if list or csv string
    :rtype: bool
    """
    if isinstance(value, list):
        return True
    if isinstance(value, str):
        if len(value.split(',')) > 1: # It's a csv string.
            return True
        else: # There's only one value here, so it's not a csv string
            return False
    else:
        raise TypeError(
            f'Passed value: {value} is not a list or a string but is of type {type(value)}'
        )

def chunk_index_list(indices):
    """
    This utility chunks very large index lists into 3KB chunks
    It measures the size as a csv string, then converts back into a list
    for the return value.

    :arg indices: A list of indices to act on.
    :rtype: list
    """
    chunks = []
    chunk = ""
    for index in indices:
        if len(chunk) < 3072:
            if not chunk:
                chunk = index
            else:
                chunk += "," + index
        else:
            chunks.append(chunk.split(','))
            chunk = index
    chunks.append(chunk.split(','))
    return chunks

def get_indices(client):
    """
    Get the current list of indices from the cluster.

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :rtype: list
    """
    try:
        indices = list(client.indices.get_settings(index='*', expand_wildcards='open,closed'))
        version_number = get_version(client)
        LOGGER.debug('Detected Elasticsearch version %s', ".".join(map(str, version_number)))
        LOGGER.debug('All indices: %s', indices)
        return indices
    except Exception as err:
        raise exceptions.FailedExecution(f'Failed to get indices. Error: {err}')

def show_dry_run(ilo, action, **kwargs):
    """
    Log dry run output with the action which would have been executed.

    :arg ilo: A :class:`curator.indexlist.IndexList`
    :arg action: The `action` to be performed.
    :arg kwargs: Any other args to show in the log output
    """
    LOGGER.info('DRY-RUN MODE.  No changes will be made.')
    msg = f'(CLOSED) indices may be shown that may not be acted on by action "{action}".'
    LOGGER.info(msg)
    indices = sorted(ilo.indices)
    for idx in indices:
        index_closed = ilo.index_info[idx]['state'] == 'close'
        var = ' (CLOSED)' if index_closed else ''
        msg = f'DRY-RUN: {action}: {idx}{var} with arguments: {kwargs}'
        LOGGER.info(msg)

### SNAPSHOT STUFF ###
def get_repository(client, repository=''):
    """
    Return configuration information for the indicated repository.

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg repository: The Elasticsearch snapshot repository to use
    :rtype: dict
    """
    try:
        return client.snapshot.get_repository(name=repository)
    except (elasticsearch8.TransportError, elasticsearch8.NotFoundError) as err:
        msg = (
            f'Unable to get repository {repository}.  Error: {err} Check Elasticsearch '
            f'logs for more information.'
        )
        raise exceptions.CuratorException(msg)

def get_snapshot(client, repository=None, snapshot=''):
    """
    Return information about a snapshot (or a comma-separated list of snapshots)
    If no snapshot specified, it will return all snapshots.  If none exist, an
    empty dictionary will be returned.

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg repository: The Elasticsearch snapshot repository to use
    :arg snapshot: The snapshot name, or a comma-separated list of snapshots
    :rtype: dict
    """
    if not repository:
        raise exceptions.MissingArgument('No value for "repository" provided')
    snapname = '_all' if snapshot == '' else snapshot
    try:
        return client.snapshot.get(repository=repository, snapshot=snapshot)
    except (elasticsearch8.TransportError, elasticsearch8.NotFoundError) as err:
        msg = (
            f'Unable to get information about snapshot {snapname} from repository: '
            f'{repository}.  Error: {err}'
        )
        raise exceptions.FailedExecution(msg)

def get_snapshot_data(client, repository=None):
    """
    Get ``_all`` snapshots from repository and return a list.

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg repository: The Elasticsearch snapshot repository to use
    :rtype: list
    """
    if not repository:
        raise exceptions.MissingArgument('No value for "repository" provided')
    try:
        return client.snapshot.get(repository=repository, snapshot="_all")['snapshots']
    except (elasticsearch8.TransportError, elasticsearch8.NotFoundError) as err:
        msg = (
            f'Unable to get snapshot information from repository: '
            f'{repository}. Error: {err}'
        )
        raise exceptions.FailedExecution(msg)

def snapshot_in_progress(client, repository=None, snapshot=None):
    """
    Determine whether the provided snapshot in `repository` is ``IN_PROGRESS``.
    If no value is provided for `snapshot`, then check all of them.
    Return `snapshot` if it is found to be in progress, or `False`

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg repository: The Elasticsearch snapshot repository to use
    :arg snapshot: The snapshot name
    """
    allsnaps = get_snapshot_data(client, repository=repository)
    inprogress = (
        [snap['snapshot'] for snap in allsnaps if 'state' in snap.keys() \
            and snap['state'] == 'IN_PROGRESS']
    )
    if snapshot:
        retval = snapshot if snapshot in inprogress else False
    else:
        if not inprogress:
            retval = False
        elif len(inprogress) == 1:
            retval = inprogress[0]
        else: # This should not be possible
            raise exceptions.CuratorException(f'More than 1 snapshot in progress: {inprogress}')
    return retval

def find_snapshot_tasks(client):
    """
    Check if there is snapshot activity in the Tasks API.
    Return `True` if activity is found, or `False`

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :rtype: bool
    """
    retval = False
    tasklist = client.tasks.list()
    for node in tasklist['nodes']:
        for task in tasklist['nodes'][node]['tasks']:
            activity = tasklist['nodes'][node]['tasks'][task]['action']
            if 'snapshot' in activity:
                LOGGER.debug('Snapshot activity detected: %s', activity)
                retval = True
    return retval

def safe_to_snap(client, repository=None, retry_interval=120, retry_count=3):
    """
    Ensure there are no snapshots in progress.  Pause and retry accordingly

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg repository: The Elasticsearch snapshot repository to use
    :arg retry_interval: Number of seconds to delay betwen retries. Default:
        120 (seconds)
    :arg retry_count: Number of attempts to make. Default: 3
    :rtype: bool
    """
    if not repository:
        raise exceptions.MissingArgument('No value for "repository" provided')
    for count in range(1, retry_count+1):
        in_progress = snapshot_in_progress(
            client, repository=repository
        )
        ongoing_task = find_snapshot_tasks(client)
        if in_progress or ongoing_task:
            if in_progress:
                LOGGER.info('Snapshot already in progress: %s', in_progress)
            elif ongoing_task:
                LOGGER.info('Snapshot activity detected in Tasks API')
            LOGGER.info('Pausing %s seconds before retrying...', retry_interval)
            time.sleep(retry_interval)
            LOGGER.info('Retry %s of %s', count, retry_count)
        else:
            return True
    return False

def create_snapshot_body(indices, ignore_unavailable=False,
                         include_global_state=True, partial=False):
    """
    Create the request body for creating a snapshot from the provided
    arguments.

    :arg indices: A single index, or list of indices to snapshot.
    :arg ignore_unavailable: Ignore unavailable shards/indices. (default:
        `False`)
    :type ignore_unavailable: bool
    :arg include_global_state: Store cluster global state with snapshot.
        (default: `True`)
    :type include_global_state: bool
    :arg partial: Do not fail if primary shard is unavailable. (default:
        `False`)
    :type partial: bool
    :rtype: dict
    """
    if not indices:
        LOGGER.error('No indices provided.')
        return False
    body = {
        "ignore_unavailable": ignore_unavailable,
        "include_global_state": include_global_state,
        "partial": partial,
    }
    if indices == '*':
        body["indices"] = indices
    else:
        body["indices"] = to_csv(indices)
    return body

# pylint: disable=unused-argument
def create_repo_body(repo_type=None,
                     compress=True, chunk_size=None,
                     max_restore_bytes_per_sec=None,
                     max_snapshot_bytes_per_sec=None,
                     location=None,
                     bucket=None, region=None, base_path=None, access_key=None,
                     secret_key=None,
                     role_arn=None, **kwargs):
    """
    Build the 'body' portion for use in creating a repository.

    :arg repo_type: The type of repository (presently only `fs` and `s3`)
    :arg compress: Turn on compression of the snapshot files. Compression is
        applied only to metadata files (index mapping and settings). Data files
        are not compressed. (Default: `True`)
    :arg chunk_size: The chunk size can be specified in bytes or by using size
        value notation, i.e. 1g, 10m, 5k. Defaults to `null` (unlimited chunk
        size).
    :arg max_restore_bytes_per_sec: Throttles per node restore rate. Defaults
        to ``20mb`` per second.
    :arg max_snapshot_bytes_per_sec: Throttles per node snapshot rate. Defaults
        to ``20mb`` per second.
    :arg location: Location of the snapshots. Required.
    :arg bucket: `S3 only.` The name of the bucket to be used for snapshots.
        Required.
    :arg region: `S3 only.` The region where bucket is located. Defaults to
        `US Standard`
    :arg base_path: `S3 only.` Specifies the path within bucket to repository
        data. Defaults to value of ``repositories.s3.base_path`` or to root
        directory if not set.
    :arg access_key: `S3 only.` The access key to use for authentication.
        Defaults to value of ``cloud.aws.access_key``.
    :arg secret_key: `S3 only.` The secret key to use for authentication.
        Defaults to value of ``cloud.aws.secret_key``.
    :arg role_arn: `S3 only.` The role arn for snapshot registration.
        Required.

    :returns: A dictionary suitable for creating a repository from the provided
        arguments.
    :rtype: dict
    """
    # This shouldn't happen, but just in case...
    if not repo_type:
        raise exceptions.MissingArgument('Missing required parameter --repo_type')

    argdict = locals()
    body = {}
    body['type'] = argdict['repo_type']
    body['settings'] = {}
    settingz = [] # Differentiate from module settings
    maybes = [
        'compress', 'chunk_size', 'max_restore_bytes_per_sec', 'max_snapshot_bytes_per_sec']
    s3args = ['bucket', 'region', 'base_path', 'access_key', 'secret_key', 'role_arn']

    settingz += [i for i in maybes if argdict[i]]
    # Type 'fs'
    if argdict['repo_type'] == 'fs':
        settingz.append('location')
    # Type 's3'
    if argdict['repo_type'] == 's3':
        settingz += [i for i in s3args if argdict[i]]
    for k in settingz:
        body['settings'][k] = argdict[k]
    return body

def create_repository(client, **kwargs):
    """
    Create repository with repository and body settings

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object

    :arg repository: The Elasticsearch snapshot repository to use
    :arg repo_type: The type of repository (presently only `fs` and `s3`)
    :arg compress: Turn on compression of the snapshot files. Compression is
        applied only to metadata files (index mapping and settings). Data files
        are not compressed. (Default: `True`)
    :arg chunk_size: The chunk size can be specified in bytes or by using size
        value notation, i.e. 1g, 10m, 5k. Defaults to `null` (unlimited chunk
        size).
    :arg max_restore_bytes_per_sec: Throttles per node restore rate. Defaults
        to ``20mb`` per second.
    :arg max_snapshot_bytes_per_sec: Throttles per node snapshot rate. Defaults
        to ``20mb`` per second.
    :arg location: Location of the snapshots. Required.
    :arg bucket: `S3 only.` The name of the bucket to be used for snapshots.
        Required.
    :arg region: `S3 only.` The region where bucket is located. Defaults to
        `US Standard`
    :arg base_path: `S3 only.` Specifies the path within bucket to repository
        data. Defaults to value of ``repositories.s3.base_path`` or to root
        directory if not set.
    :arg access_key: `S3 only.` The access key to use for authentication.
        Defaults to value of ``cloud.aws.access_key``.
    :arg secret_key: `S3 only.` The secret key to use for authentication.
        Defaults to value of ``cloud.aws.secret_key``.
    :arg skip_repo_fs_check: Skip verifying the repo after creation.

    :returns: A boolean value indicating success or failure.
    :rtype: bool
    """
    if 'repository' not in kwargs:
        raise exceptions.MissingArgument('Missing required parameter "repository"')
    else:
        repository = kwargs['repository']
    skip_repo_fs_check = kwargs.pop('skip_repo_fs_check', False)
    params = {'verify': 'false' if skip_repo_fs_check else 'true'}

    try:
        body = create_repo_body(**kwargs)
        LOGGER.debug('Checking if repository %s already exists...', repository)
        result = repository_exists(client, repository=repository)
        LOGGER.debug('Result = %s', result)
        if not result:
            LOGGER.debug('Repository %s not in Elasticsearch. Continuing...', repository)
            client.snapshot.create_repository(repository=repository, body=body, params=params)
        else:
            raise exceptions.FailedExecution(
                f'Unable to create repository {repository}. '
                f'A repository with that name already exists.'
            )
    except elasticsearch8.TransportError as err:
        raise exceptions.FailedExecution(
            f"""
            Unable to create repository {repository}.  Error: {err}.
            Check curator and elasticsearch logs for more information.
            """
        )
    LOGGER.debug("Repository %s creation initiated...", repository)
    return True

def repository_exists(client, repository=None):
    """
    Verify the existence of a repository

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg repository: The Elasticsearch snapshot repository to use
    :rtype: bool
    """
    if not repository:
        raise exceptions.MissingArgument('No value for "repository" provided')
    try:
        test_result = get_repository(client, repository)
        if repository in test_result:
            LOGGER.debug("Repository %s exists.", repository)
            response = True
        else:
            LOGGER.debug("Repository %s not found...", repository)
            response = False
    # pylint: disable=broad-except
    except Exception as err:
        LOGGER.debug('Unable to find repository "%s": Error: %s', repository, err)
        response = False
    return response

def test_repo_fs(client, repository=None):
    """
    Test whether all nodes have write access to the repository

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg repository: The Elasticsearch snapshot repository to use
    """
    try:
        nodes = client.snapshot.verify_repository(name=repository)['nodes']
        LOGGER.debug('All nodes can write to the repository')
        LOGGER.debug('Nodes with verified repository access: %s', nodes)
    except Exception as err:
        try:
            if err.status_code == 404:
                msg = (
                    f'--- Repository "{repository}" not found. Error: '
                    f'{err.status_code}, {err.error}'
                )
            else:
                msg = (
                    f'--- Got a {err.status_code} response from Elasticsearch.  '
                    f'Error message: {err.error}'
                )
        except AttributeError:
            msg = (f'--- Error message: {err}'.format())
        raise exceptions.ActionError(f'Failed to verify all nodes have repository access: {msg}')

def snapshot_running(client):
    """
    Return `True` if a snapshot is in progress, and `False` if not

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :rtype: bool
    """
    try:
        status = client.snapshot.status()['snapshots']
    # pylint: disable=broad-except
    except Exception as err:
        report_failure(err)
    # We will only accept a positively identified False.  Anything else is
    # suspect.
    return False if not status else True

def parse_date_pattern(name):
    """
    Scan and parse `name` for :py:func:`time.strftime` strings, replacing them
    with the associated value when found, but otherwise returning lowercase
    values, as uppercase snapshot names are not allowed. It will detect if the
    first character is a `<`, which would indicate `name` is going to be using
    Elasticsearch date math syntax, and skip accordingly.

    The :py:func:`time.strftime` identifiers that Curator currently recognizes
    as acceptable include:

    * ``Y``: A 4 digit year
    * ``y``: A 2 digit year
    * ``m``: The 2 digit month
    * ``W``: The 2 digit week of the year
    * ``d``: The 2 digit day of the month
    * ``H``: The 2 digit hour of the day, in 24 hour notation
    * ``M``: The 2 digit minute of the hour
    * ``S``: The 2 digit number of second of the minute
    * ``j``: The 3 digit day of the year

    :arg name: A name, which can contain :py:func:`time.strftime`
        strings
    """
    prev, rendered = ('', '')
    LOGGER.debug('Provided index name: %s', name)
    for idx, char in enumerate(name):
        LOGGER.debug('Current character in provided name: %s, position: %s', char, idx)
        if char == '<':
            LOGGER.info('"%s" is probably using Elasticsearch date math.', name)
            rendered = name
            break
        if char == '%':
            pass
        elif char in settings.date_regex() and prev == '%':
            rendered += str(datetime.utcnow().strftime(f'%{char}'))
        else:
            rendered += char
        LOGGER.debug('Partially rendered name: %s', rendered)
        prev = char
    LOGGER.debug('Fully rendered name: %s', rendered)
    return rendered

def prune_nones(mydict):
    """
    Remove keys from `mydict` whose values are `None`

    :arg mydict: The dictionary to act on
    :rtype: dict
    """
    # Test for `None` instead of existence or zero values will be caught
    return dict([(k, v) for k, v in mydict.items() if v is not None and v != 'None'])

def validate_filters(action, myfilters):
    """
    Validate that myfilters are appropriate for the action type, e.g. no
    index filters applied to a snapshot list.

    :arg action: An action name
    :arg myfilters: A list of filters to test.
    """
    # Define which set of filtertypes to use for testing
    if action in settings.snapshot_actions():
        filtertypes = settings.snapshot_filtertypes()
    else:
        filtertypes = settings.index_filtertypes()
    for fil in myfilters:
        if fil['filtertype'] not in filtertypes:
            raise exceptions.ConfigurationError(
                f"\"{fil['filtertype']}\" filtertype is not compatible with action \"{action}\""
            )
    # If we get to this point, we're still valid.  Return the original list
    return myfilters

def validate_actions(data):
    """
    Validate an Action configuration dictionary, as imported from actions.yml,
    for example.

    The method returns a validated and sanitized configuration dictionary.

    :arg data: The configuration dictionary
    :rtype: dict
    """
    # data is the ENTIRE schema...
    clean_config = {}
    # Let's break it down into smaller chunks...
    # First, let's make sure it has "actions" as a key, with a subdictionary
    root = SchemaCheck(data, actions.root(), 'Actions File', 'root').result()
    # We've passed the first step.  Now let's iterate over the actions...
    for action_id in root['actions']:
        # Now, let's ensure that the basic action structure is correct, with
        # the proper possibilities for 'action'
        action_dict = root['actions'][action_id]
        loc = f'Action ID "{action_id}"'
        valid_structure = SchemaCheck(
            action_dict,
            actions.structure(action_dict, loc),
            'structure',
            loc
        ).result()
        # With the basic structure validated, now we extract the action name
        current_action = valid_structure['action']
        # And let's update the location with the action.
        loc = f'Action ID "{action_id}", action "{current_action}"'
        clean_options = SchemaCheck(
            prune_nones(valid_structure['options']),
            options.get_schema(current_action),
            'options',
            loc
        ).result()
        clean_config[action_id] = {
            'action' : current_action,
            'description' : valid_structure['description'],
            'options' : clean_options,
        }
        if current_action == 'alias':
            add_remove = {}
            for k in ['add', 'remove']:
                if k in valid_structure:
                    current_filters = SchemaCheck(
                        valid_structure[k]['filters'],
                        Schema(validfilters(current_action, location=loc)),
                        f'"{k}" filters',
                        f'{loc}, "filters"'
                    ).result()
                    add_remove.update(
                        {
                            k: {
                                'filters' : SchemaCheck(
                                    current_filters,
                                    Schema(validfilters(current_action, location=loc)),
                                    'filters',
                                    f'{loc}, "{k}", "filters"'
                                    ).result()
                                }
                        }
                    )
            # Add/Remove here
            clean_config[action_id].update(add_remove)
        elif current_action in ['cluster_routing', 'create_index', 'rollover']:
            # neither cluster_routing nor create_index should have filters
            pass
        else: # Filters key only appears in non-alias actions
            valid_filters = SchemaCheck(
                valid_structure['filters'],
                Schema(validfilters(current_action, location=loc)),
                'filters',
                f'{loc}, "filters"'
            ).result()
            clean_filters = validate_filters(current_action, valid_filters)
            clean_config[action_id].update({'filters' : clean_filters})
        # This is a special case for remote reindex
        if current_action == 'reindex':
            # Check only if populated with something.
            if 'remote_filters' in valid_structure['options']:
                valid_filters = SchemaCheck(
                    valid_structure['options']['remote_filters'],
                    Schema(validfilters(current_action, location=loc)),
                    'filters',
                    f'{loc}, "filters"'
                ).result()
                clean_remote_filters = validate_filters(current_action, valid_filters)
                clean_config[action_id]['options'].update({'remote_filters': clean_remote_filters})

    # if we've gotten this far without any Exceptions raised, it's valid!
    return {'actions': clean_config}

def health_check(client, **kwargs):
    """
    This function calls client.cluster.health and, based on the args provided,
    will return `True` or `False` depending on whether that particular keyword
    appears in the output, and has the expected value.
    If multiple keys are provided, all must match for a `True` response.

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    """
    LOGGER.debug('KWARGS= "%s"', kwargs)
    klist = list(kwargs.keys())
    if not klist:
        raise exceptions.MissingArgument('Must provide at least one keyword argument')
    hc_data = client.cluster.health()
    response = True

    for k in klist:
        # First, verify that all kwargs are in the list
        if not k in list(hc_data.keys()):
            raise exceptions.ConfigurationError('Key "{0}" not in cluster health output')
        if not hc_data[k] == kwargs[k]:
            msg = f'NO MATCH: Value for key "{kwargs[k]}", health check data: {hc_data[k]}'
            LOGGER.debug(msg)
            response = False
        else:
            msg = f'MATCH: Value for key "{kwargs[k]}", health check data: {hc_data[k]}'
            LOGGER.debug(msg)
    if response:
        LOGGER.info('Health Check for all provided keys passed.')
    return response

def snapshot_check(client, snapshot=None, repository=None):
    """
    This function calls `client.snapshot.get` and tests to see whether the
    snapshot is complete, and if so, with what status.  It will log errors
    according to the result. If the snapshot is still `IN_PROGRESS`, it will
    return `False`.  `SUCCESS` will be an `INFO` level message, `PARTIAL` nets
    a `WARNING` message, `FAILED` is an `ERROR`, message, and all others will be
    a `WARNING` level message.

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg snapshot: The name of the snapshot.
    :arg repository: The Elasticsearch snapshot repository to use
    """
    try:
        state = client.snapshot.get(
            repository=repository, snapshot=snapshot)['snapshots'][0]['state']
    except Exception as err:
        raise exceptions.CuratorException(
            f'Unable to obtain information for snapshot "{snapshot}" in repository '
            f'"{repository}". Error: {err}'
        )
    LOGGER.debug('Snapshot state = %s', state)
    retval = True
    if state == 'IN_PROGRESS':
        LOGGER.info('Snapshot %s still in progress.', snapshot)
        retval = False
    elif state == 'SUCCESS':
        LOGGER.info('Snapshot %s successfully completed.', snapshot)
    elif state == 'PARTIAL':
        LOGGER.warning('Snapshot %s completed with state PARTIAL.', snapshot)
    elif state == 'FAILED':
        LOGGER.error('Snapshot %s completed with state FAILED.', snapshot)
    else:
        LOGGER.warning('Snapshot %s completed with state: %s', snapshot, state)
    return retval

def relocate_check(client, index):
    """
    This function calls client.cluster.state with a given index to check if
    all of the shards for that index are in the STARTED state. It will
    return `True` if all shards both primary and replica are in the STARTED
    state, and it will return `False` if any primary or replica shard is in
    a different state.

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg index: The index to check the index shards state.
    """
    shard_state_data = (
        client.cluster.state(index=index)['routing_table']['indices'][index]['shards']
    )
    finished_state = (
        all(
            all(
                shard['state'] == "STARTED" for shard in shards
            )
            for shards in shard_state_data.values()
        )
    )
    if finished_state:
        LOGGER.info('Relocate Check for index: "%s" has passed.', index)
    return finished_state


def restore_check(client, index_list):
    """
    This function calls client.indices.recovery with the list of indices to
    check for complete recovery.  It will return `True` if recovery of those
    indices is complete, and `False` otherwise.  It is designed to fail fast:
    if a single shard is encountered that is still recovering (not in `DONE`
    stage), it will immediately return `False`, rather than complete iterating
    over the rest of the response.

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg index_list: The list of indices to verify having been restored.
    """
    response = {}
    for chunk in chunk_index_list(index_list):
        try:
            chunk_response = client.indices.recovery(index=chunk, human=True)
        except Exception as err:
            raise exceptions.CuratorException(
                f'Unable to obtain recovery information for specified indices. Error: {err}'
            )
        if chunk_response == {}:
            LOGGER.info('_recovery returned an empty response. Trying again.')
            return False
        response.update(chunk_response)
    LOGGER.info('Provided indices: %s', index_list)
    LOGGER.info('Found indices: %s', list(response.keys()))
    # pylint: disable=consider-using-dict-items
    for index in response:
        for shard in range(0, len(response[index]['shards'])):
            stage = response[index]['shards'][shard]['stage']
            if stage != 'DONE':
                LOGGER.info('Index "%s" is still in stage "%s"', index, stage)
                return False

    # If we've gotten here, all of the indices have recovered
    return True


def task_check(client, task_id=None):
    """
    This function calls client.tasks.get with the provided `task_id`.  If the
    task data contains ``'completed': True``, then it will return `True`
    If the task is not completed, it will log some information about the task
    and return `False`

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg task_id: A task_id which ostensibly matches a task searchable in the
        tasks API.
    """
    try:
        task_data = client.tasks.get(task_id=task_id)
    except Exception as err:
        raise exceptions.CuratorException(
            f'Unable to obtain task information for task_id "{task_id}". Exception {err}'
        )
    task = task_data['task']
    completed = task_data['completed']
    if task['action'] == 'indices:data/write/reindex':
        LOGGER.debug('It\'s a REINDEX TASK')
        LOGGER.debug('TASK_DATA: %s', task_data)
        LOGGER.debug('TASK_DATA keys: %s', list(task_data.keys()))
        if 'response' in task_data:
            response = task_data['response']
            if response['failures']:
                raise exceptions.FailedReindex(
                    f'Failures found in reindex response: {response["failures"]}'
                )
    running_time = 0.000000001 * task['running_time_in_nanos']
    LOGGER.debug('Running time: %s seconds', running_time)
    descr = task['description']

    if completed:
        completion_time = ((running_time * 1000) + task['start_time_in_millis'])
        time_string = time.strftime(
            '%Y-%m-%dT%H:%M:%SZ', time.localtime(completion_time/1000)
        )
        LOGGER.info('Task "%s" completed at %s.', descr, time_string)
        retval = True
    else:
        # Log the task status here.
        LOGGER.debug('Full Task Data: %s', task_data)
        msg = (
            f'Task "{descr}" with task_id "{task_id}" has been running for {running_time} seconds'
        )
        LOGGER.info(msg)
        retval = False
    return retval


def wait_for_it(
        client, action, task_id=None, snapshot=None, repository=None,
        index=None, index_list=None, wait_interval=9, max_wait=-1
    ):
    """
    This function becomes one place to do all wait_for_completion type behaviors

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg action: The action name that will identify how to wait
    :arg task_id: If the action provided a task_id, this is where it must be
        declared.
    :arg snapshot: The name of the snapshot.
    :arg repository: The Elasticsearch snapshot repository to use
    :arg wait_interval: How frequently the specified "wait" behavior will be
        polled to check for completion.
    :arg max_wait: Number of seconds will the "wait" behavior persist
        before giving up and raising an Exception.  The default is -1, meaning
        it will try forever.
    """
    action_map = {
        'allocation':{
            'function': health_check,
            'args': {'relocating_shards':0},
        },
        'replicas':{
            'function': health_check,
            'args': {'status':'green'},
        },
        'cluster_routing':{
            'function': health_check,
            'args': {'relocating_shards':0},
        },
        'snapshot':{
            'function':snapshot_check,
            'args':{'snapshot':snapshot, 'repository':repository},
        },
        'restore':{
            'function':restore_check,
            'args':{'index_list':index_list},
        },
        'reindex':{
            'function':task_check,
            'args':{'task_id':task_id},
        },
        'shrink':{
            'function': health_check,
            'args': {'status':'green'},
        },
        'relocate':{
            'function': relocate_check,
            'args': {'index':index}
        },
    }
    wait_actions = list(action_map.keys())

    if action not in wait_actions:
        raise exceptions.ConfigurationError(f'"action" must be one of {wait_actions}')
    if action == 'reindex' and task_id is None:
        raise exceptions.MissingArgument(f'A task_id must accompany "action" {action}')
    if action == 'snapshot' and ((snapshot is None) or (repository is None)):
        raise exceptions.MissingArgument(
            f'A snapshot and repository must accompany "action" {action}. snapshot: '
            f'{snapshot}, repository: {repository}'
        )
    if action == 'restore' and index_list is None:
        raise exceptions.MissingArgument(f'An index_list must accompany "action" {action}')
    elif action == 'reindex':
        try:
            _ = client.tasks.get(task_id=task_id)
        except Exception as err:
            # This exception should only exist in API usage. It should never
            # occur in regular Curator usage.
            raise exceptions.CuratorException(f'Unable to find task_id {task_id}. Exception: {err}')

    # Now with this mapped, we can perform the wait as indicated.
    start_time = datetime.now()
    result = False
    while True:
        elapsed = int((datetime.now() - start_time).total_seconds())
        LOGGER.debug('Elapsed time: %s seconds', elapsed)
        response = action_map[action]['function'](client, **action_map[action]['args'])
        LOGGER.debug('Response: %s', response)
        # Success
        if response:
            LOGGER.debug(
                'Action "%s" finished executing (may or may not have been successful)', action)
            result = True
            break
        # Not success, and reached maximum wait (if defined)
        elif (max_wait != -1) and (elapsed >= max_wait):
            msg = f'Unable to complete action "{action}" within max_wait ({max_wait}) seconds.'
            LOGGER.error(msg)
            break
        # Not success, so we wait.
        else:
            msg = (
                f'Action "{action}" not yet complete, {elapsed} total seconds elapsed. '
                f'Waiting {wait_interval} seconds before checking again.'
            )
            LOGGER.debug(msg)
            time.sleep(wait_interval)

    LOGGER.debug('Result: %s', result)
    if not result:
        raise exceptions.ActionTimeout(
            f'Action "{action}" failed to complete in the max_wait period of {max_wait} seconds'
        )

def node_roles(client, node_id):
    """
    Return the list of roles assigned to the node identified by ``node_id``

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :rtype: list
    """
    return client.nodes.info()['nodes'][node_id]['roles']

def index_size(client, idx, value='total'):
    """
    Return the sum of either `primaries` or `total` shards for index ``idx``

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :arg idx: An Elasticsearch index
    :arg value: One of either `primaries` or `total`
    :rtype: integer
    """
    return client.indices.stats(index=idx)['indices'][idx][value]['store']['size_in_bytes']

def single_data_path(client, node_id):
    """
    In order for a shrink to work, it should be on a single filesystem, as
    shards cannot span filesystems.  Return `True` if the node has a single
    filesystem, and `False` otherwise.

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :rtype: bool
    """
    return len(client.nodes.stats()['nodes'][node_id]['fs']['data']) == 1


def name_to_node_id(client, name):
    """
    Return the node_id of the node identified by ``name``

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :rtype: str
    """
    stats = client.nodes.stats()
    for node in stats['nodes']:
        if stats['nodes'][node]['name'] == name:
            LOGGER.debug('Found node_id "%s" for name "%s".', node, name)
            return node
    LOGGER.error('No node_id found matching name: "%s"', name)
    return None

def node_id_to_name(client, node_id):
    """
    Return the name of the node identified by ``node_id``

    :arg client: An :class:`elasticsearch8.Elasticsearch` client object
    :rtype: str
    """
    stats = client.nodes.stats()
    name = None
    if node_id in stats['nodes']:
        name = stats['nodes'][node_id]['name']
    else:
        LOGGER.error('No node_id found matching: "%s"', node_id)
    LOGGER.debug('Name associated with node_id "%s": %s', node_id, name)
    return name

def get_datemath(client, datemath, random_element=None):
    """
    Return the parsed index name from ``datemath``
    """
    if random_element is None:
        random_prefix = (
            'curator_get_datemath_function_' +
            ''.join(random.choice(string.ascii_lowercase) for _ in range(32))
        )
    else:
        random_prefix = 'curator_get_datemath_function_' + random_element
    datemath_dummy = '<{0}-{1}>'.format(random_prefix, datemath)
    # We both want and expect a 404 here (NotFoundError), since we have
    # created a 32 character random string to definitely be an unknown
    # index name.
    LOGGER.debug('Random datemath string for extraction: %s', datemath_dummy)
    faux_index = ''
    try:
        client.indices.get(index=datemath_dummy)
    except elasticsearch8.exceptions.NotFoundError as err:
        # This is the magic.  Elasticsearch still gave us the formatted
        # index name in the error results.
        faux_index = err.info['error']['index']
    LOGGER.debug('Response index name for extraction: %s', faux_index)
    # Now we strip the random index prefix back out again
    pattern = r'^{0}-(.*)$'.format(random_prefix)
    regex = re.compile(pattern)
    try:
        # And return only the now-parsed date string
        return regex.match(faux_index).group(1)
    except AttributeError as exc:
        raise exceptions.ConfigurationError(
            f'The rendered index "{faux_index}" does not contain a valid date pattern '
            f'or has invalid index name characters.'
        ) from exc

def isdatemath(data):
    """Check if data is a datemath expression"""
    initial_check = r'^(.).*(.)$'
    regex = re.compile(initial_check)
    opener = regex.match(data).group(1)
    closer = regex.match(data).group(2)
    LOGGER.debug('opener =  %s, closer = %s', opener, closer)
    if (opener == '<' and closer != '>') or (opener != '<' and closer == '>'):
        raise exceptions.ConfigurationError('Incomplete datemath encapsulation in "< >"')
    if (opener != '<' and closer != '>'):
        return False
    return True

def parse_datemath(client, value):
    """
    Check if ``value`` is datemath.
    Parse it if it is.
    Return the bare value otherwise.
    """
    if not isdatemath(value):
        return value
    # if we didn't return here, we can continue, no 'else' needed.
    LOGGER.debug('Properly encapsulated, proceeding to next evaluation...')
    # Our pattern has 4 capture groups.
    # 1. Everything after the initial '<' up to the first '{', which we call ``prefix``
    # 2. Everything between the outermost '{' and '}', which we call ``datemath``
    # 3. An optional inner '{' and '}' containing a date formatter and potentially a timezone.
    #    Not captured.
    # 4. Everything after the last '}' up to the closing '>'
    pattern = r'^<([^\{\}]*)?(\{.*(\{.*\})?\})([^\{\}]*)?>$'
    regex = re.compile(pattern)
    try:
        prefix = regex.match(value).group(1) or ''
        datemath = regex.match(value).group(2)
        # formatter = regex.match(value).group(3) or '' (not captured, but counted)
        suffix = regex.match(value).group(4) or ''
    except AttributeError as exc:
        raise exceptions.ConfigurationError(
            f'Value "{value}" does not contain a valid datemath pattern.') from exc

    return f'{prefix}{get_datemath(client, datemath)}{suffix}'

def get_write_index(client, alias):
    """Find which index associated with an alias is the write index"""
    try:
        response = client.indices.get_alias(index=alias)
    except Exception as exc:
        raise exceptions.CuratorException(f'Alias {alias} not found') from exc
    # If there are more than one in the list, one needs to be the write index
    # otherwise the alias is a one to many, and can't do rollover.
    if len(list(response.keys())) > 1:
        for index in list(response.keys()):
            try:
                if response[index]['aliases'][alias]['is_write_index']:
                    return index
            except KeyError as exc:
                raise exceptions.FailedExecution(
                    'Invalid alias: is_write_index not found in 1 to many alias') from exc
    else:
        # There's only one, so this is it
        return list(response.keys())[0]
