"""Unit tests for input parsing utilities."""

from pathlib import Path

import pytest

from deepagents_code.input import (
    extract_leading_pasted_file_path,
    normalize_pasted_path,
    parse_file_mentions,
    parse_pasted_file_paths,
    parse_pasted_path_payload,
    parse_single_pasted_file_path,
)


def test_parse_file_mentions_with_chinese_sentence(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Ensure `@file` parsing terminates at non-path characters such as CJK text."""
    file_path = tmp_path / "input.py"
    file_path.write_text("print('hello')")

    monkeypatch.chdir(tmp_path)
    text = f"你分析@{file_path.name}的代码就懂了"

    _, files = parse_file_mentions(text)

    assert files == [file_path.resolve()]


def test_parse_file_mentions_handles_multiple_mentions(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Ensure multiple `@file` mentions are extracted from a single input."""
    first = tmp_path / "a.txt"
    second = tmp_path / "b.txt"
    first.write_text("1")
    second.write_text("2")

    monkeypatch.chdir(tmp_path)
    text = f"读一下@{first.name}，然后看看@{second.name}。"

    _, files = parse_file_mentions(text)

    assert files == [first.resolve(), second.resolve()]


def test_parse_file_mentions_with_escaped_spaces(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Ensure escaped spaces in paths are handled correctly."""
    spaced_dir = tmp_path / "my folder"
    spaced_dir.mkdir()
    file_path = spaced_dir / "test.py"
    file_path.write_text("content")
    monkeypatch.chdir(tmp_path)

    _, files = parse_file_mentions("@my\\ folder/test.py")

    assert files == [file_path.resolve()]


def test_parse_file_mentions_warns_for_nonexistent_file(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker
) -> None:
    """Ensure non-existent files are excluded and warning is printed."""
    monkeypatch.chdir(tmp_path)
    mock_console = mocker.patch("deepagents_code.input.console")

    _, files = parse_file_mentions("@nonexistent.py")

    assert files == []
    mock_console.print.assert_called_once()
    assert "nonexistent.py" in mock_console.print.call_args[0][0]


def test_parse_file_mentions_ignores_directories(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker
) -> None:
    """Ensure directories are not included in file list."""
    dir_path = tmp_path / "mydir"
    dir_path.mkdir()
    monkeypatch.chdir(tmp_path)
    mock_console = mocker.patch("deepagents_code.input.console")

    _, files = parse_file_mentions("@mydir")

    assert files == []
    mock_console.print.assert_called_once()
    assert "mydir" in mock_console.print.call_args[0][0]


def test_parse_file_mentions_with_no_mentions() -> None:
    """Ensure text without mentions returns empty file list."""
    _, files = parse_file_mentions("just some text without mentions")
    assert files == []


def test_parse_file_mentions_handles_path_traversal(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Ensure path traversal sequences are resolved to actual paths."""
    subdir = tmp_path / "subdir"
    subdir.mkdir()
    file_path = tmp_path / "test.txt"
    file_path.write_text("content")
    monkeypatch.chdir(subdir)

    _, files = parse_file_mentions("@../test.txt")

    assert files == [file_path.resolve()]


def test_parse_file_mentions_with_absolute_path(tmp_path: Path) -> None:
    """Ensure absolute paths are resolved correctly without cwd changes."""
    file_path = tmp_path / "test.py"
    file_path.write_text("content")

    _, files = parse_file_mentions(f"@{file_path}")

    assert files == [file_path.resolve()]


def test_parse_file_mentions_handles_multiple_in_sentence(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Ensure multiple `@mentions` within a sentence are each parsed separately."""
    first = tmp_path / "a.py"
    second = tmp_path / "b.py"
    first.write_text("1")
    second.write_text("2")
    monkeypatch.chdir(tmp_path)

    _, files = parse_file_mentions("compare @a.py and @b.py")

    assert files == [first.resolve(), second.resolve()]


def test_parse_file_mentions_adjacent_looks_like_email(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker
) -> None:
    """Adjacent `@mentions` without space look like emails and are skipped.

    `@a.py@b.py` - the second `@` is preceded by `y` which looks like
    an email username, so `@b.py` is skipped. This is expected behavior
    to avoid false positives on email addresses.
    """
    first = tmp_path / "a.py"
    second = tmp_path / "b.py"
    first.write_text("1")
    second.write_text("2")
    monkeypatch.chdir(tmp_path)
    mock_console = mocker.patch("deepagents_code.input.console")

    _, files = parse_file_mentions("@a.py@b.py")

    # Only first file is parsed; second looks like email and is skipped
    assert files == [first.resolve()]
    mock_console.print.assert_not_called()


def test_parse_file_mentions_handles_oserror(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker
) -> None:
    """Ensure `OSError` during path resolution is handled gracefully."""
    monkeypatch.chdir(tmp_path)
    mock_console = mocker.patch("deepagents_code.input.console")
    mocker.patch("pathlib.Path.resolve", side_effect=OSError("Permission denied"))

    _, files = parse_file_mentions("@somefile.py")

    assert files == []
    mock_console.print.assert_called_once()
    call_arg = mock_console.print.call_args[0][0]
    assert "somefile.py" in call_arg
    assert "Invalid path" in call_arg


def test_parse_file_mentions_skips_email_addresses(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker
) -> None:
    """Ensure email addresses are not parsed as file mentions.

    Email addresses like `user@example.com` should be silently skipped
    because the `@` is preceded by email-like characters.
    """
    monkeypatch.chdir(tmp_path)
    mock_console = mocker.patch("deepagents_code.input.console")

    _, files = parse_file_mentions("contact me at user@example.com")

    # Email addresses should be silently skipped (no warning, no files)
    assert files == []
    mock_console.print.assert_not_called()


def test_parse_file_mentions_skips_various_email_formats(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker
) -> None:
    """Ensure various email formats are all skipped."""
    monkeypatch.chdir(tmp_path)
    mock_console = mocker.patch("deepagents_code.input.console")

    emails = [
        "test@domain.com",
        "user.name@company.org",
        "first+tag@example.io",
        "name_123@test.co",
        "a@b.c",
    ]

    for email in emails:
        _, files = parse_file_mentions(f"Email: {email}")
        assert files == [], f"Expected {email} to be skipped"

    mock_console.print.assert_not_called()


def test_parse_file_mentions_works_after_cjk_text(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker
) -> None:
    """Ensure `@file` mentions work after CJK text (not email-like)."""
    file_path = tmp_path / "test.py"
    file_path.write_text("content")
    monkeypatch.chdir(tmp_path)
    mock_console = mocker.patch("deepagents_code.input.console")

    # CJK character before @ is not email-like, so this should parse
    _, files = parse_file_mentions("查看@test.py")

    assert files == [file_path.resolve()]
    mock_console.print.assert_not_called()


def test_parse_file_mentions_handles_bad_tilde_user(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker
) -> None:
    """Ensure `~nonexistentuser` paths produce a warning instead of crashing.

    `Path.expanduser()` raises `RuntimeError` when the username does not
    exist. This must be caught gracefully rather than propagating up.
    """
    monkeypatch.chdir(tmp_path)
    mock_console = mocker.patch("deepagents_code.input.console")

    _, files = parse_file_mentions("@~nonexistentuser12345/file.py")

    assert files == []
    mock_console.print.assert_called_once()
    call_arg = mock_console.print.call_args[0][0]
    assert "nonexistentuser12345" in call_arg


def test_parse_pasted_file_paths_with_quoted_paths(tmp_path: Path) -> None:
    """Quoted dropped paths should resolve correctly."""
    img = tmp_path / "my image.png"
    img.write_bytes(b"img")

    result = parse_pasted_file_paths(f'"{img}"')

    assert result == [img.resolve()]


def test_parse_pasted_file_paths_with_file_url(tmp_path: Path) -> None:
    """`file://` dropped paths should be URL-decoded and resolved."""
    img = tmp_path / "space name.png"
    img.write_bytes(b"img")

    result = parse_pasted_file_paths(f"file://{str(img).replace(' ', '%20')}")

    assert result == [img.resolve()]


def test_parse_pasted_file_paths_with_multiple_lines(tmp_path: Path) -> None:
    """Multiple dropped paths separated by newlines should all resolve."""
    first = tmp_path / "a.png"
    second = tmp_path / "b.png"
    first.write_bytes(b"a")
    second.write_bytes(b"b")

    result = parse_pasted_file_paths(f"{first}\n{second}")

    assert result == [first.resolve(), second.resolve()]


def test_parse_pasted_file_paths_returns_empty_for_text_payload() -> None:
    """Normal prose should not be interpreted as dropped file paths."""
    assert parse_pasted_file_paths("please inspect this image") == []


def test_parse_pasted_file_paths_returns_empty_for_missing_file(tmp_path: Path) -> None:
    """Missing dropped files should fall back to regular text paste."""
    missing = tmp_path / "missing.png"
    assert parse_pasted_file_paths(str(missing)) == []


def test_parse_pasted_file_paths_returns_empty_for_empty_string() -> None:
    """Empty string should return an empty list."""
    assert parse_pasted_file_paths("") == []


def test_parse_pasted_file_paths_returns_empty_for_whitespace() -> None:
    """Whitespace-only payloads should return an empty list."""
    assert parse_pasted_file_paths("   \n\t  ") == []


def test_parse_pasted_file_paths_handles_angle_bracket_wrapped_path(
    tmp_path: Path,
) -> None:
    """Angle-bracket wrapped paths (e.g. from some terminals) should resolve."""
    img = tmp_path / "bracketed.png"
    img.write_bytes(b"img")

    result = parse_pasted_file_paths(f"<{img}>")

    assert result == [img.resolve()]


def test_normalize_pasted_path_rejects_mixed_payload() -> None:
    """Single-path normalizer should reject path+prose mixed payloads."""
    assert normalize_pasted_path("'/tmp/a.png' what's this") is None


def test_normalize_pasted_path_accepts_windows_drive_payload() -> None:
    """Unquoted Windows drive path with spaces should parse as one path token."""
    payload = r"C:\Users\Alice\My Pictures\example image.png"
    result = normalize_pasted_path(payload)
    assert result == Path(payload)


def test_parse_single_pasted_file_path_resolves_unicode_space_variant(
    tmp_path: Path,
) -> None:
    """ASCII-space paste should resolve files with lookalike Unicode spaces."""
    unicode_name = "Screenshot 2026-02-26 at 2.02.42\u202fAM.png"
    img = tmp_path / unicode_name
    img.write_bytes(b"img")

    pasted_path = str(img).replace("\u202f", " ")
    pasted = f"'{pasted_path}'"
    resolved = parse_single_pasted_file_path(pasted)

    assert resolved == img.resolve()


def test_parse_single_pasted_file_path_unquoted_posix_path_with_spaces(
    tmp_path: Path,
) -> None:
    """Raw POSIX absolute paths with spaces should resolve as one file path."""
    img = tmp_path / "Screenshot 1.png"
    img.write_bytes(b"img")

    resolved = parse_single_pasted_file_path(str(img))

    assert resolved == img.resolve()


def test_parse_pasted_path_payload_single_path(tmp_path: Path) -> None:
    """Payload parser should resolve path-only payloads."""
    img = tmp_path / "one.png"
    img.write_bytes(b"img")

    parsed = parse_pasted_path_payload(str(img))

    assert parsed is not None
    assert parsed.paths == [img.resolve()]
    assert parsed.token_end is None


def test_parse_pasted_path_payload_leading_path_with_suffix(tmp_path: Path) -> None:
    """Payload parser should extract leading path when enabled."""
    img = tmp_path / "my image.png"
    img.write_bytes(b"img")
    payload = f"'{img}' what's in this image?"

    assert parse_pasted_path_payload(payload) is None

    parsed = parse_pasted_path_payload(payload, allow_leading_path=True)

    assert parsed is not None
    assert parsed.paths == [img.resolve()]
    assert parsed.token_end is not None
    assert payload[parsed.token_end :] == " what's in this image?"


def test_extract_leading_pasted_file_path_with_trailing_text(tmp_path: Path) -> None:
    """Leading path token should be extracted while preserving trailing text."""
    img = tmp_path / "my image.png"
    img.write_bytes(b"img")
    payload = f"'{img}' what's in this image?"

    result = extract_leading_pasted_file_path(payload)

    assert result is not None
    resolved, end = result
    assert resolved == img.resolve()
    assert payload[end:] == " what's in this image?"


def test_extract_leading_pasted_file_path_unquoted_path_with_spaces(
    tmp_path: Path,
) -> None:
    """Unquoted absolute paths with spaces should be extracted from leading text."""
    img = tmp_path / "Screenshot 1.png"
    img.write_bytes(b"img")
    payload = f"{img} what's in this"

    result = extract_leading_pasted_file_path(payload)

    assert result is not None
    resolved, end = result
    assert resolved == img.resolve()
    assert payload[end:] == " what's in this"


def test_parse_pasted_file_paths_handles_overlong_component(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """An over-long path component must not crash path probing.

    Regression test: holding a key floods the input with a single long token.
    Resolving it against the cwd produces a path whose component exceeds the
    filesystem name limit, so `os.stat` raises `OSError` (`ENAMETOOLONG`). On
    Python <=3.13 `pathlib` lets that propagate (the original crash); on 3.14
    it is swallowed, so this asserts the no-match contract on every version.
    The version-independent guard is `*_handles_oserror_on_probe` below.
    """
    monkeypatch.chdir(tmp_path)
    overlong = "a" * 5000

    assert parse_pasted_file_paths(overlong) == []


def test_parse_pasted_path_payload_handles_overlong_component(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """The dropped-path entrypoint must not crash on an over-long token.

    This mirrors the exact path that crashed the TUI: `on_text_area_changed`
    routes freshly typed text through `parse_pasted_path_payload`.
    """
    monkeypatch.chdir(tmp_path)
    overlong = "a" * 5000

    assert parse_pasted_path_payload(overlong) is None


def test_parse_pasted_file_paths_handles_oserror_on_probe(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker
) -> None:
    """`OSError` raised by an `is_file` probe must be swallowed, not propagated.

    Guards against platforms/filesystems that surface the limit at a different
    probe than `resolve`, ensuring the fix does not rely on a specific errno.
    """
    monkeypatch.chdir(tmp_path)
    target = tmp_path / "real.txt"
    target.write_text("hi")
    mocker.patch("pathlib.Path.is_file", side_effect=OSError(63, "File name too long"))

    assert parse_pasted_file_paths(str(target)) == []


def test_parse_pasted_file_paths_handles_oserror_in_unicode_variant(
    tmp_path: Path, mocker
) -> None:
    """An `OSError` probe inside the Unicode-space fallback must not propagate.

    Exercises the `_resolve_with_unicode_space_variants` traversal: the on-disk
    name carries a narrow no-break space while the paste uses an ASCII space,
    forcing the `iterdir`-match branch where component `is_file`/`is_dir` probes
    run. The path is quoted so it stays a single token instead of being split.
    """
    unicode_name = "Screenshot 2026-02-26 at 2.02.42 AM.png"
    img = tmp_path / unicode_name
    img.write_bytes(b"img")
    ascii_name = unicode_name.replace(chr(0x202F), " ")
    pasted = f"'{str(img).replace(unicode_name, ascii_name)}'"
    mocker.patch("pathlib.Path.is_file", side_effect=OSError(63, "File name too long"))

    assert parse_pasted_file_paths(pasted) == []
