import os
import re
import sys
from difflib import SequenceMatcher
from typing import Dict, Hashable, Iterable, List, Set, TypeVar

try:
    from typing import Protocol
except ImportError:
    Protocol = None

from .argparse_compat import argparse

__all__ = [
    "prompt",
    "yes_no_prompt",
    "b",
    "s",
    "cmp_compat",
    "PY3",
    "BooleanOptionalAction",
    "argparse",
    "raw_input",
    "re_escape",
    "slice_string_at_bytes",
    "len_in_bytes",
    "shlex_quote",
    "shlex_join",
    "resource_path",
    "group_by",
    "transitive_closure",
]

BooleanOptionalAction = argparse.BooleanOptionalAction

SCRIPT_DIRECTORY = os.path.dirname(os.path.realpath(__file__))

if sys.version_info < (3,):
    PY3 = False

    def b(x):
        return x

    def s(x):
        return x

else:
    PY3 = True

    def b(x) -> bytes:
        if x is not None:
            return x.encode()
        return x

    def s(x) -> str:
        # Sometimes we get str rather than bytes??? cf https://gitlab.com/coq/coq/-/jobs/544269051
        if hasattr(x, "decode"):
            return x.decode("utf-8", "ignore")
        return x

    def cmp(x, y):
        if x < y:
            return -1
        if y < x:
            return 1
        return 0

    raw_input = input

if sys.version_info < (3, 7):
    # see https://bugs.python.org/issue29995
    def re_escape(*args, **kwargs):
        ret = re.escape(*args, **kwargs)
        for ch in ':"':
            ret = ret.replace("\\" + ch, ch)
        return ret

else:
    re_escape = re.escape

cmp_compat = cmp

K = TypeVar("K")
V = TypeVar("V")
T = TypeVar("T")
TH = TypeVar("TH", bound=Hashable)


def prompt(
    options,
    case_sensitive=False,
    strip=True,
    sep="/",
    prefix="Please enter ",
    postfix=": ",
    yes_value=True,
    yes=False,
):
    msg = prefix + sep.join(i["display"] for i in options) + postfix
    if yes:
        print(msg)
        return yes_value
    else:
        response = raw_input(msg)
        while True:
            if not case_sensitive:
                response = response.lower()
            if strip:
                response = response.strip()
            for expected in options:
                if response in expected["values"]:
                    return expected["value"]
            print(
                'Response "%s" is not one of %s'
                % (
                    response,
                    ", ".join(repr(val) for i in options for val in i["values"]),
                )
            )
            response = raw_input(msg)


def yes_no_prompt(**kwargs):
    return prompt(
        (
            {"value": True, "display": "(y)es", "values": ("y", "yes")},
            {"value": False, "display": "(n)o", "values": ("n", "no")},
        ),
        yes_value=True,
        **kwargs,
    )


# Unfortunately, coqtop -emacs -time reports character locations in
# bytes, as does the glob file, so we need to handle unicode.  Here we
# write a generic slicer based on unicode locations that works across
# versions of python
def slice_string_at_bytes(string, start=None, end=None):
    string = b(string)
    if start is None:
        start = 0
    if end is None:
        end = len(string)
    return s(string[start:end])


def len_in_bytes(string):
    return len(b(string))


def normalize_newlines(string: str) -> str:
    return string.replace("\r\n", "\n").replace("\r", "\n")


# Terminal colors (maybe something cleverer needs to be done for other
# platforms).
class colors:
    ESC = "\033"
    # Escape code doesn't render on github so we use the standard escaped escape.
    # ESC = ''

    HEADER = ESC + "[95m"
    OKBLUE = ESC + "[94m"
    OKCYAN = ESC + "[96m"
    OKGREEN = ESC + "[92m"
    WARNING = ESC + "[93m"
    FAIL = ESC + "[91m"
    ENDC = ESC + "[0m"
    BOLD = ESC + "[1m"
    UNDERLINE = ESC + "[4m"


# Colors a string a given color
# Example usage: color("Hello World!", colors.OKBLUE)
def color(str, col, on=True):
    if not on:
        return str
    else:
        return col + str + colors.ENDC


if sys.version_info < (3, 3):
    import pipes

    shlex_quote = pipes.quote
else:
    import shlex

    shlex_quote = shlex.quote

if sys.version_info < (3, 8):
    shlex_join = lambda split_command: " ".join(
        shlex_quote(arg) for arg in split_command
    )
else:
    import shlex

    shlex_join = shlex.join


def resource_path(path):
    if os.path.exists(os.path.join(SCRIPT_DIRECTORY, path)):
        return os.path.join(SCRIPT_DIRECTORY, path)
    if sys.version_info < (3, 7):
        import pkg_resources

        return pkg_resources.resource_filename("coq_tools", path)
    else:
        import importlib.resources

        return importlib.resources.path("coq_tools", path)


