import pytest

from api.workspace import (
    authorize_escape_target,
    list_authorized_escape_dir,
    list_dir,
    read_file_content,
    resolve_authorized_escape_request,
    safe_resolve_ws,
)


def test_safe_resolve_blocks_external_symlink_directory(tmp_path):
    workspace = tmp_path / "workspace"
    outside = tmp_path / "outside"
    workspace.mkdir()
    outside.mkdir()
    (outside / "secret.txt").write_text("outside", encoding="utf-8")
    (workspace / "escape").symlink_to(outside)

    # The read/list gate still blocks navigation through the escape symlink.
    with pytest.raises(ValueError, match="Path traversal blocked"):
        safe_resolve_ws(workspace, "escape")

    with pytest.raises(ValueError, match="Path traversal blocked"):
        list_dir(workspace, "escape")

    # The escape symlink is now emitted (display-only) with target_outside_workspace=True.
    entries = {e["name"]: e for e in list_dir(workspace, ".")}
    assert "escape" in entries
    assert entries["escape"]["type"] == "symlink"
    assert entries["escape"]["target_outside_workspace"] is True
    # #4581 hardening: display-only escape rows are uniformly is_dir=False — the
    # target's real dir/file nature is target-derived metadata we don't disclose
    # (the row is non-navigable regardless).
    assert entries["escape"]["is_dir"] is False
    assert "target" not in entries["escape"]


def test_read_file_blocks_external_symlink_file(tmp_path):
    workspace = tmp_path / "workspace"
    outside = tmp_path / "outside"
    workspace.mkdir()
    outside.mkdir()
    (outside / "secret.txt").write_text("outside", encoding="utf-8")
    (workspace / "secret-link.txt").symlink_to(outside / "secret.txt")

    # The read gate still blocks reading through the escape symlink.
    with pytest.raises(ValueError, match="Path traversal blocked"):
        read_file_content(workspace, "secret-link.txt")

    # The escape symlink is now emitted (display-only) with target_outside_workspace=True.
    entries = {e["name"]: e for e in list_dir(workspace, ".")}
    assert "secret-link.txt" in entries
    assert entries["secret-link.txt"]["type"] == "symlink"
    assert entries["secret-link.txt"]["target_outside_workspace"] is True
    assert entries["secret-link.txt"]["is_dir"] is False


def test_internal_symlink_still_resolves_within_workspace(tmp_path):
    import api.workspace as w

    workspace = tmp_path / "workspace"
    workspace.mkdir()
    nested = workspace / "nested"
    nested.mkdir()
    (nested / "inside.txt").write_text("inside", encoding="utf-8")
    (workspace / "inside-link.txt").symlink_to(nested / "inside.txt")

    resolved = safe_resolve_ws(workspace, "inside-link.txt")

    assert resolved == (nested / "inside.txt").resolve()
    assert read_file_content(workspace, "inside-link.txt")["content"] == "inside"
    if not w._DIR_FD_OK:
        pytest.skip("internal symlink listing is platform-dependent without dir_fd")
    assert "inside-link.txt" in {entry["name"] for entry in list_dir(workspace, ".")}


def test_authorized_escape_request_reanchors_descendants(tmp_path):
    workspace = tmp_path / "workspace"
    outside = tmp_path / "outside"
    workspace.mkdir()
    (outside / "nested").mkdir(parents=True)
    (outside / "nested" / "inside.txt").write_text("inside", encoding="utf-8")
    (workspace / "escape").symlink_to(outside)

    grant = authorize_escape_target(workspace, "sess-1", "escape")
    resolved = resolve_authorized_escape_request(
        workspace,
        "sess-1",
        grant["token"],
        "escape/nested/inside.txt",
    )

    assert resolved["surface_path"] == "escape"
    assert resolved["external_rel"] == "nested/inside.txt"
    assert resolved["target"] == outside / "nested" / "inside.txt"


def test_authorized_escape_request_expires_when_surface_target_changes(tmp_path):
    workspace = tmp_path / "workspace"
    outside_a = tmp_path / "outside-a"
    outside_b = tmp_path / "outside-b"
    workspace.mkdir()
    outside_a.mkdir()
    outside_b.mkdir()
    escape = workspace / "escape"
    escape.symlink_to(outside_a)

    grant = authorize_escape_target(workspace, "sess-1", "escape")

    escape.unlink()
    escape.symlink_to(outside_b)

    with pytest.raises(ValueError, match="expired"):
        resolve_authorized_escape_request(workspace, "sess-1", grant["token"], "escape")


