import os.path
from io import SEEK_END, SEEK_SET, BufferedIOBase, RawIOBase, TextIOBase, TextIOWrapper
from mmap import mmap
from os import PathLike, fdopen
from sys import stdout
from typing import IO, Callable, Iterable, Iterator, Optional, TypeVar, Union, overload

from typing_extensions import Self

from ._files import PathType
from .callbacks import Progress
from .iter import consume, iter_equal, resizer
from .math import PosInfInt
from .ops import logical_implication, logical_xor

Data = TypeVar("Data", str, bytes)
AnyIoT = Union[BufferedIOBase, RawIOBase, TextIOBase, mmap]  # buffered/unbuffered binary/text
BufferedIoT = Union[BufferedIOBase, TextIOBase, mmap]
BufferedBinaryIoT = Union[BufferedIOBase, mmap]
BinaryIoT = Union[RawIOBase, BufferedIOBase]
TextIoT = TextIOBase
FILE_IO_BUFFER_SIZE = 1024 * 1024


class Empty(OSError):
    pass


def _check_arguments(mode: str, encoding: Optional[str] = None) -> Optional[str]:
    is_text = "t" in mode
    is_binary = "b" in mode

    if not logical_xor(is_text, is_binary):
        raise ValueError(f"Explicit text or binary mode required: {mode}")

    if not logical_implication(is_binary, encoding is None):
        raise ValueError("Encoding is not None for binary file")

    if is_text:
        encoding = encoding or "utf-8"

    return encoding


def _stripmode(mode: str) -> str:
    return "".join(set(mode) - {"t", "b"})


def _assert_buffered_seekable_reader(fp: BufferedIoT) -> None:
    if not fp.readable or not fp.seekable:
        raise TypeError("fp need to be a buffered, readable and seekable IO type")


def read_file(
    path: PathType, mode: str = "b", encoding: Optional[str] = None, errors: Optional[str] = None
) -> Union[str, bytes]:
    """Reads and returns whole file. If content is not needed use consume_file()"""

    encoding = _check_arguments(mode, encoding)

    if "r" not in mode:
        mode = "r" + mode

    with open(path, mode, encoding=encoding, errors=errors) as fr:
        return fr.read()  # buffered


def write_file(
    data: Union[str, bytes],
    path: PathType,
    mode: str = "wb",
    encoding: Optional[str] = None,
    errors: Optional[str] = None,
) -> None:
    """Writes file."""

    encoding = _check_arguments(mode, encoding)

    with open(path, mode, encoding=encoding, errors=errors) as fw:
        fw.write(data)  # buffered


@overload
def read_or_raise(fin: BufferedBinaryIoT, size: int) -> bytes: ...


@overload
def read_or_raise(fin: TextIoT, size: int) -> str: ...


def read_or_raise(fin, size):
    """Instead of returning a result smaller than `size` like `io.read()`,
    it raises and EOFError.
    """

    data = fin.read(size)
    if len(data) == 0:  # fixme: add `and size != 0`?
        raise Empty
    elif len(data) != size:
        raise EOFError

    return data


def get_file_range(path: PathType, start: int, size: int) -> bytes:
    with open(path, "rb") as fp:
        fp.seek(start)
        return fp.read(size)  # buffered


def truncate_file(path: PathType, size: int) -> None:
    with open(path, "r+b") as fp:
        fp.truncate(size)


def wrap_text(bf, mode, encoding, errors, newline):
    if "t" in mode:
        tf = TextIOWrapper(bf, encoding, errors, newline)
        tf.mode = mode
        return tf
    return bf


