import asyncio
import shutil
import sys
import time
from pathlib import Path

import pytest
from clarabot_nano_common.utility.path.common import normalize_absolute_path
from clarabot_nano_common.utility.path.error import attempt_share_locked_operation, SharedLockError

from clarabot_nano_tests.indexer.scheduler.utility import SchedulerTesterBase, PathStructureBuilder
from clarabot_nano_tests.testing.file import temp_path


@pytest.mark.asyncio
async def test_indexer_scheduler_event_sync_0(tmpdir, indexer_scheduler_tester: SchedulerTesterBase):
    """
    Simple changes and their indexing by arbitrary events.
    """
    rs = indexer_scheduler_tester.generate_tmp_roots_change_config(tmpdir, 1)
    psb = PathStructureBuilder(rs)
    r = rs[0]

    async with indexer_scheduler_tester.run_events() as scheduler:
        fs = psb.create_files_in_dirs(r, {"": 3, "a": 3, "b/b1": 3, "c/c1/c2": 3, "c/c1/c3": 3, "d": 3})
        psb.create_dirs(r, ["c/c2", "d/d1", "d/d2"])

        # first round additions
        indexer_scheduler_tester.create_event(r, "", fs[""][0], exists=None)
        indexer_scheduler_tester.create_event(r, "c/c1/c2", fs["c/c1/c2"][0], exists=None)
        indexer_scheduler_tester.create_event(r, "d/d1", exists=None)
        ps = psb.generate_filtered_paths_structure(
            {
                r: [
                    ("", fs[""][0]),
                    ("c/c1/c2", fs["c/c1/c2"][0]),
                    ("d/d1",),
                ]
            }
        )
        await indexer_scheduler_tester.assert_from_paths_until(ps)

        # second round additions & update-checks
        indexer_scheduler_tester.create_event(r, "", fs[""][0], exists=None)
        indexer_scheduler_tester.create_event(r, "", fs[""][1], exists=None)
        indexer_scheduler_tester.create_event(r, "c/c1/c2", fs["c/c1/c2"][1], exists=None)
        indexer_scheduler_tester.create_event(r, "c/c1/c3", fs["c/c1/c3"][0], exists=None)
        indexer_scheduler_tester.create_event(r, "c/c1/c3", fs["c/c1/c3"][1], exists=None)
        indexer_scheduler_tester.create_event(r, "d", fs["d"][0], exists=None)
        indexer_scheduler_tester.create_event(r, "d/d1", exists=None)
        indexer_scheduler_tester.create_event(r, "d/d2", exists=None)
        ps = psb.generate_filtered_paths_structure(
            {
                r: [
                    ("", fs[""][0]),
                    ("", fs[""][1]),
                    ("c/c1/c2", fs["c/c1/c2"][0]),
                    ("c/c1/c2", fs["c/c1/c2"][1]),
                    ("c/c1/c3", fs["c/c1/c3"][0]),
                    ("c/c1/c3", fs["c/c1/c3"][1]),
                    ("d", fs["d"][0]),
                    ("d/d1",),
                    ("d/d2",),
                ]
            }
        )
        await indexer_scheduler_tester.assert_from_paths_until(ps)

        # second round addition to cover added-directory optimization
        assert "d/d2" not in fs
        fs["d/d2"] = psb.create_files_in_dirs(r, {"d/d2": 3})["d/d2"]
        indexer_scheduler_tester.create_event(r, "d/d2", fs["d/d2"][0], exists=None)
        ps = psb.generate_filtered_paths_structure(
            {
                r: [
                    ("", fs[""][0]),
                    ("", fs[""][1]),
                    ("c/c1/c2", fs["c/c1/c2"][0]),
                    ("c/c1/c2", fs["c/c1/c2"][1]),
                    ("c/c1/c3", fs["c/c1/c3"][0]),
                    ("c/c1/c3", fs["c/c1/c3"][1]),
                    ("d", fs["d"][0]),
                    ("d/d1",),
                    ("d/d2", fs["d/d2"][0]),
                ]
            }
        )
        await indexer_scheduler_tester.assert_from_paths_until(ps)

        # test removals
        Path(r, fs[""][0]).unlink()
        Path(r, "c/c1/c2", fs["c/c1/c2"][0]).unlink()
        shutil.rmtree(Path(r, "c/c1/c3"))
        Path(r, "d/d1").rmdir()
        indexer_scheduler_tester.create_event(r, "", fs[""][0], exists=None)
        indexer_scheduler_tester.create_event(r, "c/c1/c2", fs["c/c1/c2"][0], exists=None)
        indexer_scheduler_tester.create_event(r, "c/c1/c3", exists=None)
        indexer_scheduler_tester.create_event(r, "d/d1", exists=None)
        ps = psb.generate_filtered_paths_structure(
            {
                r: [
                    ("", fs[""][1]),
                    ("c/c1/c2", fs["c/c1/c2"][1]),
                    ("d", fs["d"][0]),
                    ("d/d2", fs["d/d2"][0]),
                ]
            }
        )
        await indexer_scheduler_tester.assert_from_paths_until(ps)

        # test root type replacements
        Path(r, fs[""][1]).unlink()
        Path(r, fs[""][1], "x0").mkdir(parents=True)
        Path(r, fs[""][1], "x0", "xf0").write_text("test x0")
        Path(r, "d", fs["d"][0]).unlink()
        Path(r, "d", fs["d"][0], "x1").mkdir(parents=True)
        Path(r, "d", fs["d"][0], "x1", "xf1").write_text("test x1")
        indexer_scheduler_tester.create_event(r, fs[""][1], "x0", "xf0", exists=None)
        indexer_scheduler_tester.create_event(r, "d", fs["d"][0], "x1", "xf1", exists=None)
        ps = psb.generate_filtered_paths_structure(
            {
                r: [
                    (),  # root doc will not be removed, even if all root files are removed
                    (fs[""][1], "x0", "xf0"),
                    ("c/c1/c2", fs["c/c1/c2"][1]),
                    ("d", fs["d"][0], "x1", "xf1"),
                    ("d/d2", fs["d/d2"][0]),
                ]
            }
        )
        await indexer_scheduler_tester.assert_from_paths_until(ps)


