import asyncio
import os
import sys
import time
from concurrent.futures.thread import ThreadPoolExecutor
from pathlib import Path
from typing import Sequence

import pytest
import wait_for2
from clarabot_nano_common.utility.path.common import normalize_absolute_path

from clarabot_nano.common.filesystem.operation.base import AbstractIOQueueOperation
from clarabot_nano.common.filesystem.operation.copy import CopyOperation
from clarabot_nano.common.filesystem.operation.delete import DeleteOperation
from clarabot_nano.common.filesystem.operation.mkdir import MkdirOperation
from clarabot_nano.common.filesystem.operation.rename import RenameOperation
from clarabot_nano.common.filesystem.operation.replace import ReplaceOperation
from clarabot_nano.common.filesystem.operation.write import WriteTruncateOperation, SeekWriteOperation
from clarabot_nano.common.filesystem.tx_queue import FilesystemTxQueue, TokenLockError
from clarabot_nano_tests.testing.file import temp_path


class _TestingFilesystemTxQueue(FilesystemTxQueue):
    def empty(self):
        return (
            not self._locks
            and not self._ops_by_account
            and not self._tracker._affects_upstream
            and not self._tracker._affects_downstream
        )


class _TestingOperation(AbstractIOQueueOperation):
    OPERATION_KEY = "_TEST"

    def __init__(self, account_id: int, path: str, cycles: int):
        AbstractIOQueueOperation.__init__(self, account_id)
        self.path = path
        self.cycles = cycles

    def paths(self) -> Sequence[str]:
        return (self.path,)

    def execute(self):
        for _ in range(self.cycles):
            time.sleep(1)


@pytest.fixture
async def fstx_queue():
    with ThreadPoolExecutor(thread_name_prefix="TestP-") as pool:
        tx_queue = _TestingFilesystemTxQueue(pool)
        try:
            yield tx_queue
        finally:
            await tx_queue.close()