def copen(
    file: Union[PathType, IO, int],
    mode: str = "rt",
    archive_file: Optional[str] = None,
    encoding: Optional[str] = None,
    errors: Optional[str] = None,
    newline: Optional[str] = None,
    compresslevel: int = 9,
    ext: Optional[str] = None,
    handle_archives: bool = True,
) -> IO:
    """Generic file open method. It supports transparent compression and improved text-mode handling.

    `file`: Can be a path, file-like or file descriptor.
    `mode`, `errors` and `newline`: see `io.open`
    `archive_file`: if a zip file is opened, this specifies the file within the archive.
    `encoding`: if `None` it defaults to "utf-8" in text-mode. It doesn't use any locale.
    `compresslevel`: 0-9, 0: no compression, 1: least, 9: highest compression.
            Only used if a compressed format is specified.
    `ext`: if `file` is a file-like object, than this can be one of {".gz", ".bz2", ".zip"}
            to enable transparent compression
    `handle_archives`: Allow transparent handling of archives. Defaults to `True`.
        If `archive_file` is not given for archives which require it, the archive file will be treated as a normal file.

    Returns a file-like object. If `file` was a fd,
            it will be closed when the file-like object is closed.
    """

    encoding = _check_arguments(mode, encoding)

    if archive_file is not None and handle_archives is False:
        raise ValueError("handle_archives must be True to allow archive_file to be used")

    if isinstance(file, (str, PathLike)):
        ext = os.path.splitext(file)[1].lower()
    elif isinstance(file, int):
        file = fdopen(file, mode, encoding=encoding, errors=errors, newline=newline)
        ext = ext and ext.lower()
    else:
        ext = ext and ext.lower()

    if handle_archives:
        if ext == ".gz":
            import gzip

            return gzip.open(file, mode, compresslevel=compresslevel, encoding=encoding, errors=errors, newline=newline)

        elif ext == ".bz2":
            import bz2

            return bz2.open(file, mode, compresslevel=compresslevel, encoding=encoding, errors=errors, newline=newline)

        elif ext == ".zip" and archive_file:
            from zipfile import ZipFile

            newmode = _stripmode(mode)

            with ZipFile(
                file, newmode
            ) as zf:  # note: even if the outer zip file is closed, the inner file can still be read apparently
                bf = zf.open(archive_file, newmode, force_zip64=True)

            return wrap_text(bf, mode, encoding, errors, newline)

    if isinstance(file, TextIOBase):
        return file

    if hasattr(file, "read") or hasattr(file, "write"):  # file should be in binary mode here
        return wrap_text(file, mode, encoding, errors, newline)

    return open(file, mode, encoding=encoding, errors=errors, newline=newline)


class OpenFileAndDeleteOnError:
    """Context manager which opens a file using the same arguments as `open`,
    but deletes the file in case an exception occurs after opening.
    """

    def __init__(
        self,
        file: PathType,
        mode: str = "rt",
        encoding: Optional[str] = None,
        errors: Optional[str] = None,
        newline: Optional[str] = None,
        compresslevel: int = 9,
    ) -> None:
        encoding = _check_arguments(mode, encoding)

        self.file = file
        self.mode = mode
        self.encoding = encoding
        self.errors = errors
        self.newline = newline
        self.compresslevel = compresslevel
        self.fp: Optional[IO] = None

    def __enter__(self) -> IO:
        self.fp = copen(self.file, self.mode, None, self.encoding, self.errors, self.newline, self.compresslevel)
        return self.fp

    def __exit__(self, exc_type, exc_value, traceback):
        self.fp.close()
        if exc_type:
            # fixme: race condition
            # use https://stackoverflow.com/a/3594593 and `ReOpenFile` on Windows
            # or SetFileInformationByHandle
            # see also: https://nullprogram.com/blog/2016/08/07/
            os.remove(self.file)


class OptionalWriteOnlyFile:
    def __init__(
        self,
        path: Optional[PathType] = None,
        mode: str = "xb",
        encoding: Optional[str] = None,
        errors: Optional[str] = None,
        newline: Optional[str] = None,
        compresslevel: int = 9,
    ) -> None:
        if path:
            self.fp: Optional[IO] = copen(
                path, mode, encoding=encoding, errors=errors, newline=newline, compresslevel=compresslevel
            )
        else:
            self.fp = None

    def __enter__(self):
        if self.fp:
            return self.fp
        else:
            return self

    def close(self) -> None:
        if self.fp:
            self.fp.close()

    def __exit__(self, exc_type, exc_value, traceback):
        self.close()

    def write(self, data) -> Optional[int]:
        pass

    def seek(self, offset: int, whence: int = 0) -> int:
        pass


