"""
Comprehensive tests for PDFStorageManager helper methods.

Focuses on:
- _get_safe_file_path(): Path traversal prevention (security-critical)
- _infer_storage_mode(): Storage detection logic
- _generate_filename(): URL-to-filename conversion
"""

from unittest.mock import Mock

import pytest

from local_deep_research.constants import (
    FILE_PATH_BLOB_DELETED,
    FILE_PATH_METADATA_ONLY,
    FILE_PATH_SENTINELS,
    FILE_PATH_TEXT_ONLY,
)
from local_deep_research.research_library.services.pdf_storage_manager import (
    PDFStorageManager,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _mgr(tmp_path, mode="filesystem"):
    """Shortcut to create a PDFStorageManager rooted at tmp_path."""
    return PDFStorageManager(tmp_path, mode)


# ===========================================================================
# _get_safe_file_path  --  SECURITY-CRITICAL
# ===========================================================================


class TestGetSafeFilePath:
    """Path traversal prevention tests for _get_safe_file_path."""

    # -- Valid paths --

    def test_simple_relative_path(self, tmp_path):
        mgr = _mgr(tmp_path)
        result = mgr._get_safe_file_path("pdfs/paper.pdf")
        assert result is not None
        assert result == tmp_path / "pdfs" / "paper.pdf"

    def test_nested_relative_path(self, tmp_path):
        mgr = _mgr(tmp_path)
        result = mgr._get_safe_file_path("pdfs/2024/01/paper.pdf")
        assert result is not None
        assert str(result).startswith(str(tmp_path))

    def test_filename_only(self, tmp_path):
        mgr = _mgr(tmp_path)
        result = mgr._get_safe_file_path("paper.pdf")
        assert result is not None
        assert result == tmp_path / "paper.pdf"

    # -- Empty / sentinel inputs  → None --

    def test_empty_string_returns_none(self, tmp_path):
        mgr = _mgr(tmp_path)
        assert mgr._get_safe_file_path("") is None

    def test_none_returns_none(self, tmp_path):
        mgr = _mgr(tmp_path)
        assert mgr._get_safe_file_path(None) is None

    @pytest.mark.parametrize("sentinel", list(FILE_PATH_SENTINELS))
    def test_sentinels_return_none(self, tmp_path, sentinel):
        mgr = _mgr(tmp_path)
        assert mgr._get_safe_file_path(sentinel) is None

    def test_metadata_only_sentinel(self, tmp_path):
        mgr = _mgr(tmp_path)
        assert mgr._get_safe_file_path(FILE_PATH_METADATA_ONLY) is None

    def test_text_only_sentinel(self, tmp_path):
        mgr = _mgr(tmp_path)
        assert mgr._get_safe_file_path(FILE_PATH_TEXT_ONLY) is None

    def test_blob_deleted_sentinel(self, tmp_path):
        mgr = _mgr(tmp_path)
        assert mgr._get_safe_file_path(FILE_PATH_BLOB_DELETED) is None

    # -- Path traversal attacks  → None --

    def test_dot_dot_simple(self, tmp_path):
        mgr = _mgr(tmp_path)
        assert mgr._get_safe_file_path("../etc/passwd") is None

    def test_dot_dot_deep(self, tmp_path):
        mgr = _mgr(tmp_path)
        assert mgr._get_safe_file_path("../../../../../../etc/shadow") is None

    def test_dot_dot_in_middle(self, tmp_path):
        mgr = _mgr(tmp_path)
        assert mgr._get_safe_file_path("pdfs/../../etc/passwd") is None

    def test_dot_dot_encoded_slash(self, tmp_path):
        """URL-encoded slashes should not bypass the check."""
        mgr = _mgr(tmp_path)
        # Even if someone encodes slashes, PathValidator should catch it
        assert mgr._get_safe_file_path("..%2F..%2Fetc%2Fpasswd") is None or str(
            mgr._get_safe_file_path("..%2F..%2Fetc%2Fpasswd")
        ).startswith(str(tmp_path))

    def test_backslash_traversal(self, tmp_path):
        """Backslash-based traversal (Windows-style)."""
        mgr = _mgr(tmp_path)
        result = mgr._get_safe_file_path("..\\..\\etc\\passwd")
        # Should either be None or safely contained within library_root
        if result is not None:
            assert str(result.resolve()).startswith(str(tmp_path))

    def test_absolute_path_rejected(self, tmp_path):
        mgr = _mgr(tmp_path)
        result = mgr._get_safe_file_path("/etc/passwd")
        # Absolute paths outside library_root must be blocked
        if result is not None:
            assert str(result.resolve()).startswith(str(tmp_path))

    def test_null_byte_injection(self, tmp_path):
        """Null bytes must not bypass validation."""
        mgr = _mgr(tmp_path)
        result = mgr._get_safe_file_path("pdfs/paper.pdf\x00.txt")
        # Should be blocked (None) or safely contained
        if result is not None:
            assert str(result.resolve()).startswith(str(tmp_path))

    def test_dot_dot_with_trailing_slash(self, tmp_path):
        mgr = _mgr(tmp_path)
        assert mgr._get_safe_file_path("../") is None

    def test_current_dir_reference(self, tmp_path):
        """Single dot should still resolve within library root."""
        mgr = _mgr(tmp_path)
        result = mgr._get_safe_file_path("./pdfs/paper.pdf")
        # This is actually safe, but PathValidator may reject it
        if result is not None:
            assert str(result.resolve()).startswith(str(tmp_path))

    def test_double_dot_slash_variations(self, tmp_path):
        """Various .. patterns that attackers try."""
        mgr = _mgr(tmp_path)
        attacks = [
            "../",
            "..\\",
            "../../../etc/passwd",
            "pdfs/../../../etc/passwd",
            "pdfs/./../../etc/passwd",
            "....//....//etc/passwd",
        ]
        for attack in attacks:
            result = mgr._get_safe_file_path(attack)
            if result is not None:
                assert str(result.resolve()).startswith(str(tmp_path)), (
                    f"Attack '{attack}' escaped library root to {result}"
                )

    def test_symlink_blocked(self, tmp_path):
        """Symlinks pointing outside library root should be blocked."""
        # Create a symlink inside tmp_path that points to /etc
        link_path = tmp_path / "pdfs"
        link_path.mkdir(parents=True, exist_ok=True)
        symlink = tmp_path / "pdfs" / "evil.pdf"
        try:
            symlink.symlink_to("/etc/passwd")
        except (OSError, PermissionError):
            pytest.skip("Cannot create symlinks in this environment")

        mgr = _mgr(tmp_path)
        result = mgr._get_safe_file_path("pdfs/evil.pdf")
        assert result is None, "Symlinks must be blocked"

    def test_long_path_with_valid_characters(self, tmp_path):
        """Very long but valid relative paths should work."""
        mgr = _mgr(tmp_path)
        long_path = "pdfs/" + "a" * 200 + ".pdf"
        result = mgr._get_safe_file_path(long_path)
        # Should succeed if the path is within library root
        if result is not None:
            assert str(result).startswith(str(tmp_path))


# ===========================================================================
# _infer_storage_mode
# ===========================================================================


class TestInferStorageMode:
    """Tests for backward-compatible storage mode inference."""

    def test_blob_present_means_database(self, tmp_path):
        mgr = _mgr(tmp_path)
        doc = Mock()
        doc.blob = Mock()  # truthy blob
        doc.file_path = "pdfs/test.pdf"
        assert mgr._infer_storage_mode(doc) == "database"

    def test_blob_none_with_file_path_means_filesystem(self, tmp_path):
        mgr = _mgr(tmp_path)
        doc = Mock()
        doc.blob = None
        doc.file_path = "pdfs/test.pdf"
        assert mgr._infer_storage_mode(doc) == "filesystem"

    def test_blob_none_with_no_file_path_means_none(self, tmp_path):
        mgr = _mgr(tmp_path)
        doc = Mock()
        doc.blob = None
        doc.file_path = None
        assert mgr._infer_storage_mode(doc) == "none"

    def test_blob_none_with_empty_file_path_means_none(self, tmp_path):
        mgr = _mgr(tmp_path)
        doc = Mock()
        doc.blob = None
        doc.file_path = ""
        assert mgr._infer_storage_mode(doc) == "none"

    @pytest.mark.parametrize("sentinel", list(FILE_PATH_SENTINELS))
    def test_sentinels_infer_as_none(self, tmp_path, sentinel):
        """All sentinel values should infer as 'none' storage."""
        mgr = _mgr(tmp_path)
        doc = Mock()
        doc.blob = None
        doc.file_path = sentinel
        assert mgr._infer_storage_mode(doc) == "none"

    def test_blob_takes_precedence_over_sentinel(self, tmp_path):
        """If blob is present AND file_path is sentinel, blob wins."""
        mgr = _mgr(tmp_path)
        doc = Mock()
        doc.blob = Mock()
        doc.file_path = FILE_PATH_METADATA_ONLY
        assert mgr._infer_storage_mode(doc) == "database"

    def test_blob_takes_precedence_over_file_path(self, tmp_path):
        """If both blob and file_path exist, blob (database) wins."""
        mgr = _mgr(tmp_path)
        doc = Mock()
        doc.blob = Mock()
        doc.file_path = "pdfs/test.pdf"
        assert mgr._infer_storage_mode(doc) == "database"

    def test_no_blob_attribute_means_no_database(self, tmp_path):
        """Document without blob attribute at all should not be 'database'."""
        mgr = _mgr(tmp_path)
        doc = Mock(spec=["file_path"])  # no blob attribute
        doc.file_path = "pdfs/test.pdf"
        assert mgr._infer_storage_mode(doc) == "filesystem"

    def test_no_blob_no_file_path_attribute(self, tmp_path):
        """Document with neither blob nor file_path should be 'none'."""
        mgr = _mgr(tmp_path)
        doc = Mock(spec=["file_path"])
        doc.file_path = None
        assert mgr._infer_storage_mode(doc) == "none"

    def test_empty_blob_is_falsy(self, tmp_path):
        """An empty/falsy blob should not count as database storage."""
        mgr = _mgr(tmp_path)
        doc = Mock()
        doc.blob = []  # falsy
        doc.file_path = "pdfs/paper.pdf"
        assert mgr._infer_storage_mode(doc) == "filesystem"

    def test_blob_false_is_falsy(self, tmp_path):
        mgr = _mgr(tmp_path)
        doc = Mock()
        doc.blob = False
        doc.file_path = "pdfs/paper.pdf"
        assert mgr._infer_storage_mode(doc) == "filesystem"


# ===========================================================================
# _generate_filename
# ===========================================================================


class TestGenerateFilename:
    """URL-to-filename conversion tests."""

    # -- arXiv --

    def test_arxiv_standard_url(self, tmp_path):
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://arxiv.org/pdf/2401.12345.pdf", None, "fallback.pdf"
        )
        assert fn == "arxiv_2401.12345.pdf"

    def test_arxiv_abs_url(self, tmp_path):
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://arxiv.org/abs/2301.1234", 42, "fallback.pdf"
        )
        assert fn == "arxiv_2301.1234.pdf"

    def test_arxiv_4digit_id(self, tmp_path):
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://arxiv.org/abs/2301.1234", None, "fallback.pdf"
        )
        assert "2301.1234" in fn
        assert fn.startswith("arxiv_")
        assert fn.endswith(".pdf")

    def test_arxiv_5digit_id(self, tmp_path):
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://arxiv.org/pdf/2401.12345v2", None, "fallback.pdf"
        )
        assert "2401.12345" in fn

    def test_arxiv_subdomain(self, tmp_path):
        """export.arxiv.org should be recognized as arXiv."""
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://export.arxiv.org/pdf/2301.54321", 5, "fallback.pdf"
        )
        assert fn == "arxiv_2301.54321.pdf"

    def test_arxiv_no_id_in_url(self, tmp_path):
        """arXiv URL without recognizable ID falls back to timestamp."""
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://arxiv.org/some/other/path", 99, "fallback.pdf"
        )
        assert fn.startswith("arxiv_")
        assert fn.endswith(".pdf")
        assert "99" in fn  # resource_id used

    def test_arxiv_no_id_no_resource_id(self, tmp_path):
        """arXiv URL without ID and no resource_id uses 'unknown'."""
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://arxiv.org/some/other/path", None, "fallback.pdf"
        )
        assert "unknown" in fn
        assert fn.startswith("arxiv_")

    # -- PubMed / PMC --

    def test_pmc_standard_url(self, tmp_path):
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://ncbi.nlm.nih.gov/pmc/articles/PMC1234567/pdf/",
            None,
            "fallback.pdf",
        )
        assert fn == "pmc_PMC1234567.pdf"

    def test_pmc_with_resource_id(self, tmp_path):
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://ncbi.nlm.nih.gov/pmc/articles/PMC9876543/",
            42,
            "fallback.pdf",
        )
        assert fn == "pmc_PMC9876543.pdf"

    def test_pmc_no_id_in_url(self, tmp_path):
        """PMC URL without recognizable PMC ID falls back to timestamp."""
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://ncbi.nlm.nih.gov/pmc/about/", 10, "fallback.pdf"
        )
        assert fn.startswith("pubmed_")
        assert fn.endswith(".pdf")
        assert "10" in fn

    def test_pmc_no_id_no_resource_id(self, tmp_path):
        """PMC URL without ID and no resource_id uses 'unknown'."""
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://ncbi.nlm.nih.gov/pmc/about/", None, "fallback.pdf"
        )
        assert "unknown" in fn
        assert fn.startswith("pubmed_")

    def test_ncbi_non_pmc_path_uses_fallback(self, tmp_path):
        """NCBI URL without /pmc in path should use fallback."""
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://ncbi.nlm.nih.gov/pubmed/12345678",
            None,
            "my_paper.pdf",
        )
        assert fn == "my_paper.pdf"

    # -- Fallback --

    def test_unknown_url_uses_fallback(self, tmp_path):
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://example.com/paper.pdf", None, "my_paper.pdf"
        )
        assert fn == "my_paper.pdf"

    def test_unknown_url_preserves_fallback_exactly(self, tmp_path):
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://nature.com/articles/12345/download",
            100,
            "document_20240101.pdf",
        )
        assert fn == "document_20240101.pdf"

    def test_fallback_with_special_characters(self, tmp_path):
        """Fallback filename with special chars is returned as-is."""
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://example.com/", None, "a file (1).pdf"
        )
        assert fn == "a file (1).pdf"

    # -- Edge cases --

    def test_empty_url_uses_fallback(self, tmp_path):
        """Empty URL should use fallback filename."""
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename("", None, "fallback.pdf")
        assert fn == "fallback.pdf"

    def test_url_with_no_hostname(self, tmp_path):
        """URL with no hostname should use fallback."""
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "file:///local/paper.pdf", None, "fallback.pdf"
        )
        assert fn == "fallback.pdf"

    def test_http_vs_https_arxiv(self, tmp_path):
        """Both http and https arXiv URLs should work."""
        mgr = _mgr(tmp_path)
        fn_http = mgr._generate_filename(
            "http://arxiv.org/abs/2301.12345", None, "fallback.pdf"
        )
        fn_https = mgr._generate_filename(
            "https://arxiv.org/abs/2301.12345", None, "fallback.pdf"
        )
        assert fn_http == fn_https == "arxiv_2301.12345.pdf"

    def test_arxiv_with_query_params(self, tmp_path):
        """arXiv URL with query params should still extract ID."""
        mgr = _mgr(tmp_path)
        fn = mgr._generate_filename(
            "https://arxiv.org/pdf/2301.12345?download=true",
            None,
            "fallback.pdf",
        )
        assert "2301.12345" in fn


# ===========================================================================
# __init__ validation
# ===========================================================================


class TestInitValidation:
    """Verify constructor handles edge cases."""

    def test_unknown_mode_defaults_to_none(self, tmp_path):
        mgr = PDFStorageManager(tmp_path, "s3")
        assert mgr.storage_mode == "none"

    def test_empty_string_mode_defaults_to_none(self, tmp_path):
        mgr = PDFStorageManager(tmp_path, "")
        assert mgr.storage_mode == "none"

    def test_library_root_is_resolved(self, tmp_path):
        relative = tmp_path / "sub" / ".." / "sub"
        relative.mkdir(parents=True, exist_ok=True)
        mgr = PDFStorageManager(relative, "filesystem")
        # Should be resolved (no ..)
        assert ".." not in str(mgr.library_root)

    def test_max_size_calculation(self, tmp_path):
        mgr = PDFStorageManager(tmp_path, "none", max_pdf_size_mb=50)
        assert mgr.max_pdf_size_bytes == 50 * 1024 * 1024

    def test_default_max_size(self, tmp_path):
        mgr = PDFStorageManager(tmp_path, "none")
        assert mgr.max_pdf_size_bytes == 3072 * 1024 * 1024
