import asyncio
from dataclasses import asdict
from datetime import datetime, timezone
from unittest.mock import AsyncMock

import pytest

from mirage.commands.builtin.find_eval import Name, Not, Or
from mirage.commands.builtin.generic.find import (FindArgs, apply_mount_prefix,
                                                  apply_mtime_filter,
                                                  parse_find_args, walk_find)
from mirage.commands.errors import FindParseError
from mirage.resource.ram import RAMResource
from mirage.types import FileStat, FileType, FindType, MountMode, PathSpec
from mirage.workspace import Workspace


def _defaults() -> dict:
    return asdict(FindArgs())


def test_parse_find_args_empty_returns_defaults():
    args = parse_find_args(())
    assert asdict(args) == _defaults()


def test_parse_find_args_name_passthrough():
    args = parse_find_args((), name="*.txt")
    assert args.name == "*.txt"
    assert args.or_names is None


def test_parse_find_args_iname_and_path():
    args = parse_find_args((), iname="HELLO.*", path="**/sub/*")
    assert args.iname == "HELLO.*"
    assert args.path_pattern == "**/sub/*"


def test_parse_find_args_maxdepth_mindepth_str_to_int():
    args = parse_find_args((), maxdepth="3", mindepth="1")
    assert args.maxdepth == 3
    assert args.mindepth == 1


def test_parse_find_args_size_plus_lower_bound():
    args = parse_find_args((), size="+500c")
    assert args.min_size == 501
    assert args.max_size is None


def test_parse_find_args_size_minus_upper_bound():
    args = parse_find_args((), size="-1k")
    assert args.min_size is None
    assert args.max_size == 0


def test_parse_find_args_size_exact():
    args = parse_find_args((), size="1k")
    assert args.min_size == 1
    assert args.max_size == 1024


def test_parse_find_args_mtime_minus_recent():
    """`-mtime -1` means modified within last 1 day."""
    args = parse_find_args((), mtime="-1")
    assert args.mtime_min is not None
    assert args.mtime_max is None


def test_parse_find_args_mtime_plus_old():
    args = parse_find_args((), mtime="+7")
    assert args.mtime_min is None
    assert args.mtime_max is not None


def test_parse_find_args_type_canonicalized_to_findtype_enum():
    """Known POSIX `-type` values become FindType members."""
    assert parse_find_args((), type="d").type is FindType.DIRECTORY
    assert parse_find_args((), type="f").type is FindType.FILE


def test_parse_find_args_unknown_type_left_as_string():
    """Non-POSIX types pass through verbatim (allows custom backend types)."""
    assert parse_find_args((), type="symlink").type == "symlink"


def test_parse_find_args_unknown_predicate_raises():
    with pytest.raises(FindParseError,
                       match="find: unknown predicate '-bogus'"):
        parse_find_args(("-bogus", ))


def test_parse_find_args_negation_builds_not_tree():
    args = parse_find_args(("-not", "-name", "*.pyc"))
    assert args.tree == Not(Name("*.pyc"))


def test_parse_find_args_or_builds_or_tree():
    args = parse_find_args(("-name", "*.txt", "-o", "-name", "*.py"))
    assert args.tree == Or([Name("*.txt"), Name("*.py")])


def test_parse_find_args_or_names_none_when_only_one_name():
    """If only `name` is set with no `-or -name` clauses, or_names is None."""
    args = parse_find_args((), name="*.txt")
    assert args.or_names is None


@pytest.mark.asyncio
async def test_apply_mtime_filter_skips_when_no_window():
    out = await apply_mtime_filter(
        ["/a.txt"],
        mtime_min=None,
        mtime_max=None,
        stat=_unreached_stat,
    )
    assert out == ["/a.txt"]


@pytest.mark.asyncio
async def test_apply_mtime_filter_keeps_within_window():
    now = datetime.now(tz=timezone.utc)
    iso = now.isoformat()

    async def stat(_spec: PathSpec) -> FileStat:
        return FileStat(name="a.txt", size=1, modified=iso, type=FileType.TEXT)

    out = await apply_mtime_filter(
        ["/a.txt"],
        mtime_min=now.timestamp() - 60,
        mtime_max=now.timestamp() + 60,
        stat=stat,
    )
    assert out == ["/a.txt"]


@pytest.mark.asyncio
async def test_apply_mtime_filter_stats_the_mounted_virtual_path():
    now = datetime.now(tz=timezone.utc)
    stat = AsyncMock(return_value=FileStat(
        name="a.txt", size=1, modified=now.isoformat(), type=FileType.TEXT))

    out = await apply_mtime_filter(
        ["/a.txt"],
        mtime_min=now.timestamp() - 60,
        mtime_max=now.timestamp() + 60,
        stat=stat,
        mount_prefix="/mnt",
    )

    assert out == ["/a.txt"]
    spec = stat.await_args.args[0]
    assert spec.virtual == "/mnt/a.txt"
    assert spec.resource_path == "a.txt"