class StdoutFile:
    def __init__(
        self,
        path: Optional[PathType] = None,
        mode: str = "xb",
        encoding: Optional[str] = None,
        errors: Optional[str] = None,
        newline: Optional[str] = None,
        compresslevel: int = 9,
    ) -> None:
        encoding = _check_arguments(mode, encoding)

        if path:
            self.fp = copen(path, mode, encoding=encoding, errors=errors, newline=newline, compresslevel=compresslevel)
            self.doclose = True
        else:
            self.doclose = False
            if "b" in mode:
                self.fp = stdout.buffer
            elif "t" in mode:
                self.fp = stdout
            else:
                raise ValueError(f"Explicit text or binary mode required: {mode}")

    def __enter__(self):
        return self.fp

    def close(self) -> None:
        if self.doclose:
            self.fp.close()

    def __exit__(self, exc_type, exc_value, traceback):
        self.close()


class PathOrBinaryIO:
    def __init__(self, fname: Union[PathType, IO[bytes]], mode: str = "rb", close: bool = False) -> None:
        if isinstance(fname, (RawIOBase, BufferedIOBase)):
            self.doclose: bool = close
            self.fp: IO[bytes] = fname
        else:
            self.doclose = True
            self.fp = copen(fname, mode)

    def __enter__(self) -> IO[bytes]:
        return self.fp

    def close(self) -> None:
        if self.doclose:
            self.fp.close()

    def __exit__(self, exc_type, exc_value, traceback):
        self.close()


class PathOrTextIO:
    def __init__(
        self,
        fname: Union[PathType, IO[str]],
        mode: str = "rt",
        encoding: str = "utf-8",
        errors: str = "strict",
        newline: Optional[str] = None,
        close: bool = False,
    ) -> None:
        if isinstance(fname, TextIOBase):
            self.doclose: bool = close
            self.fp: IO[str] = fname
        else:
            self.doclose = True
            self.fp = copen(fname, mode, encoding=encoding, errors=errors, newline=newline)

    def __enter__(self) -> IO[str]:
        return self.fp

    def close(self) -> None:
        if self.doclose:
            self.fp.close()

    def __exit__(self, exc_type, exc_value, traceback):
        self.close()


class LastLineFile:
    chunk_size = 1024 * 4
    nl = "\n"

    def __init__(self, path, mode="rt+"):
        self.f = open(path, mode)  # buffered
        self.ll_pos = None

    def __enter__(self) -> Self:
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        self.close()

    def close(self):
        self.f.close()

    def _seek_to_last_line(self):
        if self.ll_pos is None:
            self.ll_pos = self._get_last_line_pos()

        self.f.seek(self.ll_pos, SEEK_SET)

    def _get_last_line_pos(self):
        self.f.seek(0, SEEK_END)
        pos = self.f.tell()
        ret_pos = 0
        while pos > 0:
            seek_next = max(pos - self.chunk_size, 0)
            self.f.seek(seek_next, SEEK_SET)
            chunk = self.f.read(pos - seek_next)
            fpos = chunk.rfind("\n")
            if fpos != -1:
                ret_pos = seek_next + fpos + 1
                if ret_pos != pos:
                    break
            pos = seek_next
        return ret_pos

    def _validate(self, s):
        if self.nl in s:
            raise ValueError("Newline in input")

    def read(self):
        self._seek_to_last_line()
        return self.f.read()

    def newline(self, s):
        self.f.seek(0, SEEK_END)
        ret = self.f.write(self.nl)
        self.ll_pos = self.f.tell()
        return ret

    def replace(self, s):
        self._validate(s)
        self._seek_to_last_line()
        ret = self.f.write(s)
        self.f.truncate()
        return ret


