import re as regex
from collections.abc import Callable, Iterable, Iterator, MutableSequence
from random import shuffle
from typing import Any, Literal, TypeVar

from .tcr_compare import able
from .tcr_null import UniqueDefault as RaiseError
from .tcr_regex import RegexPreset

T = TypeVar('T')
Ts = TypeVar('Ts')


def batched(
  it: Iterable[T],
  n: int,
  *,
  back_to_front: bool = False,
) -> list[Iterable[T]]:
  """### Poor man's `itertools.batched()` (on Py3.11).

  itertools.batched exists but I left it here because:
  - I still need to support py3.11
  - I don't think itertools.batched() has an option to easily back_to_front the results.

  Return a list of splits of the original `it` Iterable that was passed in, split every `n` items. Last group may be smaller than `n` items if the source was exhausted
  `back_to_front`: makes the items pile up in the back and now the first item is the one to have less than n items if there's not enough to pack it with. For example:
  - `batched("1234567890", n=3)                    ` returns `["123", "456", "789", "0"]`
  - `batched("1234567890", n=3, back_to_front=True)` returns `["1", "234", "567", "890"]`
  """
  if back_to_front:
    return [x[::-1] for x in batched(it[::-1], n=n, back_to_front=False)[::-1]]
  return [it[i : i + n] for i in range(0, len(it), n)]


def cut_at(
  it: str | Iterable,
  n: int,
  end: str | Iterable = '...',
  *,
  filter_links: Literal[False] | str | Callable = False,
  shrink_links_visually_if_fits: bool = False,
) -> str:
  """### Return a cut off part of the provided `it` iterable.

  with the `end` added at the end such that the return value is exactly `n` in length.\\
  Mainly strings but also supports other iterables\\
  Set `end` to `''`/`[]` to disable it.\\

  Use `filter_links` to shorten links to this string, only applicable if the iterable provided is a string and if the iterable exceeded `n`, else nothing will be affected
  - `$1` - Entire link
  - `$2` - http/https (will keep the 's' if present, that's its point)
  - `$3` - any `subdomains.` (if present), `.domains.` or `.tld`s
  - `$4` - `.domain` (second to last (possibly after . and) before the last .)
  - `$5` - `.tld` (in between the last . and / or end of match)
  - `$6` - part of the link after `/` (not including it, may be '')

  https://regex101.com/r/46LZtS/1

  `shrink_links_visually_if_fits` replaces links with their markdown counterparts where the visible text is http(s):/subdomain.domain.tld/ (no further part of the links is visible, but the entire link is contained)
  """
  if len(it) <= n:
    if shrink_links_visually_if_fits and len(a := regex.sub(RegexPreset.URL, r'[\2:/\3/](<\1>)', it)) <= n:  # :/ instead of :// because discord bruh
      return a
    return it
  if filter_links is not False and isinstance(it, str):
    if not isinstance(filter_links, str | Callable):
      msg = f'Set filter_links to a str of the replacement value for links or Callable, not {filter_links!r}'
      raise ValueError(msg)
    it = regex.sub(RegexPreset.URL, filter_links, it)
    if len(it) <= n:
      return it
  if n > len(end):
    return it[: (n - len(end))] + end
  if n > 0:
    return end[:n]
  return ''


def bogo_sort(arr: list[T]) -> list[T]:
  if 0 <= len(arr) <= 1:
    return arr
  while True:
    shuffle(arr)
    for i in range(len(arr) - 1):
      if arr[i] > arr[i + 1]:
        break
    else:
      return arr


def stalin_sort(arr: Iterable[T]) -> list[T]:
  if len(arr) <= 1:
    return arr

  sorted_arr = [arr[0]]

  for i in range(1, len(arr)):
    if arr[i] >= sorted_arr[-1]:
      sorted_arr.append(arr[i])

  return sorted_arr


def shuffled(arr: MutableSequence[T]) -> MutableSequence[T]:
  shuffle(arr)
  return arr