@pytest.mark.asyncio
async def test_apply_mtime_filter_drops_outside_window():
    old = datetime(2020, 1, 1, tzinfo=timezone.utc)

    async def stat(_spec: PathSpec) -> FileStat:
        return FileStat(name="a.txt",
                        size=1,
                        modified=old.isoformat(),
                        type=FileType.TEXT)

    out = await apply_mtime_filter(
        ["/a.txt"],
        mtime_min=datetime(2025, 1, 1, tzinfo=timezone.utc).timestamp(),
        mtime_max=None,
        stat=stat,
    )
    assert out == []


@pytest.mark.asyncio
async def test_apply_mtime_filter_drops_entries_with_no_modified_time():

    async def stat(_spec: PathSpec) -> FileStat:
        return FileStat(name="a.txt",
                        size=1,
                        modified=None,
                        type=FileType.TEXT)

    out = await apply_mtime_filter(
        ["/a.txt"],
        mtime_min=1.0,
        mtime_max=None,
        stat=stat,
    )
    assert out == []


@pytest.mark.asyncio
async def test_apply_mtime_filter_silently_skips_stat_errors():

    async def stat(_spec: PathSpec) -> FileStat:
        raise FileNotFoundError("gone")

    out = await apply_mtime_filter(
        ["/a.txt", "/b.txt"],
        mtime_min=1.0,
        mtime_max=None,
        stat=stat,
    )
    assert out == []


def test_apply_mount_prefix_noop_when_empty():
    assert apply_mount_prefix(["/a.txt"], "") == ["/a.txt"]


def test_apply_mount_prefix_prepends():
    assert apply_mount_prefix(["/a.txt", "/dir/b.txt"],
                              "/mnt") == ["/mnt/a.txt", "/mnt/dir/b.txt"]


def test_apply_mount_prefix_strips_leading_slash_from_entries():
    assert apply_mount_prefix(["a.txt"], "/mnt") == ["/mnt/a.txt"]


async def _unreached_stat(_spec: PathSpec) -> FileStat:
    raise AssertionError("stat should not be called when no mtime window set")


def _root_spec() -> PathSpec:
    return PathSpec(resource_path="",
                    virtual="/",
                    directory="/",
                    resolved=False)


@pytest.mark.asyncio
async def test_walk_find_tolerates_not_found_readdir():
    readdir = AsyncMock(side_effect=FileNotFoundError("/"))
    stat = AsyncMock(side_effect=FileNotFoundError("/"))
    results = await walk_find(_root_spec(),
                              readdir=readdir,
                              stat=stat,
                              is_dir_name=lambda c: None,
                              index=None,
                              args=FindArgs())
    assert results == []


@pytest.mark.asyncio
async def test_walk_find_emits_start_path_at_depth_zero():
    readdir = AsyncMock(return_value=["/child.txt"])
    stat = AsyncMock(return_value=FileStat(name="/", type=FileType.DIRECTORY))
    results = await walk_find(_root_spec(),
                              readdir=readdir,
                              stat=stat,
                              is_dir_name=lambda c: False,
                              index=None,
                              args=FindArgs(maxdepth=0))
    assert results == ["/"]
    readdir.assert_not_awaited()


@pytest.mark.asyncio
async def test_walk_find_propagates_non_not_found_readdir_errors():
    readdir = AsyncMock(side_effect=ValueError("bad page token"))
    stat = AsyncMock()
    with pytest.raises(ValueError, match="bad page token"):
        await walk_find(_root_spec(),
                        readdir=readdir,
                        stat=stat,
                        is_dir_name=lambda c: None,
                        index=None,
                        args=FindArgs())


@pytest.mark.asyncio
async def test_walk_find_stat_fallback_treats_not_found_as_file():
    readdir = AsyncMock(return_value=["/mystery"])
    stat = AsyncMock(side_effect=FileNotFoundError("/mystery"))
    results = await walk_find(_root_spec(),
                              readdir=readdir,
                              stat=stat,
                              is_dir_name=lambda c: None,
                              index=None,
                              args=FindArgs(type=FindType.FILE))
    assert results == ["/mystery"]


@pytest.mark.asyncio
async def test_walk_find_empty_matches_empty_files_and_dirs():

    async def readdir(spec: PathSpec, _index):
        table = {
            "/": ["/empty.txt", "/full.txt", "/empty-dir", "/full-dir"],
            "/empty-dir": [],
            "/full-dir": ["/full-dir/a.txt"],
        }
        return table[spec.virtual]

    async def stat(spec: PathSpec, _index):
        stats = {
            "/": FileStat(name="/", type=FileType.DIRECTORY),
            "/empty.txt": FileStat(name="empty.txt",
                                   size=0,
                                   type=FileType.TEXT),
            "/full.txt": FileStat(name="full.txt", size=1, type=FileType.TEXT),
            "/empty-dir": FileStat(name="empty-dir", type=FileType.DIRECTORY),
            "/full-dir": FileStat(name="full-dir", type=FileType.DIRECTORY),
            "/full-dir/a.txt": FileStat(name="a.txt",
                                        size=1,
                                        type=FileType.TEXT),
        }
        return stats[spec.virtual]

    results = await walk_find(_root_spec(),
                              readdir=readdir,
                              stat=stat,
                              is_dir_name=lambda c: c.endswith("dir"),
                              index=None,
                              args=parse_find_args(("-empty", )))
    assert results == ["/empty-dir", "/empty.txt"]