class Tell:
    def __init__(self, fp: BufferedIoT) -> None:
        try:
            assert not fp.seekable()
        except AttributeError:
            pass

        self._fp = fp
        self._pos = 0

    # wrapped

    def read(
        self, size=None
    ):  # the docs for `io.BufferedIOBase` say that size=-1, but that's not true for `HTTPResponse`
        ret = self._fp.read(size)
        self._pos += len(ret)
        return ret

    def read1(self, n=-1):
        ret = self._fp.read1(n)
        self._pos += len(ret)
        return ret

    def readall(self):
        ret = self._fp.readall()
        self._pos += len(ret)
        return ret

    def readline(self, limit=-1):
        ret = self._fp.readline(limit)
        self._pos += len(ret)
        return ret

    def readlines(self, hint=-1):
        raise NotImplementedError
        return self._fp.readlines(hint)

    def readinto(self, b):
        ret = self._fp.readinto(b)
        self._pos += ret
        return ret

    def readinto1(self, b):
        ret = self._fp.readinto1(b)
        self._pos += ret
        return ret

    def write(self, b):  # fixme: should this be added to _pos?
        return self._fp.write(b)

    def writelines(self, lines):  # fixme: should this be added to _pos?
        return self._fp.writelines(lines)

    def seek(self, offset, whence=SEEK_SET):
        raise OSError("Stream is not seekable")

    def tell(self):
        return self._pos

    # redirected

    def close(self):
        return self._fp.close()

    def detach(self):
        return self._fp.detach()

    def fileno(self):
        return self._fp.fileno()

    def flush(self):
        return self._fp.flush()

    def isatty(self):
        return self._fp.isatty()

    def readable(self):
        return self._fp.readable()

    def seekable(self):
        return self._fp.seekable()

    def truncate(self, size=None):
        return self._fp.truncate(size)

    def writable(self):
        return self._fp.writable()


from collections import deque
from urllib import response


class BufferedTell(response.addinfourl):  # fixme: untested!!!
    def __init__(self, filesize):
        # response.addinfourl.__init__(self, *args, **kwargs)
        self._pos = 0
        self._read = 0
        self.filesize = filesize
        self.buf = deque(maxlen=1024)

    def read(self, num=-1):
        if num == -1:
            num = self.filesize - self._pos
            print("num=-1")
        if self._pos == self._read:
            self._pos += num
            self._read += num
            data = response.addinfourl.read(num)
            self.buf.extend(data)
            return data
        elif self._pos < self._read:
            delta = self._read - self._pos
            self._pos += num
            if num <= delta:
                data = b"".join(self.buf.pop() for i in range(delta))
                self.buf.extend(data[::-1])
                return data[-1 : -1 - num : -1]
            else:
                datab = b"".join(self.buf.pop() for i in range(delta))
                datab = datab[::-1]
                self.buf.extend(datab)
                dataf = response.addinfourl.read(num - delta)
                self.buf.extend(dataf)
                return datab + dataf
        else:
            # return b""
            raise Exception("Unbuffered read")

    def tell(self):
        return self._pos

    def seek(self, offset, whence=0):
        if whence == 0:
            self._pos = offset
        elif whence == 1:
            self._pos += offset
        elif whence == 2:
            self._pos = self.filesize - offset
        else:
            raise ValueError("Unknown whence")


def copyfilelike(
    fin: AnyIoT,
    fout: BufferedIoT,
    amount: Optional[int] = None,
    buffer: int = FILE_IO_BUFFER_SIZE,
    report: Optional[Callable[[int, int], None]] = None,
) -> int:
    """Read data from `fin` in chunks of size `buffer` (or less) and write them to `fout`.
    Optionally limit the amount of data to `amount`. `report` can be a callable which receives
    the total number of bytes copied and bytes remaining.
    Input and Output streams but both be either binary or both text.
    see `shutil.copyfileobj`
    """

    # todo: have different input and output buffers

    _amount = amount or PosInfInt

    copied = 0
    while _amount > 0:
        if report:
            report(copied, _amount + copied)

        data = fin.read(min(buffer, _amount))  # buffered or unbuffered

        if not data:
            break
        fout.write(data)
        _amount -= len(data)
        copied += len(data)
    return copied


