"""Security tests: path-traversal / symlink confinement (Constitution Principle V).

These assert the bundler refuses to read or write outside an allowed root, so a
malicious manifest or artifact path cannot escape the project/bundle directory.
"""
from __future__ import annotations

import os
from pathlib import Path

import pytest

from specify_cli.bundler import BundlerError
from specify_cli.bundler.lib.yamlio import ensure_within, is_safe_relpath


def test_ensure_within_allows_child(tmp_path: Path):
    root = tmp_path / "bundle"
    root.mkdir()
    child = root / "sub" / "file.txt"
    assert ensure_within(root, child) == child.resolve()


def test_ensure_within_rejects_parent_traversal(tmp_path: Path):
    root = tmp_path / "bundle"
    root.mkdir()
    escape = root / ".." / "secret.txt"
    with pytest.raises(BundlerError, match="escapes"):
        ensure_within(root, escape)


def test_ensure_within_rejects_absolute_outside(tmp_path: Path):
    root = tmp_path / "bundle"
    root.mkdir()
    with pytest.raises(BundlerError):
        ensure_within(root, Path("/etc/passwd"))


@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows")
def test_ensure_within_rejects_symlink_escape(tmp_path: Path):
    root = tmp_path / "bundle"
    root.mkdir()
    outside = tmp_path / "outside.txt"
    outside.write_text("secret", encoding="utf-8")
    link = root / "link.txt"
    link.symlink_to(outside)
    with pytest.raises(BundlerError, match="escapes"):
        ensure_within(root, link)


@pytest.mark.parametrize("rel,safe", [
    ("a/b.txt", True),
    ("./a.txt", True),
    ("../escape", False),
    ("a/../../escape", False),
    ("/abs", False),
    ("C:/abs", False),
    ("C:\\abs", False),
    ("\\\\server\\share", False),
    ("", False),
])
def test_is_safe_relpath(rel, safe):
    assert is_safe_relpath(rel) is safe


def test_build_skips_symlinks(tmp_path: Path):
    """Packager must not follow symlinks out of the bundle dir."""
    import yaml

    from specify_cli.bundler.services.packager import build_bundle
    from tests.bundler_helpers import valid_manifest_dict

    bundle = tmp_path / "bundle"
    bundle.mkdir()
    (bundle / "bundle.yml").write_text(
        yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
    )
    (bundle / "README.md").write_text("# Demo", encoding="utf-8")

    if os.name != "nt":
        secret = tmp_path / "secret.txt"
        secret.write_text("top secret", encoding="utf-8")
        (bundle / "leak.txt").symlink_to(secret)

    result = build_bundle(bundle, output_dir=tmp_path / "out")
    import zipfile

    with zipfile.ZipFile(result.artifact_path) as archive:
        names = archive.namelist()
    assert "leak.txt" not in names
    assert "bundle.yml" in names


def test_load_records_refuses_symlinked_specify_escape(tmp_path: Path):
    # Reading bundle-records.json must honour the same confinement as writes:
    # a symlinked .specify pointing outside project_root is refused.
    from specify_cli.bundler.models.records import load_records

    project = tmp_path / "proj"
    project.mkdir()
    outside = tmp_path / "outside"
    outside.mkdir()
    (outside / "bundle-records.json").write_text(
        '{"schema_version": "1.0", "bundles": []}', encoding="utf-8"
    )
    (project / ".specify").symlink_to(outside, target_is_directory=True)

    with pytest.raises(BundlerError, match="escapes the allowed root"):
        load_records(project)


def test_active_integration_refuses_symlinked_specify_escape(tmp_path: Path):
    # Reading the integration marker must not follow a .specify symlink that
    # resolves outside project_root; an escape is treated as "not determinable".
    from specify_cli.bundler.lib.project import active_integration

    project = tmp_path / "proj"
    project.mkdir()
    outside = tmp_path / "outside"
    outside.mkdir()
    (outside / "integration.json").write_text(
        '{"integration": "leaked"}', encoding="utf-8"
    )
    (project / ".specify").symlink_to(outside, target_is_directory=True)

    assert active_integration(project) is None


def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path):
    from specify_cli.bundler.commands_impl import catalog_config as cc

    project = tmp_path / "proj"
    project.mkdir()
    outside = tmp_path / "outside"
    outside.mkdir()
    (outside / "bundle-catalogs.yml").write_text(
        "schema_version: '1.0'\ncatalogs: []\n", encoding="utf-8"
    )
    (project / ".specify").symlink_to(outside, target_is_directory=True)

    with pytest.raises(BundlerError, match="escapes the allowed root"):
        cc._read(project)


def test_load_source_stack_refuses_symlinked_specify_dir(tmp_path: Path):
    from specify_cli.bundler.models.catalog import load_source_stack

    project = tmp_path / "project"
    project.mkdir()
    outside = tmp_path / "outside"
    outside.mkdir()
    (outside / "bundle-catalogs.yml").write_text("catalogs: []\n", encoding="utf-8")
    try:
        (project / ".specify").symlink_to(outside, target_is_directory=True)
    except (OSError, NotImplementedError):
        pytest.skip("symlinks not supported on this platform")
    with pytest.raises(BundlerError, match="escapes the allowed root"):
        load_source_stack(project)


def test_find_project_root_ignores_symlinked_specify(tmp_path: Path):
    from specify_cli.bundler.lib.project import find_project_root

    real = tmp_path / "real-specify"
    real.mkdir()
    project = tmp_path / "project"
    project.mkdir()
    try:
        (project / ".specify").symlink_to(real, target_is_directory=True)
    except (OSError, NotImplementedError):
        pytest.skip("symlinks not supported on this platform")
    # A symlinked .specify must not be accepted as a project root.
    assert find_project_root(project) is None