@pytest.mark.asyncio
async def test_server_fstx_queue_ops(tmpdir, fstx_queue: _TestingFilesystemTxQueue):
    r = Path(normalize_absolute_path(temp_path(tmpdir)))
    r.mkdir()

    # mkdir
    dir_path0 = r / "dir0"
    assert not dir_path0.exists()
    assert fstx_queue.empty()
    async with fstx_queue.wait_ready(MkdirOperation(0, dir_path0.as_posix())):
        assert not fstx_queue.empty()
        assert not dir_path0.exists()  # test not submitting
    assert not fstx_queue.empty()
    await asyncio.sleep(0.05)
    assert fstx_queue.empty()
    assert not dir_path0.exists()

    async def _mkdir_check(path_: Path, ex_before=True, ex_after=True, submit_exc=None, **kwargs):
        assert path_.exists() is ex_before
        async with fstx_queue.wait_ready(MkdirOperation(0, path_.as_posix(), **kwargs)) as submitter_:
            assert not fstx_queue.empty()
            assert path_.exists() is ex_before
            if submit_exc:
                with pytest.raises(submit_exc):
                    await submitter_()
            else:
                await submitter_()
            assert fstx_queue.empty()
            assert path_.is_dir() is ex_after
        assert fstx_queue.empty()
        assert path_.is_dir() is ex_after

    await _mkdir_check(dir_path0, ex_before=False)
    await _mkdir_check(dir_path0, submit_exc=FileExistsError)
    await _mkdir_check(dir_path0, parents=True, submit_exc=FileExistsError)
    await _mkdir_check(dir_path0, exist_ok=True)
    await _mkdir_check(dir_path0, parents=True, exist_ok=True)
    dir_path0_1_2 = dir_path0 / "dir1" / "dir2"
    await _mkdir_check(dir_path0_1_2, ex_before=False, ex_after=False, submit_exc=FileNotFoundError)
    await _mkdir_check(dir_path0_1_2, exist_ok=True, ex_before=False, ex_after=False, submit_exc=FileNotFoundError)
    await _mkdir_check(dir_path0_1_2, parents=True, ex_before=False)
    await _mkdir_check(dir_path0_1_2, parents=True, submit_exc=FileExistsError)
    await _mkdir_check(dir_path0_1_2, exist_ok=True)
    await _mkdir_check(dir_path0_1_2, parents=True, exist_ok=True)
    dir_path0_f = dir_path0 / "file"
    dir_path0_f.touch()
    await _mkdir_check(dir_path0_f, exist_ok=True, ex_after=False, submit_exc=FileExistsError)
    dir_path0_f_3 = dir_path0_f / "dir3"
    file_exc = (FileNotFoundError, FileExistsError) if sys.platform.startswith("win") else NotADirectoryError
    await _mkdir_check(dir_path0_f_3, ex_before=False, ex_after=False, submit_exc=file_exc)
    await _mkdir_check(dir_path0_f_3, parents=True, ex_before=False, ex_after=False, submit_exc=file_exc)
    await _mkdir_check(dir_path0_f_3, exist_ok=True, ex_before=False, ex_after=False, submit_exc=file_exc)
    await _mkdir_check(dir_path0_f_3, parents=True, exist_ok=True, ex_before=False, ex_after=False, submit_exc=file_exc)

    assert fstx_queue.empty()
    t_path0 = r / "temp0"
    f_path0 = r / "file0"
    content0 = os.urandom(17)
    content1 = os.urandom(15)
    content2 = os.urandom(13)
    # write
    assert not t_path0.exists()
    assert not f_path0.exists()
    async with fstx_queue.wait_ready(
        WriteTruncateOperation(0, t_path0.as_posix(), f_path0.as_posix(), content0)
    ) as submitter:
        await submitter()
    assert t_path0.is_file()
    assert not f_path0.exists()
    assert t_path0.read_bytes() == content0
    async with fstx_queue.wait_ready(
        SeekWriteOperation(0, t_path0.as_posix(), f_path0.as_posix(), content1, len(content0))
    ) as submitter:
        await submitter()
    assert t_path0.is_file()
    assert not f_path0.exists()
    assert t_path0.read_bytes() == content0 + content1
    async with fstx_queue.wait_ready(
        SeekWriteOperation(0, t_path0.as_posix(), f_path0.as_posix(), content2, len(content0))
    ) as submitter:
        await submitter()
    assert t_path0.is_file()
    assert not f_path0.exists()
    assert t_path0.read_bytes() == content0 + content2 + content1[13:]
    async with fstx_queue.wait_ready(
        WriteTruncateOperation(0, t_path0.as_posix(), f_path0.as_posix(), content0 + content1)
    ) as submitter:
        await submitter()
    assert t_path0.is_file()
    assert not f_path0.exists()
    assert t_path0.read_bytes() == content0 + content1

    assert fstx_queue.empty()
    # replace
    async with fstx_queue.wait_ready(ReplaceOperation(0, t_path0.as_posix(), f_path0.as_posix())) as submitter:
        await submitter()
    assert not t_path0.exists()
    assert f_path0.is_file()
    assert f_path0.read_bytes() == content0 + content1

    assert fstx_queue.empty()
    # rename
    async with fstx_queue.wait_ready(RenameOperation(0, f_path0.as_posix(), dir_path0.as_posix())) as submitter:
        await submitter()
    assert not t_path0.exists()
    assert not f_path0.exists()
    dirf_path0 = dir_path0 / f_path0.name
    assert dirf_path0.is_file()
    dirf_path1 = dir_path0 / "asd0"
    async with fstx_queue.wait_ready(RenameOperation(0, dirf_path0.as_posix(), dirf_path1.as_posix())) as submitter:
        await submitter()
    assert not t_path0.exists()
    assert not f_path0.exists()
    assert not dirf_path0.exists()
    assert dirf_path1.is_file()
    dir_path1 = r / "dir1"
    async with fstx_queue.wait_ready(RenameOperation(0, dir_path0.as_posix(), dir_path1.as_posix())) as submitter:
        await submitter()
    assert not t_path0.exists()
    assert not f_path0.exists()
    assert not dirf_path0.exists()
    assert not dirf_path1.exists()
    dirf_path2 = dir_path1 / dirf_path1.name
    assert dirf_path2.is_file()

    assert fstx_queue.empty()
    # copy
    async with fstx_queue.wait_ready(CopyOperation(0, dirf_path2.as_posix(), f_path0.as_posix())) as submitter:
        await submitter()
    assert not t_path0.exists()
    assert f_path0.is_file()
    assert not dirf_path0.exists()
    assert not dirf_path1.exists()
    assert dirf_path2.is_file()
    async with fstx_queue.wait_ready(CopyOperation(0, dir_path1.as_posix(), dir_path0.as_posix())) as submitter:
        await submitter()
    assert not t_path0.exists()
    assert f_path0.is_file()
    assert not dirf_path0.exists()
    assert dirf_path1.is_file()
    assert dirf_path2.is_file()

    assert fstx_queue.empty()
    # delete
    async with fstx_queue.wait_ready(DeleteOperation(0, dir_path1.as_posix())) as submitter:
        await submitter()
    assert not t_path0.exists()
    assert f_path0.is_file()
    assert not dirf_path0.exists()
    assert not dir_path1.exists()
    assert dirf_path1.is_file()
    assert not dirf_path2.exists()
    async with fstx_queue.wait_ready(DeleteOperation(0, f_path0.as_posix())) as submitter:
        await submitter()
    assert not t_path0.exists()
    assert not f_path0.exists()
    assert not dirf_path0.exists()
    assert not dir_path1.exists()
    assert dirf_path1.is_file()
    assert not dirf_path2.exists()
    async with fstx_queue.wait_ready(DeleteOperation(0, dirf_path1.as_posix())) as submitter:
        await submitter()
    assert not t_path0.exists()
    assert not f_path0.exists()
    assert not dirf_path0.exists()
    assert not dir_path1.exists()
    assert not dirf_path1.exists()
    assert not dirf_path2.exists()
    assert dir_path0.is_dir()

    assert fstx_queue.empty()