@pytest.mark.skipif(not sys.platform.startswith("win"), reason="event delay only implemented on windows")
@pytest.mark.asyncio
async def test_indexer_scheduler_event_sync_1(tmpdir, indexer_scheduler_tester: SchedulerTesterBase):
    """
    Test processing delay by exist state inconsistencies. This is only implemented on Windows, because other systems
    ensure event delivery after operations are flushed.
    """
    rs = indexer_scheduler_tester.generate_tmp_roots_change_config(tmpdir, 1)
    psb = PathStructureBuilder(rs)
    r = rs[0]

    async with indexer_scheduler_tester.run_events() as scheduler:
        fs = psb.create_files_in_dirs(r, {"": 3, "a": 1, "b": 1, "c": 3})
        psb.create_files(r, ["f0", "f1", "f2", "f3", "f4"])

        # first round
        indexer_scheduler_tester.create_event(r, "a", exists=False)
        indexer_scheduler_tester.create_event(r, "a1", exists=True)
        indexer_scheduler_tester.create_event(r, "b", exists=None)
        indexer_scheduler_tester.create_event(r, "b1", exists=None)
        indexer_scheduler_tester.create_event(r, "f0", exists=False)
        indexer_scheduler_tester.create_event(r, "f1", exists=str(Path(r, "f0")))
        await asyncio.sleep(2.5)
        with psb.keep_paths_from_lost(r, ["b"]):
            Path(r, "a").rename(Path(r, "a1"))
            Path(r, "b").rename(Path(r, "b1"))
            Path(r, "f0").replace(Path(r, "f1"))
            ps = psb.generate_filtered_paths_structure(
                {
                    r: [
                        ("a1", fs["a"][0]),
                        ("b", fs["b"][0]),
                        ("f1",),
                    ]
                }
            )
            await indexer_scheduler_tester.assert_from_paths_until(ps)

            # second round
            indexer_scheduler_tester.create_event(r, "a1", exists=False)
            indexer_scheduler_tester.create_event(r, "a2", exists=True)
            indexer_scheduler_tester.create_event(r, "b1", exists=None)
            indexer_scheduler_tester.create_event(r, "b2", exists=None)
            indexer_scheduler_tester.create_event(r, "f1", exists=False)
            indexer_scheduler_tester.create_event(r, "f2", exists=str(Path(r, "f1")))
            await asyncio.sleep(2.5)
            with psb.keep_paths_from_lost(r, ["b1"]):
                Path(r, "a1").rename(Path(r, "a2"))
                Path(r, "b1").rename(Path(r, "b2"))
                Path(r, "f1").replace(Path(r, "f2"))
                ps = psb.generate_filtered_paths_structure(
                    {
                        r: [
                            ("a2", fs["a"][0]),
                            ("b", fs["b"][0]),
                            ("b1", fs["b"][0]),
                            ("f2",),
                        ]
                    }
                )
                await indexer_scheduler_tester.assert_from_paths_until(ps)

                # third round (limit timeout)
                indexer_scheduler_tester.create_event(r, "a2", exists=False)
                indexer_scheduler_tester.create_event(r, "a3", exists=True)
                indexer_scheduler_tester.create_event(r, "b2", exists=None)
                indexer_scheduler_tester.create_event(r, "b3", exists=None)
                indexer_scheduler_tester.create_event(r, "f2", exists=False)
                indexer_scheduler_tester.create_event(r, "f3", exists=str(Path(r, "f2")))
                await asyncio.sleep(11.5)
                with psb.keep_paths_from_lost(r, ["a2", "b2", "f2", "f3"]):
                    Path(r, "a2").rename(Path(r, "a3"))
                    Path(r, "b2").rename(Path(r, "b3"))
                    Path(r, "f2").replace(Path(r, "f3"))
                    ps = psb.generate_filtered_paths_structure(
                        {
                            r: [
                                ("a2", fs["a"][0]),
                                ("b", fs["b"][0]),
                                ("b1", fs["b"][0]),
                                ("b2", fs["b"][0]),
                                ("f2",),
                                ("f3",),  # because it existed before move (replace)
                            ]
                        }
                    )
                    await indexer_scheduler_tester.assert_from_paths_until(ps)