def simple_file_iter(fr: IO[Data], chunk_size: int = FILE_IO_BUFFER_SIZE) -> Iterator[Data]:
    """Iterate file-like object `fr` and yield chunks of size `chunk_size` (or less in unbuffered mode)."""

    assert isinstance(chunk_size, int), "chunk_size needs to be an integer"

    # return iter(partial(fr.read, chunk_size), "") sentinel cannot be defined well
    while True:
        data = fr.read(chunk_size)
        if data:
            yield data
        else:
            break


def reversed_file_iter(fp: BufferedBinaryIoT, chunk_size: int = FILE_IO_BUFFER_SIZE) -> Iterator[bytes]:
    """Generate blocks of file's contents in reverse order."""

    _assert_buffered_seekable_reader(fp)

    fp.seek(0, SEEK_END)
    here = fp.tell()
    while here > 0:
        delta = min(chunk_size, here)
        here -= delta
        fp.seek(here, SEEK_SET)
        yield fp.read(delta)


def limited_file_iter(fr: IO[Data], amount: int, chunk_size: int = FILE_IO_BUFFER_SIZE) -> Iterator[Data]:
    """Iterate file-like object `fr` and yield chunks of size `chunk_size`.
    Limit output to `amount` bytes.
    """

    while amount > 0:
        data = fr.read(min(chunk_size, amount))
        if not data:
            break
        yield data
        amount -= len(data)


def iterfilelike(
    fr: IO[Data], start: int = 0, amount: Optional[int] = None, chunk_size: int = FILE_IO_BUFFER_SIZE
) -> Iterable[Data]:
    """Iterate file-like object `fr` and yield chunks of size `chunk_size`.
    Starts reading at `start` and optionally limit output to `amount` bytes.
    """

    if start:
        fr.seek(start)

    if amount is None:
        return simple_file_iter(fr, chunk_size)
    else:
        return limited_file_iter(fr, amount, chunk_size)


def blockfileiter(
    path: PathType,
    mode: str = "rb",
    buffering: int = -1,
    encoding: Optional[str] = None,
    errors: Optional[str] = None,
    start: int = 0,
    amount: Optional[int] = None,
    chunk_size: int = FILE_IO_BUFFER_SIZE,
    opener=None,
) -> Iterator[Union[str, bytes]]:
    """Iterate over file at `path` and yield chunks of size `chunk_size` (or less in unbuffered mode).
    Optionally limit output to `amount` bytes.
    """

    encoding = _check_arguments(mode, encoding)

    with open(path, mode, buffering, encoding, errors=errors, opener=opener) as fr:
        yield from iterfilelike(fr, start, amount, chunk_size)


def blockfilesiter(paths: Iterable[PathType], chunk_size: int = FILE_IO_BUFFER_SIZE) -> Iterator[bytes]:
    """Iterate over a list of files and return their contents in a
    continuous stream of chunks of size `chunk_size`.
    That means one chunk can span multiple files.
    """

    chunk = b""

    for path in paths:
        with open(path, "rb") as fr:
            if len(chunk) != 0:
                data = fr.read(chunk_size - len(chunk))  # buffered
                chunk += data  # fixme: probably slow for lots of small files

            if len(chunk) == chunk_size:
                yield chunk
                chunk = b""

            if len(chunk) == 0:
                for data in iterfilelike(fr, chunk_size=chunk_size):
                    if len(data) == chunk_size:
                        yield data
                    else:  # must be the last chunk in buffered mode
                        chunk = data

    if chunk:
        yield chunk


def bufferedfileiter(
    path: PathType,
    chunk_size: int,
    mode: str = "rb",
    buffering: int = -1,
    encoding: Optional[str] = None,
    errors: Optional[str] = None,
    start: int = 0,
    amount: Optional[int] = None,
    buffer_size: int = FILE_IO_BUFFER_SIZE,
) -> Iterable[Union[str, bytes]]:
    """Iterate over file at `path` reading chunks of size `buffer_size` at a time.
    Yields data chunks of `chunk_size` and optionally limits output to `amount` bytes.
    """

    return resizer(blockfileiter(path, mode, buffering, encoding, errors, start, amount, buffer_size), chunk_size)


def byte_out(path: PathType, buffer_size: int = FILE_IO_BUFFER_SIZE) -> Iterable[bytes]:
    """Reads file at `path` byte by byte, using a read buffer of size `buffer_size`."""

    return resizer(blockfileiter(path, "rb", chunk_size=buffer_size), 1)