class _TestingSeekWriteOperation(SeekWriteOperation):
    def execute(self):
        SeekWriteOperation.execute(self)
        with open(self.path, "rb") as f:
            return f.read()


@pytest.mark.asyncio
async def test_server_fstx_queue_locking(tmpdir, fstx_queue: _TestingFilesystemTxQueue):
    r = Path(normalize_absolute_path(temp_path(tmpdir)))
    r.mkdir()
    t_path = r / "tmp"
    f_path = r / "fin"

    test_init_future = asyncio.get_running_loop().create_future()
    t_path.touch()  # file must exist for parallel writes

    parts = [os.urandom(19) for _ in range(7)]
    reads = []

    async def parallel_writes(account, t_path_, f_path_, offset_, content):
        async with fstx_queue.wait_ready(
            _TestingSeekWriteOperation(account, t_path_.as_posix(), f_path_.as_posix(), content, offset_)
        ) as submitter:
            await test_init_future  # make all wait and set lock-wait
            reads.append(await submitter())

    tasks = []
    offset = 0
    for i, part in enumerate(parts):
        tasks.append(asyncio.create_task(parallel_writes(i, t_path, f_path, offset, part)))
        offset += len(part)

    test_init_future.set_result(True)
    await asyncio.gather(*tasks)
    assert fstx_queue.empty()

    assert t_path.read_bytes() == b"".join(parts)
    assert len(reads) == len(parts)
    assert reads[0] == parts[0]
    assert reads[-1] == b"".join(parts)
    assert len(set(reads)) == len(parts)


@pytest.mark.asyncio
async def test_server_fstx_queue_timeout_cancel(fstx_queue: _TestingFilesystemTxQueue):
    async with fstx_queue.wait_ready(_TestingOperation(0, "asdf", 4)) as submitter:
        try:
            await wait_for2.wait_for(submitter(), 1.5)
        except asyncio.TimeoutError:
            pass
    assert not fstx_queue.empty()


@pytest.mark.asyncio
async def test_server_fstx_queue_errors(tmpdir, fstx_queue: _TestingFilesystemTxQueue):
    r = Path(normalize_absolute_path(temp_path(tmpdir)))
    r.mkdir()
    path = r / "tmp"
    path2 = r / "tmp2"
    path.mkdir()

    op = DeleteOperation(0, path.as_posix())
    op2 = DeleteOperation(0, path2.as_posix())

    try:
        async with fstx_queue.wait_ready(op, paths=op.paths()):
            pass
    except RuntimeError as e:
        assert e.args[0] == "Do not provide operation and paths both."
    else:
        assert False
    try:
        async with fstx_queue.wait_ready():
            pass
    except AttributeError as e:
        assert e.args[0] == "'NoneType' object has no attribute 'paths'"
    else:
        assert False

    async with fstx_queue.wait_ready(paths=op2.paths()) as submitter:
        with pytest.raises(RuntimeError, match=r"Provide an operation at submit if the preparation has none\."):
            submitter()
        with pytest.raises(RuntimeError, match=r"Fs operation paths do not match prepared\."):
            submitter(op)

    async with fstx_queue.wait_ready(op) as submitter:
        with pytest.raises(RuntimeError, match=r"Do not provide an operation if the preparation has one\."):
            submitter(op)
        await submitter()
        with pytest.raises(RuntimeError, match=r"Fs operation already submitted\."):
            submitter()

    assert fstx_queue.empty()