def test_authorized_escape_request_keeps_other_live_grants(tmp_path):
    workspace = tmp_path / "workspace"
    outside_a = tmp_path / "outside-a"
    outside_b = tmp_path / "outside-b"
    workspace.mkdir()
    outside_a.mkdir()
    outside_b.mkdir()
    (outside_a / "alpha.txt").write_text("alpha", encoding="utf-8")
    (outside_b / "beta.txt").write_text("beta", encoding="utf-8")
    (workspace / "escape-a").symlink_to(outside_a)
    (workspace / "escape-b").symlink_to(outside_b)

    grant_a = authorize_escape_target(workspace, "sess-1", "escape-a")
    grant_b = authorize_escape_target(workspace, "sess-1", "escape-b")

    resolved_a = resolve_authorized_escape_request(
        workspace,
        "sess-1",
        grant_a["token"],
        "escape-a/alpha.txt",
    )
    resolved_b = resolve_authorized_escape_request(
        workspace,
        "sess-1",
        grant_b["token"],
        "escape-b/beta.txt",
    )

    assert resolved_a["target"] == outside_a / "alpha.txt"
    assert resolved_b["target"] == outside_b / "beta.txt"


def test_authorized_listing_keeps_nested_child_escape_display_only(tmp_path):
    workspace = tmp_path / "workspace"
    outside = tmp_path / "outside"
    second_outside = tmp_path / "second-outside"
    workspace.mkdir()
    outside.mkdir()
    second_outside.mkdir()
    (workspace / "escape").symlink_to(outside)
    (outside / "nested-escape").symlink_to(second_outside)

    grant = authorize_escape_target(workspace, "sess-1", "escape")
    payload = list_authorized_escape_dir(workspace, "sess-1", grant["token"], "escape")
    entries = {entry["name"]: entry for entry in payload["entries"]}

    assert entries["nested-escape"]["path"] == "escape/nested-escape"
    assert entries["nested-escape"]["target_outside_workspace"] is True
    assert entries["nested-escape"]["escape_read_only"] is True
    assert "target" not in entries["nested-escape"]


# ── TOCTOU hardening (#3398): a path that passes safe_resolve_ws() but is then
#    swapped to an external symlink before the open must not read/list/write
#    outside the workspace. The read/list/write paths use a portable anchored
#    openat-walk (openat + O_NOFOLLOW per component, dir_fd where supported). ──


def test_read_file_toctou_swap_to_external_symlink_blocked(tmp_path, monkeypatch):
    """If the resolved path is swapped to an external symlink AFTER the
    safe_resolve_ws() check, read_file_content must refuse, not follow the
    symlink and leak external content."""
    import api.workspace as w
    if not w._DIR_FD_OK:
        pytest.skip("TOCTOU symlink-swap hardening requires dir_fd support")

    workspace = tmp_path / "workspace"
    workspace.mkdir()
    (workspace / "data.txt").write_text("LEGIT", encoding="utf-8")
    outside = tmp_path / "outside"
    outside.mkdir()
    (outside / "secret.txt").write_text("SECRET-LEAK", encoding="utf-8")

    real_resolve = w.safe_resolve_ws

    def racing_resolve(root, rel):
        p = real_resolve(root, rel)
        if rel == "data.txt":
            try:
                p.unlink()
            except OSError:
                pass
            p.symlink_to(outside / "secret.txt")
        return p

    monkeypatch.setattr(w, "safe_resolve_ws", racing_resolve)
    try:
        result = w.read_file_content(workspace, "data.txt")
        assert "SECRET" not in result["content"], "TOCTOU symlink swap leaked external content"
    except (FileNotFoundError, ValueError):
        pass  # refused — the correct outcome


def test_list_dir_toctou_swap_to_external_symlink_blocked(tmp_path, monkeypatch):
    """If a checked directory path is swapped to an external symlink after
    safe_resolve_ws(), list_dir must refuse rather than enumerate the external
    directory."""
    import api.workspace as w
    if not w._DIR_FD_OK:
        pytest.skip("TOCTOU symlink-swap hardening requires dir_fd support")

    workspace = tmp_path / "workspace"
    workspace.mkdir()
    (workspace / "sub").mkdir()
    outside = tmp_path / "outside"
    outside.mkdir()
    (outside / "secret.txt").write_text("x", encoding="utf-8")

    real_resolve = w.safe_resolve_ws

    def racing_resolve(root, rel):
        p = real_resolve(root, rel)
        if rel == "sub":
            try:
                p.rmdir()
            except OSError:
                pass
            p.symlink_to(outside)
        return p

    monkeypatch.setattr(w, "safe_resolve_ws", racing_resolve)
    try:
        entries = w.list_dir(workspace, "sub")
        names = {e["name"] for e in entries}
        assert "secret.txt" not in names, "TOCTOU symlink swap leaked external dir listing"
    except (FileNotFoundError, ValueError):
        pass  # refused — the correct outcome