@pytest.mark.asyncio
async def test_indexer_scheduler_event_sync_2(tmpdir, indexer_scheduler_tester: SchedulerTesterBase):
    """
    Test simple changes by observed events.
    """
    ro = Path(normalize_absolute_path(temp_path(tmpdir)))
    ro.mkdir()
    fo = ro / "f"
    fo.write_text("outside")
    rs = indexer_scheduler_tester.generate_tmp_roots_change_config(tmpdir, 1)
    psb = PathStructureBuilder(rs)
    r = rs[0]

    async with indexer_scheduler_tester.run_events(observe_fs_events=True) as scheduler:
        # create
        fs = psb.create_files_in_dirs(r, {"": 5, "a": 3, "b/b1": 3, "c/c1/c2": 3, "c/c1/c3": 3, "d": 3, "g": 3})
        psb.create_dirs(r, ["c/c2", "d/d1", "d/d2"])
        await indexer_scheduler_tester.assert_from_paths_until(psb.generate_paths_structure())

        # add & update
        psb.update_files_in_dirs(r, {"": 1, "b/b1": 2, "c/c1/c3": 1})
        fs.update(psb.create_files_in_dirs(r, {"d2": 4}))
        await indexer_scheduler_tester.assert_from_paths_until(psb.generate_paths_structure())

        # remove
        Path(r, fs[""][0]).unlink()
        Path(r, "c/c1/c2", fs["c/c1/c2"][0]).unlink()
        shutil.rmtree(Path(r, "c/c1/c3"))
        Path(r, "d/d2").rmdir()
        await indexer_scheduler_tester.assert_from_paths_until(psb.generate_paths_structure())

        # move
        Path(r, "c/c1").rename(Path(r, "e"))
        Path(r, fs[""][1]).rename(Path(r, "c/qx.txt"))
        # NOTE: Moving this file sometimes was not registered because its size and mtime matched.
        #   Such is an edge case where the indexed content staleness cannot be correctly inferred from those values.
        #   File-write was given a delay in PathStructureBuilder so the issue will be avoided for testing.
        Path(r, fs[""][2]).replace(Path(r, fs[""][3]))
        await indexer_scheduler_tester.assert_from_paths_until(psb.generate_paths_structure())

        # create symlinks
        Path(r, "c/es").symlink_to(Path(r, "e"), target_is_directory=True)
        Path(r, "c/fs").symlink_to(Path(r, fs[""][3]))
        Path(r, "c/od").symlink_to(ro, target_is_directory=True)
        Path(r, "c/of").symlink_to(fo)
        Path(r, "a/symBX").symlink_to(Path(r, "a", "symXN"))  # broken symlink
        Path(r, "a/sym0").symlink_to(Path(r, "a", fs["a"][0]))
        Path(r, "a/sym1").symlink_to(Path(r, "a", fs["a"][0]))
        Path(r, "a/sym2").symlink_to(Path(r, "a", fs["a"][0]))
        Path(r, "dymBX").symlink_to(Path(r, "dymXN"), target_is_directory=True)  # broken symlink
        Path(r, "dym0").symlink_to(Path(r, "g"), target_is_directory=True)
        Path(r, "dym1").symlink_to(Path(r, "g"), target_is_directory=True)
        Path(r, "dym2").symlink_to(Path(r, "g"), target_is_directory=True)
        await indexer_scheduler_tester.assert_from_paths_until(psb.generate_paths_structure())

        # remove some aliases in the same directory as the link target
        Path(r, "a/sym1").unlink()
        Path(r, "dym1").unlink()
        await indexer_scheduler_tester.assert_from_paths_until(psb.generate_paths_structure())

        # update original file of symlink (this tests partial directory update when there are aliases in the same dir)
        Path(r, "a", fs["a"][0]).write_text("modified content")
        await indexer_scheduler_tester.assert_from_paths_until(psb.generate_paths_structure())

        # remove target of aliases in the same directory
        Path(r, "a", fs["a"][0]).unlink()
        shutil.rmtree(Path(r, "g"))
        await indexer_scheduler_tester.assert_from_paths_until(psb.generate_paths_structure())

        # delete symlinks
        Path(r, "c/es").unlink()
        Path(r, "c/fs").unlink()
        Path(r, "c/od").unlink()
        Path(r, "c/of").unlink()
        await indexer_scheduler_tester.assert_from_paths_until(psb.generate_paths_structure())

        # move the whole root around
        Path(r).rename(Path(r + "X"))
        Path(r + "X").rename(Path(r))
        Path(r, "last").write_text("see me")
        await indexer_scheduler_tester.assert_from_paths_until(psb.generate_paths_structure())


