import difflib
import re
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass

from mirage.commands.builtin.diff_helper import _ed_script, _normal_diff
from mirage.commands.builtin.utils.lines import split_lines_keepends
from mirage.commands.config import CommandOpts
from mirage.commands.errors import UsageError
from mirage.commands.spec import SPECS
from mirage.commands.spec.types import CommandName, FlagValue, FlagView
from mirage.commands.spec.usage import extra_operand_error
from mirage.io.types import ByteSource, IOResult
from mirage.types import FileStat, FileType, PathSpec
from mirage.utils.errors import FS_ERRORS, format_fs_error
from mirage.utils.key_prefix import rekey
from mirage.utils.path import gnu_basename


@dataclass(frozen=True, slots=True)
class _DiffFlags:
    i: bool = False
    w: bool = False
    b: bool = False
    e: bool = False
    u: bool = False
    q: bool = False


def _child_spec(parent: PathSpec, name: str) -> PathSpec:
    child = parent.virtual.rstrip("/") + "/" + name
    return PathSpec(virtual=child,
                    directory=child,
                    resource_path=rekey(parent.virtual, parent.resource_path,
                                        child))


async def _diff_pair(
    path1: PathSpec,
    path2: PathSpec,
    read_bytes: Callable[..., Awaitable[bytes]],
    flags: _DiffFlags,
) -> bytes:
    name1 = path1.virtual
    name2 = path2.virtual
    text_a = (await read_bytes(path1)).decode(errors="replace")
    text_b = (await read_bytes(path2)).decode(errors="replace")
    if flags.i:
        text_a = text_a.lower()
        text_b = text_b.lower()
    if flags.w:
        text_a = re.sub(r"\s+", "", text_a)
        text_b = re.sub(r"\s+", "", text_b)
    if flags.b:
        text_a = re.sub(r"[ \t]+", " ", text_a)
        text_b = re.sub(r"[ \t]+", " ", text_b)
    if flags.q:
        if text_a != text_b:
            return f"Files {name1} and {name2} differ\n".encode()
        return b""
    a_lines = split_lines_keepends(text_a)
    b_lines = split_lines_keepends(text_b)
    if flags.e:
        result = _ed_script(a_lines, b_lines)
    elif flags.u:
        result = list(
            difflib.unified_diff(a_lines,
                                 b_lines,
                                 fromfile=name1,
                                 tofile=name2))
    else:
        result = _normal_diff(a_lines, b_lines)
    return "".join(result).encode()


async def _diff_dirs(
    dir_a: PathSpec,
    dir_b: PathSpec,
    read_bytes: Callable[..., Awaitable[bytes]],
    readdir_fn: Callable[..., Awaitable[list[str]]],
    stat_fn: Callable[..., Awaitable[FileStat]],
    flags: _DiffFlags,
) -> bytes:
    raw_a = await readdir_fn(dir_a)
    raw_b = await readdir_fn(dir_b)
    names_a = {gnu_basename(entry) for entry in raw_a}
    names_b = {gnu_basename(entry) for entry in raw_b}
    left = dir_a.virtual.rstrip("/")
    right = dir_b.virtual.rstrip("/")
    parts: list[bytes] = []
    for name in sorted(names_a | names_b):
        if name not in names_b:
            parts.append(f"Only in {left}: {name}\n".encode())
            continue
        if name not in names_a:
            parts.append(f"Only in {right}: {name}\n".encode())
            continue
        child_a = _child_spec(dir_a, name)
        child_b = _child_spec(dir_b, name)
        a_dir = (await stat_fn(child_a)).type == FileType.DIRECTORY
        b_dir = (await stat_fn(child_b)).type == FileType.DIRECTORY
        if a_dir and b_dir:
            parts.append(await _diff_dirs(child_a, child_b, read_bytes,
                                          readdir_fn, stat_fn, flags))
        elif not a_dir and not b_dir:
            body = await _diff_pair(child_a, child_b, read_bytes, flags)
            if body:
                if flags.q:
                    parts.append(body)
                else:
                    header = f"diff -r {child_a.virtual} {child_b.virtual}\n"
                    parts.append(header.encode() + body)
        elif a_dir:
            parts.append((f"File {child_a.virtual} is a directory while file "
                          f"{child_b.virtual} is a regular file\n").encode())
        else:
            parts.append(
                (f"File {child_a.virtual} is a regular file while file "
                 f"{child_b.virtual} is a directory\n").encode())
    return b"".join(parts)


async def diff(
    paths: list[PathSpec],
    *,
    read_bytes: Callable[..., Awaitable[bytes]],
    readdir_fn: Callable[..., Awaitable[list[str]]],
    stat_fn: Callable[..., Awaitable[FileStat]],
    i: bool = False,
    w: bool = False,
    b: bool = False,
    e: bool = False,
    u: bool = False,
    q: bool = False,
    r: bool = False,
) -> tuple[ByteSource | None, IOResult]:
    if len(paths) > 2:
        raise extra_operand_error(CommandName.DIFF, paths[2].raw_path)
    if len(paths) < 2:
        raise UsageError("diff: requires two paths")
    flags = _DiffFlags(i=i, w=w, b=b, e=e, u=u, q=q)
    try:
        both_dirs = False
        if r:
            both_dirs = ((await stat_fn(paths[0])).type == FileType.DIRECTORY
                         and
                         (await stat_fn(paths[1])).type == FileType.DIRECTORY)
        if both_dirs:
            output = await _diff_dirs(paths[0], paths[1], read_bytes,
                                      readdir_fn, stat_fn, flags)
        else:
            output = await _diff_pair(paths[0], paths[1], read_bytes, flags)
    except FS_ERRORS as exc:
        # GNU diff reserves exit 1 for "files differ"; trouble (a missing
        # or unreadable operand) is exit 2.
        return None, IOResult(exit_code=2,
                              stderr=format_fs_error("diff", exc, paths))
    exit_code = 1 if output else 0
    return output, IOResult(exit_code=exit_code,
                            cache=[paths[0].mount_path, paths[1].mount_path])


__all__ = ["diff"]


@dataclass(frozen=True, slots=True)
class DiffFlags:
    ignore_case: bool = False
    ignore_all_space: bool = False
    ignore_space_change: bool = False
    ed: bool = False
    unified: bool = False
    brief: bool = False
    recursive: bool = False


def parse_flags(flags: Mapping[str, FlagValue]) -> DiffFlags:
    fl = FlagView(flags, spec=SPECS["diff"])
    return DiffFlags(
        ignore_case=fl.as_bool("i"),
        ignore_all_space=fl.as_bool("w"),
        ignore_space_change=fl.as_bool("b"),
        ed=fl.as_bool("e"),
        unified=fl.as_bool("u"),
        brief=fl.as_bool("q"),
        recursive=fl.as_bool("r"),
    )


async def diff_generic(
    paths: list[PathSpec],
    texts: list[str],
    opts: CommandOpts,
    read_bytes: Callable[..., Awaitable[bytes]],
    readdir_fn: Callable[..., Awaitable[list[str]]],
    stat_fn: Callable[..., Awaitable[FileStat]],
) -> tuple[ByteSource | None, IOResult]:
    parsed = parse_flags(opts.flags)
    return await diff(paths,
                      read_bytes=read_bytes,
                      readdir_fn=readdir_fn,
                      stat_fn=stat_fn,
                      i=parsed.ignore_case,
                      w=parsed.ignore_all_space,
                      b=parsed.ignore_space_change,
                      e=parsed.ed,
                      u=parsed.unified,
                      q=parsed.brief,
                      r=parsed.recursive)