def test_anchored_create_blocks_symlinked_component(tmp_path):
    """open_anchored_create_fd must refuse to write through a symlinked path
    component (the upload / archive-extraction write race), landing nothing
    outside the workspace."""
    import api.workspace as w
    if not w._DIR_FD_OK:
        pytest.skip("anchored symlink-component rejection requires dir_fd support")
    from api.workspace import open_anchored_create_fd

    workspace = tmp_path / "workspace"
    workspace.mkdir()
    outside = tmp_path / "outside"
    outside.mkdir()
    (workspace / "evil").symlink_to(outside)  # symlinked intermediate dir

    with pytest.raises((FileNotFoundError, ValueError, OSError)):
        open_anchored_create_fd(workspace, (workspace / "evil" / "pwned.txt"))
    assert not (outside / "pwned.txt").exists()


def test_anchored_create_no_fd_leak_on_rejection(tmp_path):
    """Repeated rejected anchored creates must not leak file descriptors."""
    import os

    from api.workspace import open_anchored_create_fd

    if not os.path.isdir("/proc/self/fd"):
        pytest.skip("fd-count check requires /proc/self/fd")

    workspace = tmp_path / "workspace"
    workspace.mkdir()
    outside = tmp_path / "outside"
    outside.mkdir()
    (workspace / "evil").symlink_to(outside)

    before = len(os.listdir("/proc/self/fd"))
    for _ in range(200):
        try:
            open_anchored_create_fd(workspace, (workspace / "evil" / "x.txt"))
        except Exception:
            pass
    after = len(os.listdir("/proc/self/fd"))
    assert after <= before + 2, f"fd leak: before={before} after={after}"


def test_anchored_create_nested_autocreates_dirs(tmp_path):
    """A normal (non-escaping) nested create works and lands under the workspace."""
    import os

    from api.workspace import open_anchored_create_fd

    workspace = tmp_path / "workspace"
    workspace.mkdir()
    fd = open_anchored_create_fd(workspace, workspace / "a" / "b" / "file.txt")
    os.write(fd, b"hello")
    os.close(fd)
    assert (workspace / "a" / "b" / "file.txt").read_text() == "hello"


def test_rename_anchored_reports_destination_traversal(tmp_path):
    from api.workspace import rename_anchored

    workspace = tmp_path / "workspace"
    outside = tmp_path / "outside"
    workspace.mkdir()
    outside.mkdir()
    source = workspace / "inside.txt"
    source.write_text("inside", encoding="utf-8")
    dest = outside / "outside.txt"

    with pytest.raises(ValueError) as exc_info:
        rename_anchored(workspace, source, dest)
    assert str(dest) in str(exc_info.value)


def test_list_read_create_work_on_no_dir_fd_fallback(tmp_path, monkeypatch):
    """The no-dir_fd portability fallback (Windows path) must still list, read,
    and create within the workspace, and still hide/block external symlinks via
    the static safe_resolve_ws guard — no fd-relative API that would brick on
    platforms without os.supports_dir_fd."""
    import os

    import api.workspace as w

    monkeypatch.setattr(w, "_DIR_FD_OK", False)

    workspace = tmp_path / "workspace"
    workspace.mkdir()
    (workspace / "a.txt").write_text("hi", encoding="utf-8")
    (workspace / "sub").mkdir()
    (workspace / "internal").symlink_to(workspace / "sub")
    outside = tmp_path / "outside"
    outside.mkdir()
    (outside / "s.txt").write_text("x", encoding="utf-8")
    (workspace / "escape").symlink_to(outside)

    names = {e["name"] for e in w.list_dir(workspace, ".")}
    assert "a.txt" in names
    if w._DIR_FD_OK:
        assert "internal" in names          # legit internal symlink listed
    assert "escape" in names            # external symlink emitted (display-only)
    escape_entry = next(e for e in w.list_dir(workspace, ".") if e["name"] == "escape")
    assert escape_entry["target_outside_workspace"] is True
    assert w.read_file_content(workspace, "a.txt")["content"] == "hi"

    fd = w.open_anchored_create_fd(workspace, workspace / "new" / "f.txt")
    os.write(fd, b"ok")
    os.close(fd)
    assert (workspace / "new" / "f.txt").read_text() == "ok"