@pytest.mark.asyncio
async def test_server_fstx_queue_ex_locking(tmpdir, fstx_queue: _TestingFilesystemTxQueue):
    r = Path(normalize_absolute_path(temp_path(tmpdir)))
    r.mkdir()

    r_d0 = (r / "d0").as_posix()
    r_d0_f0 = (r / "d0" / "f0").as_posix()
    r_d0_f1 = (r / "d0" / "f1").as_posix()
    r_d1 = (r / "d1").as_posix()
    r_d1_f0 = (r / "d1" / "f0").as_posix()
    r_d1_f1 = (r / "d1" / "f1").as_posix()

    assert fstx_queue.get_ex_lock_token(r_d0) is None
    t0 = "t0"
    t1 = "t1"
    fstx_queue.ex_lock(r_d0_f0, t0)
    assert fstx_queue.get_ex_lock_token(r_d0_f1) is None
    assert fstx_queue.get_ex_lock_token(r_d0_f0) == t0
    assert fstx_queue.get_ex_lock_token(r_d0) == t0
    assert fstx_queue.get_ex_lock_token(r_d1) is None
    until0 = fstx_queue._get_ex_lock_best(r_d0_f0).until
    time.sleep(0.1)
    fstx_queue.ex_lock(r_d0_f0, t0)  # refreshes
    assert fstx_queue._get_ex_lock_best(r_d0_f0).until > until0
    with pytest.raises(TokenLockError) as e:
        fstx_queue.ex_lock(r_d0_f0, t1)
    assert e.value.token == t0
    with pytest.raises(TokenLockError) as e:
        fstx_queue.ex_refresh_lock(r_d0_f0, t1)
    assert e.value.token == t0
    fstx_queue.ex_refresh_lock(r_d0_f0, t0)
    with pytest.raises(TokenLockError) as e:
        fstx_queue.ex_replace_lock(r_d0_f0, t1, "xx")
    assert e.value.token == t0
    with pytest.raises(TokenLockError) as e:
        fstx_queue.ex_replace_lock(r_d0, t1, "xx")
    assert e.value.token == t0
    with pytest.raises(TokenLockError) as e:
        fstx_queue.ex_replace_lock(r_d1, t1, "xx")
    assert e.value.token == ""
    with pytest.raises(TokenLockError) as e:
        fstx_queue.ex_unlock(r_d0, t1)
    assert e.value.token == t0
    with pytest.raises(TokenLockError) as e:
        fstx_queue.ex_unlock(r_d1, t1)
    assert e.value.token == ""

    # unlock on affected
    fstx_queue.ex_unlock(r_d0, t0)
    assert fstx_queue.get_ex_lock_token(r_d0_f0) is None
    fstx_queue.ex_lock(r_d0_f1, t1)
    assert fstx_queue.get_ex_lock_token(r_d0_f0) is None
    assert fstx_queue.get_ex_lock_token(r_d0) == t1
    # replace on affected
    fstx_queue.ex_replace_lock(r_d0, t0, t1)
    assert fstx_queue.get_ex_lock_token(r_d0_f1) == t0
    assert fstx_queue.get_ex_lock_token(r_d0_f0) == t0
    assert fstx_queue.get_ex_lock_token(r_d0) == t0
    fstx_queue.ex_replace_lock(r_d0_f1, t0, t0)
    assert fstx_queue.get_ex_lock_token(r_d0_f1) == t0
    assert fstx_queue.get_ex_lock_token(r_d0_f0) is None
    assert fstx_queue.get_ex_lock_token(r_d0) == t0
    fstx_queue.ex_unlock(r_d0_f1, t0)
    assert fstx_queue.get_ex_lock_token(r_d0_f1) is None

    # operation lock interop
    os.mkdir(r_d0)
    fstx_queue.ex_lock(r_d0_f0, t1)
    try:
        async with fstx_queue.wait_ready(DeleteOperation(0, r_d0)):
            assert False
    except TokenLockError as e:
        assert e.token == t1
    assert fstx_queue.get_ex_lock_token(r_d0_f0) == t1
    async with fstx_queue.wait_ready(DeleteOperation(0, r_d0), break_ex_locks=True) as submitter:
        assert fstx_queue.get_ex_lock_token(r_d0_f0) == ""
        with pytest.raises(TokenLockError) as e:
            fstx_queue.ex_lock(r_d0_f0, t0)
        assert e.value.token == ""
        await submitter()
    assert fstx_queue.get_ex_lock_token(r_d0_f0) is None

    # ttl
    fstx_queue.ex_lock(r_d0_f0, t1)
    assert fstx_queue.get_ex_lock_token(r_d0_f0) == t1
    fstx_queue._get_ex_lock_best(r_d0_f0).until -= fstx_queue.DEFAULT_TOKEN_LOCK_TTL
    time.sleep(0.01)  # if system time resolution is not good enough
    assert fstx_queue.get_ex_lock_token(r_d0_f0) is None