def group_by_iter(ls, f):
    """
    Groups elements in a list based on a given condition.

    Args:
        ls (iterable): The list of elements to be grouped.
        f (function): The condition function used for grouping.

    Returns:
        iterable: A list of lists, where each inner list represents a group of elements.
    """
    it = iter(ls)
    prev = next(it)
    cur = [prev]
    for token in it:
        if f(prev, token):
            cur.append(token)
        else:
            yield cur
            cur = [token]
        prev = token
    yield cur


def group_by(ls, f):
    """
    Groups elements in a list based on a given condition.

    Args:
        ls (list): The list of elements to be grouped.
        f (function): The condition function used for grouping.

    Returns:
        list: A list of lists, where each inner list represents a group of elements.

    Example:
        >>> group_by([1, 2, 3, 4, 5, 7, 8, 10], lambda x, y: x + 1 == y)
        [[1, 2, 3, 4, 5], [7, 8], [10]]
    """
    return list(group_by_iter(ls, f))


def list_diff(
    old_strs: List[str], new_strs: List[str], *, autojunk: bool = False
) -> str:
    """
    >>> old = ["line1", "line2", "line3", "line4"]
    >>> new = ["line1", "line3", "line4", "line5"]
    >>> print(list_diff(old, new))
      line1
    - line2
      line3
      line4
    + line5
    """
    # Create a SequenceMatcher object
    sm = SequenceMatcher(None, old_strs, new_strs, autojunk=autojunk)
    opcodes = sm.get_opcodes()
    diff_lines = []

    # Threshold for eliding long equal blocks (if more than this many lines, collapse)
    ELIDE_THRESHOLD = 3

    for tag, i1, i2, j1, j2 in opcodes:
        if tag == "equal":
            block_length = i2 - i1
            if block_length > ELIDE_THRESHOLD:
                # Show the first line, an ellipsis, then the last line of the block.
                diff_lines.append("  " + old_strs[i1])
                diff_lines.append("  ...")
                diff_lines.append("  " + old_strs[i2 - 1])
            else:
                for i in range(i1, i2):
                    diff_lines.append("  " + old_strs[i])
        elif tag == "delete":
            # Lines in old_strs that were removed.
            for i in range(i1, i2):
                diff_lines.append("- " + old_strs[i])
        elif tag == "insert":
            # Lines added in new_strs.
            for j in range(j1, j2):
                diff_lines.append("+ " + new_strs[j])
        elif tag == "replace":
            # Show removed lines
            for i in range(i1, i2):
                diff_lines.append("- " + old_strs[i])
            # Then show inserted lines
            for j in range(j1, j2):
                diff_lines.append("+ " + new_strs[j])

    return "\n".join(diff_lines)


# if Protocol is not None:

#     class _TransitiveClosureDict(Generic[TH], Protocol):
#         def __getitem__(self, key: TH) -> Iterable[TH]: ...

#         def keys(self) -> Iterable[TH]: ...

#         def values(self) -> Iterable[Iterable[TH]]: ...

#     TransitiveClosureDict = _TransitiveClosureDict
# else:

#     class _TransitiveClosureDictOld(Generic[TH], Dict[TH, Iterable[TH]]):
#         pass

#     TransitiveClosureDict = _TransitiveClosureDictOld


def transitive_closure(
    graph: Dict[TH, Iterable[TH]],
) -> Dict[TH, Set[TH]]:
    """
    Computes the transitive closure of a directed graph represented as an adjacency list.

    The input is a dictionary where keys are nodes and values are iterables of neighboring nodes.
    The output is a dictionary where each key is a node and the value is the set of all reachable nodes
    from that key, including itself.

    This implementation uses iterative DFS for each starting node to handle large graphs and avoid recursion depth issues.
    """
    # Collect all nodes (keys and those appearing in values)
    all_nodes = set(graph.keys())
    for neighbors in graph.values():
        all_nodes.update(neighbors)

    def adj(node):
        # allow fills from __missing__
        try:
            return graph[node]
        except KeyError:
            return ()

    closure = {}
    for start in all_nodes:
        visited = set()
        stack = [start]
        while stack:
            node = stack.pop()
            if node not in visited:
                visited.add(node)
                for neighbor in adj(node):
                    if neighbor not in visited:
                        stack.append(neighbor)
        closure[start] = visited

    return closure


if __name__ == "__main__":
    # if we're working in Python 3.3, we can test this file
    try:
        import doctest

        success = True
    except ImportError:
        print(
            "This is not the main file to use.\nOnly run it if you have doctest (Python 3.3+) and are testing things."
        )
        success = False
    if success:
        doctest.testmod()