@pytest.mark.asyncio
async def test_walk_find_not_negates_predicate():
    readdir = AsyncMock(return_value=["/a.txt", "/b.md"])

    async def stat(spec: PathSpec, _index):
        if spec.virtual == "/":
            return FileStat(name="/", type=FileType.DIRECTORY)
        return FileStat(name=spec.virtual.rsplit("/", 1)[-1],
                        size=1,
                        type=FileType.TEXT)

    results = await walk_find(_root_spec(),
                              readdir=readdir,
                              stat=stat,
                              is_dir_name=lambda c: False,
                              index=None,
                              args=parse_find_args(("-not", "-name", "*.txt")))
    assert results == ["/", "/b.md"]


@pytest.mark.asyncio
async def test_walk_find_stat_fallback_propagates_other_errors():
    readdir = AsyncMock(return_value=["/mystery"])
    stat = AsyncMock(side_effect=ValueError("rate limited"))
    with pytest.raises(ValueError, match="rate limited"):
        await walk_find(_root_spec(),
                        readdir=readdir,
                        stat=stat,
                        is_dir_name=lambda c: None,
                        index=None,
                        args=FindArgs())


@pytest.mark.asyncio
async def test_walk_find_size_filter_drops_not_found_entries():
    readdir = AsyncMock(return_value=["/a.json"])
    stat = AsyncMock(side_effect=FileNotFoundError("/a.json"))
    results = await walk_find(_root_spec(),
                              readdir=readdir,
                              stat=stat,
                              is_dir_name=lambda c: False,
                              index=None,
                              args=FindArgs(min_size=1))
    assert results == []


@pytest.mark.asyncio
async def test_walk_find_size_filter_propagates_other_stat_errors():
    readdir = AsyncMock(return_value=["/a.json"])
    stat = AsyncMock(side_effect=ValueError("rate limited"))
    with pytest.raises(ValueError, match="rate limited"):
        await walk_find(_root_spec(),
                        readdir=readdir,
                        stat=stat,
                        is_dir_name=lambda c: False,
                        index=None,
                        args=FindArgs(min_size=1))


@pytest.mark.parametrize("kwargs,flag,value", [
    ({
        "maxdepth": "abc"
    }, "-maxdepth", "abc"),
    ({
        "mindepth": "xx"
    }, "-mindepth", "xx"),
    ({
        "size": ""
    }, "-size", ""),
    ({
        "size": "abc"
    }, "-size", "abc"),
    ({
        "mtime": "abc"
    }, "-mtime", "abc"),
])
def test_parse_find_args_invalid_numeric_raises_find_parse_error(
        kwargs, flag, value):
    with pytest.raises(FindParseError) as exc:
        parse_find_args((), **kwargs)
    assert str(exc.value) == f"find: invalid argument '{value}' to '{flag}'"


@pytest.mark.parametrize("expr", [
    "-maxdepth abc",
    "-mindepth xx",
    "-size ''",
    "-size abc",
    "-mtime abc",
])
def test_find_invalid_numeric_arg_exits_one_with_clean_stderr(expr):

    async def _go() -> tuple[int, str]:
        ws = Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
        ws.create_session("s")
        r = await ws.execute(f"find / {expr}", session_id="s")
        return r.exit_code, await r.stderr_str()

    code, stderr = asyncio.run(_go())
    assert code == 1
    assert stderr.startswith("find: invalid argument ")
    assert stderr.endswith("\n")


# ── Issue #312 parse-level regression tests ────────────────


def test_parse_find_args_start_path_included():
    args = parse_find_args(())
    assert args.maxdepth is None


def test_parse_find_args_maxdepth_zero():
    args = parse_find_args(("-maxdepth", "0"))
    assert args.maxdepth == 0


def test_parse_find_args_empty_predicate():
    args = parse_find_args(("-empty", ))
    assert args.empty is True


def test_parse_find_args_not_negation():
    args = parse_find_args(("-not", "-name", "*.txt"))
    assert isinstance(args.tree, Not)
    assert isinstance(args.tree.kid, Name)
    assert args.tree.kid.pattern == "*.txt"


def test_parse_find_args_bogus_predicate_raises():
    with pytest.raises(FindParseError, match="unknown predicate"):
        parse_find_args(("-boguspredicate", ))