def limited_iterable(it: Iterable[T] | Iterator[T], limit: int) -> tuple[list[T], int]:
  """## Return tuple[list, int] of (limited_iterable, overflow_from_limit).

  ### This is different from it[:limit] because it works with iterators

  Examples:
  ```py
  >>> limited_iterable([10, 20, 30], 10)
  ([10, 20, 30], 0) # Nothing is cut off
  >>> limited_iterable([10, 20, 30], 3)
  ([10, 20, 30], 0)
  >>> limited_iterable([10, 20, 30], 2)
  ([10, 20], 1)
  >>> limited_iterable([10, 20, 30], 1)
  ([10], 2)
  >>> limited_iterable([10, 20, 30], 0)
  ([], 3)
  >>> limited_iterable([10, 20, 30], -1)
  ([], 4) # Overflow counter counts the negative limit even though it makes no sense, for predictability reasons
  ```
  ---

  ### When there's no len method on the passed in iterable (eg. generator) and the iterable takes longer than the limit the overflow returned is -1**

  ```py
  >>> limited_iterable(range(10), 7)
  ([0, 1, 2, 3, 4, 5, 6], 3) # range by itself has len support even though it's a generator due to comparing boundaries
  >>> limited_iterable(iter(range(10)), 7)
  ([0, 1, 2, 3, 4, 5, 6], -1) # when wrapped into an iter() the length can't be determined within the limit
  >>> limited_iterable(iter(range(5)), 7)
  ([0, 1, 2, 3, 4], 0) # 0 was returned even though it's a non-len object because the iterator was exhausted and the end of it being within limit could be determined.
  ```
  """
  if limit <= 0:
    return ([], len(it) - limit if able(len, it) else -1)
  list_ = []
  breaknext = False
  for i, item in enumerate(it):
    if breaknext:
      return breaknext
    list_.append(item)
    if i + 2 > limit:
      overflow = len(it) - limit if able(len, it) else -1
      breaknext = (list_, overflow)
  return list_, 0


def hasmanyattrs(obj, attr: str, *attrs: str) -> bool:
  attrs = (attr, *attrs)

  for attr in attrs:
    if not hasattr(obj, attr):
      return False

    obj = getattr(obj, attr)

  return True


def getmanyattrs(obj, attr: str, *attrs: str) -> Any:
  attrs = (attr, *attrs)

  for attr in attrs:
    obj = getattr(obj, attr)

  return obj


def getattr_queue(
  obj: object,
  /,
  *queue: str,
  return_as_str: bool = False,
  default: Any | RaiseError = RaiseError,
):
  """## Try to access `obj`'s attrs in order.

  ```py
  queue = ('__qualname__', '__name__')

  getattr(obj, '__qualname__') # return this if hasattr(obj, '__qualname__') else go to the next line
  getattr(obj, '__name__')     # return this if hasattr(obj, '__name__') else go to the next line
  if default is not RaiseError # RaiseError is the default value for default (if nothing is passed in)
    return default
  raise KeyError("Unable to find any attrs from queue in obj.") # Nothing could be found, error raised.
  ```

  In other words: try to get name #1, if not found, get name #2, if not found get #3, etc. etc. until the queue is exhausted.
  """
  if not queue:
    raise ValueError('queue cannot be empty.')
  for name in queue:
    if hasmanyattrs(obj, *name.split('.')):
      if return_as_str:
        return name
      else:
        return getmanyattrs(obj, *name.split('.'))
  if default is not RaiseError:
    return default
  raise KeyError('Unable to find any attrs from queue in obj.')


def Or(arg: T, *args: Ts, none: Any = None) -> T | Ts:
  """### Returns the first element of the tuple (arg, *args) that does not equal the supplied (`none`) variable, by default None.

  This is different from or chaining because this does not check for truthy values but rather non-none values.\\
  Keep in mind, the passed in none may be unrelateed to built-in None however that is its default.
  """
  args = (arg, *args)

  for a in args:
    if a != none:
      return a

  return args[-1]
