import csv
from datetime import datetime, timezone
from enum import Enum
import operator
import re
from typing import Any, Callable, Iterable, Optional, cast

import pendulum
from pendulum.datetime import DateTime
from typing_extensions import TypeAlias

from daikanban.errors import UserInputError


SECS_PER_HOUR = 3600
HOURS_PER_DAY = 24
DAYS_PER_WEEK = 7
DAYS_PER_MONTH = 30.5
DAYS_PER_YEAR = 365.25
SECS_PER_DAY = SECS_PER_HOUR * HOURS_PER_DAY


#########
# ENUMS #
#########

class StrEnum(str, Enum):
    """Enum class whose __str__ representation is just a plain string value.
    NOTE: this class exists in the standard library in Python >= 3.11."""

    def __str__(self) -> str:
        return cast(str, self.value)


class NotGivenType(Enum):
    """Sentinel class for a value of "not given," when needed to distinguish it from None."""
    NotGiven = 'NotGiven'


NotGiven = NotGivenType.NotGiven


###################
# STRING HANDLING #
###################

def to_snake_case(name: str) -> str:
    """Converts an arbitrary string to snake case."""
    name = name.replace('"', '').replace("'", '')
    return re.sub(r'[^\w]+', '_', name.strip()).lower()

def prefix_match(token: str, match: str, minlen: int = 1) -> bool:
    """Returns True if token is a prefix of match and has length at least minlen."""
    n = len(token)
    return (n >= minlen) and (match[:n] == token)

def convert_number_words_to_digits(s: str) -> str:
    """Replaces occurrences of number words like 'one', 'two', etc. to their digital equivalents."""
    words_to_numbers = {
        'zero': '0',
        'one': '1',
        'two': '2',
        'three': '3',
        'four': '4',
        'five': '5',
        'six': '6',
        'seven': '7',
        'eight': '8',
        'nine': '9',
    }
    pattern = re.compile(r'\b(' + '|'.join(words_to_numbers.keys()) + r')\b')
    return re.sub(pattern, lambda x: words_to_numbers[x.group()], s)

# function which matches a queried name against an existing name
NameMatcher: TypeAlias = Callable[[str, str], bool]
exact_match: NameMatcher = operator.eq

def whitespace_insensitive_match(name1: str, name2: str) -> bool:
    """Matches two strings, leading/trailing-whitespace-insensitively."""
    return name1.strip() == name2.strip()

def case_insensitive_match(name1: str, name2: str) -> bool:
    """Matches two strings, case- and leading/trailing-whitespace-insensitively."""
    return name1.strip().casefold() == name2.strip().casefold()

def fuzzy_match(name1: str, name2: str) -> bool:
    """Matches a queried name against a stored name, case-insensitively.
    This allows the first string to be a prefix of the second, if it is at least three characters long."""
    s1 = name1.strip().casefold()
    s2 = name2.strip().casefold()
    return (s1 == s2) or ((len(s1) >= 3) and s2.startswith(s1))

def first_name_match(matcher: NameMatcher, name1: str, names2: Iterable[str]) -> Optional[str]:
    """Given a NameMatcher, query name, and an iterable of names to compare against, returns the first name that matches (if there is one), otherwise None."""
    return next((name2 for name2 in names2 if matcher(name1, name2)), None)

def parse_string_set(s: str) -> Optional[set[str]]:
    """Parses a comma-separated string into a set of strings.
    Allows for quote delimiting so that commas can be escaped.
    Strips any leading or trailing whitespace from each string."""
    return {string.strip() for string in list(csv.reader([s]))[0]} or None

def parse_key_value_pair(s: str, strict: bool = False) -> Optional[tuple[str, str]]:
    """If the given string is of the form [KEY]=[VALUE], returns a tuple (KEY, VALUE).
    Otherwise, raises a UserInputError if strict=True, or else returns None."""
    match = _EQUALS_EXPR.fullmatch(s.strip())
    if match:
        (key, val) = match.groups()
        return (key, val)
    if strict:
        raise UserInputError(f'Invalid argument {s!r}\n\texpected format \\[OPTION]=\\[VALUE]')
    return None

def count_fmt(n: int, name: str, plural_suffix: str = 's', plural_form: Optional[str] = None) -> str:
    """Renders an integer and item name as a string indicating how many items there are.
    For example:
        - `count_fmt(1, 'item') == "1 item"`
        - `count_fmt(3, 'item') == "3 items"`
        - `count_fmt(3, 'box', plural_suffix='es') == "3 boxes"`
        - `count_fmt(3, 'cactus', plural_form='cacti') == "3 cacti"`
    """
    if n != 1:
        if plural_form is None:
            name = name + plural_suffix
        else:
            name = plural_form
    return f'{n} {name}'

_EQUALS_EXPR = re.compile(r'(\w+)\s*=\s*(.*)')


############
# DATETIME #
############

def get_current_time() -> datetime:
    """Gets the current time (timezone-aware)."""
    return datetime.now(timezone.utc).astimezone()

def get_duration_between(dt1: datetime, dt2: datetime) -> float:
    """Gets the duration (in days) between two datetimes."""
    return (dt2 - dt1).total_seconds() / SECS_PER_DAY