def consume_file(filename: PathType, buffer_size: int = FILE_IO_BUFFER_SIZE) -> None:
    """reads whole file but ignores content"""

    consume(blockfileiter(filename, "rb", chunk_size=buffer_size))


# was: same_files, textfile_equal: equal_files(*paths, mode="rt")
def equal_files(
    *paths: PathType,
    mode: str = "rb",
    encoding: Optional[str] = None,
    errors: Optional[str] = None,
    amount: Optional[int] = None,
    chunk_size: int = FILE_IO_BUFFER_SIZE,
) -> bool:
    """Check if files at `*paths` are equal. Chunks of size `chunk_size` are read at a time.
    Data can be optionally limited to `amount`.
    """

    # blockfileiter must be used in buffered mode, since the chunks can be of different size
    its = tuple(
        blockfileiter(path, mode, encoding=encoding, errors=errors, amount=amount, chunk_size=chunk_size)
        for path in paths
    )
    return iter_equal(*its)


def is_all_byte(fr: BinaryIoT, thebyte: bytes = b"\0", chunk_size: int = FILE_IO_BUFFER_SIZE) -> bool:
    """Test if file-like `fr` consists only of `thebyte` bytes."""

    assert isinstance(thebyte, bytes)

    thebyte = thebyte * chunk_size
    for data in simple_file_iter(fr, chunk_size):
        if data != thebyte[: len(data)]:
            return False
    return True


def iter_lines(
    path: PathType,
    encoding: str = "utf-8",
    errors: str = "strict",
    newline: Optional[str] = None,
    progress: Optional[Progress] = None,
) -> Iterator[str]:
    """Yield lines from text file. Handles compressed files as well.
    Use `progress` for progress reporting.
    """

    with copen(path, "rt", encoding=encoding, errors=errors, newline=newline) as fr:
        if progress is not None:
            prev_pos = 0

            fr.seek(0, os.SEEK_END)
            total = fr.tell()
            fr.seek(0, os.SEEK_SET)

            with progress.task(total=total) as task:
                while True:  # cannot use `for line in fr` here
                    line = fr.readline()
                    if not line:
                        break
                    cur_pos = fr.tell()
                    task.advance(cur_pos - prev_pos)
                    yield line
                    prev_pos = cur_pos
        else:
            for line in fr:
                yield line


def iter_stripped(
    path: PathType, encoding: str = "utf-8", errors: str = "strict", newline: Optional[str] = None
) -> Iterator[str]:
    """Yield lines from text file with trailing newlines stripped.
    Handles compressed files as well.
    """

    with copen(path, "rt", encoding=encoding, errors=errors, newline=newline) as fr:
        for line in fr:
            yield line.rstrip("\r\n")


# is this still needed?
def file_byte_reader(
    filename: PathType, inputblocksize: int, outputblocksize: int, DEBUG: bool = True
) -> Iterator[bytes]:
    assert (inputblocksize % outputblocksize == 0) or (
        outputblocksize % inputblocksize == 0
    ), "Neither input nor output size is a multiple of the other"

    bytes_yielded = 0
    bytes = bytearray(max(inputblocksize, outputblocksize))
    bytes_used = 0
    for read in blockfileiter(filename, chunk_size=inputblocksize):
        bytes[bytes_used : bytes_used + len(read)] = read
        # print("read {} bytes to pos [{}:{}]".format(len(read),bytes_used,bytes_used+len(read)))
        bytes_used += len(read)
        pos = 0
        while bytes_used >= outputblocksize:
            yield bytes[pos : pos + outputblocksize]
            bytes_yielded += outputblocksize
            # print("yielded {} bytes from pos [{}:{}]".format(outputblocksize,pos,pos+outputblocksize))
            pos += outputblocksize
            bytes_used -= outputblocksize
    if DEBUG:
        import os.path

        filesize = os.path.getsize(filename)
        if filesize != bytes_yielded:
            print(f"{bytes_yielded} bytes yielded, filesize: {filesize}")