def test_read_blocked_when_workspace_root_raced_to_symlink(tmp_path):
    """If the workspace root itself is swapped to an external symlink after
    resolve() but before the anchored open, read_file_content must refuse
    (O_NOFOLLOW on the root open), not follow it and leak external content."""
    import os
    import shutil

    import api.workspace as w

    if not w._DIR_FD_OK:
        pytest.skip("anchored root-open race only applies on dir_fd platforms")

    outside = tmp_path / "evil"
    outside.mkdir()
    (outside / "f.txt").write_text("SECRET-LEAK", encoding="utf-8")
    wsroot = tmp_path / "wsroot"
    wsroot.mkdir()
    (wsroot / "f.txt").write_text("LEGIT", encoding="utf-8")

    real_open = os.open
    state = {"swapped": False}

    def racing_open(path, *args, **kwargs):
        if (not state["swapped"]) and "dir_fd" not in kwargs and str(path) == str(wsroot.resolve()):
            state["swapped"] = True
            shutil.rmtree(str(wsroot))
            os.symlink(str(outside), str(wsroot))
        return real_open(path, *args, **kwargs)

    os.open = racing_open
    try:
        try:
            result = w.read_file_content(wsroot, "f.txt")
            assert "SECRET" not in result["content"], "root-swap race leaked external content"
        except (FileNotFoundError, ValueError, NotADirectoryError, OSError):
            pass  # refused — correct
    finally:
        os.open = real_open


# ── #4510: escape-target symlinks are now emitted as display-only rows ──────
#    The escape filter was widened (not removed): symlinks whose resolved target
#    sits outside the workspace root are now emitted with
#    target_outside_workspace=True instead of being silently dropped. The
#    read/list gate (safe_resolve_ws / open_anchored_fd) is unchanged and still
#    blocks navigation through them. ──────────────────────────────────────────


def test_list_dir_in_workspace_symlink_shape(tmp_path):
    """In-workspace symlinks emit type='symlink' with target_outside_workspace=False."""
    import api.workspace as w

    if not w._DIR_FD_OK:
        pytest.skip("symlink listing requires dir_fd support")

    workspace = tmp_path / "workspace"
    workspace.mkdir()
    (workspace / "data.txt").write_text("hello", encoding="utf-8")
    (workspace / "link.txt").symlink_to(workspace / "data.txt")

    entries = {e["name"]: e for e in w.list_dir(workspace, ".")}
    assert "link.txt" in entries
    assert entries["link.txt"]["type"] == "symlink"
    assert entries["link.txt"]["target_outside_workspace"] is False
    assert entries["link.txt"]["is_dir"] is False
    assert entries["link.txt"]["target"] == str((workspace / "data.txt").resolve())


def test_list_dir_outside_workspace_symlink_emitted_with_flag(tmp_path):
    """Escape-target symlinks are emitted with target_outside_workspace=True."""
    workspace = tmp_path / "workspace"
    outside = tmp_path / "outside"
    workspace.mkdir()
    outside.mkdir()
    (outside / "file.txt").write_text("external", encoding="utf-8")
    (workspace / "ext-link.txt").symlink_to(outside / "file.txt")

    entries = {e["name"]: e for e in list_dir(workspace, ".")}
    assert "ext-link.txt" in entries
    assert entries["ext-link.txt"]["type"] == "symlink"
    assert entries["ext-link.txt"]["target_outside_workspace"] is True
    assert entries["ext-link.txt"]["is_dir"] is False
    # #4581 hardening: a display-only escape-target row must NOT disclose where it
    # points — no resolved outside path, no target-derived size, no target-derived
    # metadata. Only the link name/path + the display-only flag are emitted.
    assert "target" not in entries["ext-link.txt"]
    assert "size" not in entries["ext-link.txt"]


def test_list_dir_external_symlink_blocked_system_path_unchanged(tmp_path):
    """Symlinks to blocked system paths (/etc, /usr) are still filtered out."""
    workspace = tmp_path / "workspace"
    workspace.mkdir()
    (workspace / "etc-link").symlink_to("/etc")
    (workspace / "usr-link").symlink_to("/usr")

    names = {e["name"] for e in list_dir(workspace, ".")}
    assert "etc-link" not in names
    assert "usr-link" not in names


def test_list_dir_escape_symlink_read_still_blocked(tmp_path):
    """Listing shows the escape symlink (display-only) but read_file_content
    on the same target still raises ValueError — proving the read gate is intact."""
    workspace = tmp_path / "workspace"
    outside = tmp_path / "outside"
    workspace.mkdir()
    outside.mkdir()
    (outside / "secret.txt").write_text("secret", encoding="utf-8")
    (workspace / "escape.txt").symlink_to(outside / "secret.txt")

    # Listing emits the entry with target_outside_workspace=True
    entries = {e["name"]: e for e in list_dir(workspace, ".")}
    assert "escape.txt" in entries
    assert entries["escape.txt"]["target_outside_workspace"] is True

    # But reading through it is still blocked
    with pytest.raises(ValueError, match="Path traversal blocked"):
        read_file_content(workspace, "escape.txt")