def human_readable_duration(days: float, prefer_days: bool = False) -> str:
    """Given a duration (in days), converts it to a human-readable string.
    This goes out to minute precision only.
    If prefer_days=True, show days and not weeks."""
    if days == 0:
        return '0 seconds'
    dur = pendulum.Duration(days=days)
    s = dur.in_words()
    if prefer_days and ('week' in s):
        s = re.sub(r'\d+ weeks?( \d+ days?)?', f'{dur.days} days', s)
    # hacky way to truncate the seconds
    return re.sub(r'\s+\d+ seconds?', '', s)

def replace_relative_day(s: str) -> str:
    """Given a time string containing yesterday/today/tomorrow, or an expression like "last Friday" or "next Tuesday", replaces it with the appropriate date."""
    pattern1 = '(yesterday|today|tomorrow)'
    def replace1(match: re.Match[str]) -> str:
        now = pendulum.now()
        expr = match.group(0)
        if expr == 'yesterday':
            day = now.subtract(days=1)
        elif expr == 'today':
            day = now
        else:  # tomorrow
            day = now.add(days=1)
        return day.to_date_string()
    pattern2 = r'(last|next)\s+(mon|tues?|wed(nes)?|thu(rs?)?|fri|sat(ur)?|sun)(day)?'
    weekday_map = {day: i for (i, day) in enumerate(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
    def replace2(match: re.Match[str]) -> str:
        now = pendulum.now()
        groups = match.groups()
        past = (groups[0] == 'last')
        i = weekday_map[groups[1][:3]]
        diff = now.weekday() - i
        if past:
            diff = -diff if (diff > 0) else -(7 + diff)
        else:
            diff = (7 - diff) if (diff >= 0) else -diff
        return now.add(days=diff).to_date_string()
    return re.sub(pattern2, replace2, re.sub(pattern1, replace1, s))

def replace_relative_time_expression(s: str) -> str:
    """Resolves a relative time expression to an absolute one."""
    s = replace_relative_day(s)
    pattern = r'(in\s+)?(\d*\.?\d+)\s+(sec(ond)?|min(ute)?|hr|hour|day|week|month|yr|year)s?(\s+(ago|from\s+now))?'
    def replace(match: re.Match[str]) -> str:
        groups = match.groups()
        is_future = (groups[0] is not None) and ('in' in groups[0])
        is_past = (groups[-1] == 'ago')
        is_future |= (groups[-1] is not None) and ('from' in groups[-1])
        if is_past and is_future:  # invalid, so bail
            return match.string
        amount = float(groups[1])
        unit = groups[2]
        now = pendulum.now()
        diff_func = DateTime.subtract if is_past else DateTime.add
        # diff_func = now.subtract if is_past else now.add
        if unit.startswith('sec'):
            return diff_func(now, seconds=amount).to_datetime_string()
        if unit.startswith('min'):
            return diff_func(now, minutes=amount).to_datetime_string()  # type: ignore[arg-type]
        if unit in ['hr', 'hour']:
            return diff_func(now, hours=amount).to_datetime_string()  # type: ignore[arg-type]
        if unit == 'day':
            return diff_func(now, days=amount).to_date_string()  # type: ignore[arg-type]
        if unit == 'week':
            return diff_func(now, weeks=amount).to_date_string()  # type: ignore[arg-type]
        if unit == 'month':
            months = int(amount)
            days = (amount - months) * DAYS_PER_MONTH
            return diff_func(diff_func(now, months=months), days=days).to_date_string()  # type: ignore[arg-type]
        assert unit in ['yr', 'year']
        years = int(amount)
        days = (amount - years) * DAYS_PER_YEAR
        return diff_func(diff_func(now, years=years), days=days).to_date_string()  # type: ignore[arg-type]
    return re.sub(pattern, replace, s)


#########
# STYLE #
#########

def style_str(val: Any, color: str, bold: bool = False, italic: bool = False) -> str:
    """Renders a value as a rich-formatted string with a given color, bold, and italic settings."""
    tags = [('' if bold else 'not ') + 'bold', ('' if italic else 'not ') + 'italic', color]
    tag = ' '.join(tags)
    return f'[{tag}]{val}[/]'

def err_style(obj: object) -> str:
    """Renders an error as a rich-styled string."""
    s = str(obj)
    if s:
        s = s[0].upper() + s[1:]
    return style_str(s, 'red')


########
# MISC #
########

class IdCollection:
    """Class maintaining a collection of ID numbers (starting at 0), with the ability to retrieve the smallest unused ID."""

    def __init__(self, ids: set[int] = set()) -> None:  # noqa: B006
        self.ids = set(ids)
        self.free_id = 0
        self._update()

    def __len__(self) -> int:
        return len(self.ids)

    def __contains__(self, elt: object) -> bool:
        return elt in self.ids

    def _update(self) -> None:
        while self.free_id in self.ids:
            self.free_id += 1

    def add(self, id_: int) -> None:
        """Adds a new ID, then updates the free ID."""
        self.ids.add(id_)
        self._update()