@pytest.mark.parametrize("skip_event_aggregation", [True, False])
@pytest.mark.asyncio
async def test_indexer_scheduler_event_sync_3(
    tmpdir, indexer_scheduler_tester: SchedulerTesterBase, skip_event_aggregation
):
    """
    Test frequent changes (create-move) by observed events.
    This was the failure test of a bug that involved priority sync items to be lost by arriving during the
    cleanup of the previous item for the same path.
    """
    ro = Path(normalize_absolute_path(temp_path(tmpdir)))
    ro.mkdir()
    rs = indexer_scheduler_tester.generate_tmp_roots_change_config(tmpdir, 1)
    psb = PathStructureBuilder(rs)
    r = rs[0]

    async with indexer_scheduler_tester.run_events(
        observe_fs_events=True, skip_event_aggregation=skip_event_aggregation
    ) as scheduler:
        psb.create_files(r, [f"f{i}" for i in range(1)])
        await indexer_scheduler_tester.assert_from_paths_until(psb.generate_paths_structure())

        def _writes(j, w):
            p = Path(r, f"x{j}")
            p.write_text(psb.generate_content())
            time.sleep(w)  # there was some timing where the issue presented itself
            p.rename(Path(r, f"x{j}x"))

        async def _time_tests():
            move_delay = 0.001
            for timing_attempt in range(100):
                await asyncio.get_running_loop().run_in_executor(None, _writes, timing_attempt, move_delay)
                await indexer_scheduler_tester.assert_from_paths_until(psb.generate_paths_structure())
                move_delay += 0.001

        try:
            await attempt_share_locked_operation(0, 0, _time_tests)
        except SharedLockError:
            assert sys.platform.startswith("win")
