"""Tests for content processor module."""

import asyncio
import sys

import pytest

from openzim_mcp.content_processor import ContentProcessor


class TestContentProcessor:
    """Test ContentProcessor class."""

    def test_html_to_plain_text(
        self, content_processor: ContentProcessor, sample_html: str
    ):
        """Test HTML to plain text conversion."""
        result = content_processor.html_to_plain_text(sample_html)

        # Should contain main content
        assert "Main Title" in result
        assert "first paragraph" in result
        assert "bold text" in result

        # Should not contain unwanted elements
        assert "alert('test')" not in result
        assert "Edit section" not in result
        assert "Footer content" not in result

    def test_html_to_plain_text_empty(self, content_processor: ContentProcessor):
        """Test HTML to plain text with empty input."""
        result = content_processor.html_to_plain_text("")
        assert result == ""

    def test_html_to_plain_text_invalid_html(self, content_processor: ContentProcessor):
        """Test HTML to plain text with invalid HTML."""
        result = content_processor.html_to_plain_text("<invalid>test</invalid>")
        assert "test" in result

    def test_create_snippet_short_content(self, content_processor: ContentProcessor):
        """Test creating snippet from short content."""
        content = "This is a short piece of content."
        result = content_processor.create_snippet(content)
        assert result == content

    def test_create_snippet_long_content(self, content_processor: ContentProcessor):
        """Test creating snippet from long content."""
        content = "a" * 200  # Longer than snippet_length (100)
        result = content_processor.create_snippet(content)
        assert len(result) <= 103  # 100 + "..."
        assert result.endswith("...")

    def test_create_snippet_multiple_paragraphs(
        self, content_processor: ContentProcessor
    ):
        """Test creating snippet from multiple paragraphs."""
        content = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph."
        result = content_processor.create_snippet(content, max_paragraphs=2)
        assert "First paragraph" in result
        assert "Second paragraph" in result
        assert "Third paragraph" not in result

    def test_truncate_content_short(self, content_processor: ContentProcessor):
        """Test truncating short content."""
        content = "Short content"
        result = content_processor.truncate_content(content, 100)
        assert result == content

    def test_truncate_content_long(self, content_processor: ContentProcessor):
        """Test truncating long content."""
        content = "a" * 200
        result = content_processor.truncate_content(content, 100)
        assert len(result) > 100  # Includes truncation message
        assert "Content truncated" in result
        assert "200 characters" in result

    def test_process_mime_content_html(self, content_processor: ContentProcessor):
        """Test processing HTML MIME content."""
        html_bytes = b"<html><body><h1>Test</h1></body></html>"
        result = content_processor.process_mime_content(html_bytes, "text/html")
        assert "Test" in result
        assert "<html>" not in result

    def test_process_mime_content_plain_text(self, content_processor: ContentProcessor):
        """Test processing plain text MIME content."""
        text_bytes = b"Plain text content"
        result = content_processor.process_mime_content(text_bytes, "text/plain")
        assert result == "Plain text content"

    def test_process_mime_content_image(self, content_processor: ContentProcessor):
        """Test processing image MIME content."""
        image_bytes = b"fake image data"
        result = content_processor.process_mime_content(image_bytes, "image/png")
        assert "Image content - Cannot display directly" in result

    def test_process_mime_content_unsupported(
        self, content_processor: ContentProcessor
    ):
        """Test processing unsupported MIME content."""
        data_bytes = b"binary data"
        result = content_processor.process_mime_content(
            data_bytes, "application/octet-stream"
        )
        assert "Unsupported content type" in result

    def test_html_to_plain_text_exception_handling(
        self, content_processor: ContentProcessor
    ):
        """Test html_to_plain_text exception handling."""
        # Test with malformed HTML that might cause parsing issues
        malformed_html = "<html><body><div><p>Unclosed tags"

        # This should not raise an exception, but handle it gracefully
        result = content_processor.html_to_plain_text(malformed_html)
        assert "Unclosed tags" in result

    def test_process_mime_content_exception_handling(
        self, content_processor: ContentProcessor
    ):
        """Test process_mime_content exception handling."""
        from unittest.mock import patch

        # Mock the html_to_plain_text method to raise an exception
        with patch.object(
            content_processor, "html_to_plain_text", side_effect=Exception("Test error")
        ):
            result = content_processor.process_mime_content(
                b"<html>test</html>", "text/html"
            )
            assert "Error processing content" in result

    def test_create_snippet_exception_handling(
        self, content_processor: ContentProcessor
    ):
        """Test create_snippet exception handling."""
        from unittest.mock import patch

        # Mock re.sub to raise an exception
        with patch(
            "openzim_mcp.content_processor.re.sub", side_effect=Exception("Test error")
        ):
            result = content_processor.create_snippet("test content")
            # Should return original content when exception occurs (line 92)
            assert result == "test content"

    def test_extract_html_structure_exception_handling(
        self, content_processor: ContentProcessor
    ):
        """Test extract_html_structure exception handling."""
        from unittest.mock import patch

        # Test with content that causes an exception during processing
        with patch(
            "openzim_mcp.content_processor.BeautifulSoup",
            side_effect=Exception("Parse error"),
        ):
            result = content_processor.extract_html_structure(
                "<html><body>test</body></html>"
            )
            # Should return basic structure when exception occurs
            assert "headings" in result
            assert "sections" in result

    def test_extract_html_links_exception_handling(
        self, content_processor: ContentProcessor
    ):
        """Test extract_html_links exception handling."""
        from unittest.mock import patch

        # Test with content that causes an exception during link extraction
        with patch(
            "openzim_mcp.content_processor.BeautifulSoup",
            side_effect=Exception("Parse error"),
        ):
            result = content_processor.extract_html_links(
                "<html><body><a href='test'>link</a></body></html>"
            )
            # Should return empty structure when exception occurs
            assert "internal_links" in result
            assert "external_links" in result

    def test_extract_html_structure(self, content_processor: ContentProcessor):
        """Test HTML structure extraction."""
        html_content = """
        <html>
        <head>
            <title>Test Article</title>
            <meta name="description" content="Test description">
        </head>
        <body>
            <h1 id="intro">Introduction</h1>
            <p>This is the introduction paragraph.</p>
            <h2>Section 1</h2>
            <p>Content of section 1 with multiple words.</p>
            <h3>Subsection 1.1</h3>
            <p>Subsection content here.</p>
            <h2>Section 2</h2>
            <p>Content of section 2.</p>
        </body>
        </html>
        """

        structure = content_processor.extract_html_structure(html_content)

        # Check basic structure
        assert "headings" in structure
        assert "sections" in structure
        assert "metadata" in structure
        assert "word_count" in structure

        # Check headings
        headings = structure["headings"]
        assert len(headings) == 4
        assert headings[0]["level"] == 1
        assert headings[0]["text"] == "Introduction"
        assert headings[0]["id"] == "intro"
        assert headings[1]["level"] == 2
        assert headings[1]["text"] == "Section 1"

        # Check sections
        sections = structure["sections"]
        assert len(sections) > 0
        assert any("Introduction" in section["title"] for section in sections)

        # Check metadata
        metadata = structure["metadata"]
        assert "description" in metadata
        assert metadata["description"] == "Test description"

        # Check word count
        assert structure["word_count"] > 0

    def test_extract_html_structure_empty(self, content_processor: ContentProcessor):
        """Test HTML structure extraction with empty content."""
        structure = content_processor.extract_html_structure("")

        assert "headings" in structure
        assert "sections" in structure
        assert "metadata" in structure
        assert "word_count" in structure
        assert structure["word_count"] == 0

    def test_extract_html_links(self, content_processor: ContentProcessor):
        """Test HTML link extraction."""
        html_content = """
        <html>
        <body>
            <p>Internal link: <a href="C/Other_Article" title="Other Article">
                Link to other article</a></p>
            <p>External link: <a href="https://example.com">Example website</a></p>
            <p>Anchor link: <a href="#section1">Go to section 1</a></p>
            <img src="I/image.jpg" alt="Test image" title="Image title">
            <video src="M/video.mp4">Video content</video>
            <audio src="M/audio.mp3">Audio content</audio>
        </body>
        </html>
        """

        links_data = content_processor.extract_html_links(html_content)

        # Check basic structure
        assert "internal_links" in links_data
        assert "external_links" in links_data
        assert "media_links" in links_data

        # Check internal links
        internal_links = links_data["internal_links"]
        assert (
            len(internal_links) >= 2
        )  # Should have the internal article link and anchor link

        # Find the internal article link
        article_link = next(
            (link for link in internal_links if "Other_Article" in link["url"]), None
        )
        assert article_link is not None
        assert article_link["text"] == "Link to other article"
        assert article_link["title"] == "Other Article"
        assert article_link["type"] == "internal"

        # Find the anchor link
        anchor_link = next(
            (link for link in internal_links if link["url"].startswith("#")), None
        )
        assert anchor_link is not None
        assert anchor_link["type"] == "anchor"

        # Check external links
        external_links = links_data["external_links"]
        assert len(external_links) >= 1
        example_link = next(
            (link for link in external_links if link.get("domain") == "example.com"),
            None,
        )
        assert example_link is not None
        assert example_link["domain"] == "example.com"

        # Check media links
        media_links = links_data["media_links"]
        assert len(media_links) >= 3  # image, video, audio

        # Check for image
        image_link = next(
            (link for link in media_links if link["type"] == "image"), None
        )
        assert image_link is not None
        assert "image.jpg" in image_link["url"]
        assert image_link["alt"] == "Test image"
        assert image_link["title"] == "Image title"

    def test_extract_html_links_empty(self, content_processor: ContentProcessor):
        """Test HTML link extraction with empty content."""
        links_data = content_processor.extract_html_links("")

        assert "internal_links" in links_data
        assert "external_links" in links_data
        assert "media_links" in links_data
        assert len(links_data["internal_links"]) == 0
        assert len(links_data["external_links"]) == 0
        assert len(links_data["media_links"]) == 0


@pytest.mark.asyncio
async def test_html_to_plain_text_is_thread_safe(
    content_processor: ContentProcessor,
) -> None:
    """Concurrent conversions must not interleave their state.

    ``html2text.HTML2Text`` accumulates parser state on ``self`` (``out``,
    ``outtextlist``, ``style``, ``pre``, etc.). Sharing one instance across
    threads lets one thread's state bleed into another's output. This test
    runs many concurrent conversions over moderately complex HTML and
    tightens the GIL switch interval so the interleaving window is wide
    enough to catch leakage in CI.
    """
    # Each document carries a unique marker plus enough structural variety
    # (lists, links, code blocks) to keep html2text inside its handle()
    # state machine across multiple GIL release points.
    distinctive_html = [
        (
            f"<html><body>"
            f"<h1>Article {i}</h1>"
            f"<p>Body {i} with <a href='/p/{i}'>link {i}</a>.</p>"
            f"<ul><li>item-{i}-a</li><li>item-{i}-b</li></ul>"
            f"<pre><code>code-block-{i}</code></pre>"
            f"<p>Trailer {i}.</p>"
            f"</body></html>"
        )
        for i in range(2000)
    ]

    async def convert(html: str) -> str:
        return await asyncio.to_thread(content_processor.html_to_plain_text, html)

    original_interval = sys.getswitchinterval()
    sys.setswitchinterval(0.0001)
    try:
        outs = await asyncio.gather(*(convert(h) for h in distinctive_html))
    finally:
        sys.setswitchinterval(original_interval)

    for i, out in enumerate(outs):
        assert f"Article {i}" in out, f"output {i} corrupted: {out!r}"
        assert f"Body {i}" in out, f"output {i} corrupted: {out!r}"
        assert f"code-block-{i}" in out, f"output {i} corrupted: {out!r}"
        assert f"Trailer {i}" in out, f"output {i} corrupted: {out!r}"


class TestSlugifyHeading:
    """Test heading slug generation, including non-Latin scripts."""

    @pytest.mark.parametrize(
        "heading,expected_substring",
        [
            ("简介", "简介"),
            ("Введение", "введение"),
            ("مقدمة", "مقدمة"),
            ("はじめに", "はじめに"),
            ("Hello World!", "hello-world"),
            ("Étude française", "étude-française"),
        ],
    )
    def test_slugify_heading_preserves_unicode(
        self, heading: str, expected_substring: str
    ):
        """Non-Latin scripts must produce non-empty slugs containing the text.

        The previous NFKD + ASCII-encode approach silently dropped Arabic,
        Chinese, Cyrillic, Japanese, etc. — yielding empty slugs and broken
        TOC anchors for the majority of Wikipedia ZIM files by language.
        """
        from openzim_mcp.content_processor import _slugify_heading

        slug = _slugify_heading(heading)
        assert slug, f"empty slug for {heading!r}"
        assert expected_substring in slug, (
            f"expected {expected_substring!r} in slug {slug!r} "
            f"for heading {heading!r}"
        )

    def test_slugify_heading_empty_input(self):
        """Empty/whitespace input returns empty string for caller fallback."""
        from openzim_mcp.content_processor import _slugify_heading

        assert _slugify_heading("") == ""
        assert _slugify_heading("   ") == ""

    def test_slugify_heading_strips_punctuation(self):
        """Punctuation collapses to hyphens; leading/trailing hyphens stripped."""
        from openzim_mcp.content_processor import _slugify_heading

        assert _slugify_heading("Hello, World!") == "hello-world"
        assert _slugify_heading("  Spaces  Around  ") == "spaces-around"

    def test_slugify_heading_deterministic(self):
        """Same input must always produce the same output."""
        from openzim_mcp.content_processor import _slugify_heading

        text = "Some Heading Title 测试"
        assert _slugify_heading(text) == _slugify_heading(text)

    def test_extract_structure_disambiguates_duplicate_heading_slugs(
        self, content_processor: ContentProcessor
    ):
        """Identical headings get _2, _3 disambiguation suffixes (MediaWiki-style)."""
        html = (
            "<html><body>"
            "<h1>Intro</h1>"
            "<h1>Intro</h1>"
            "<h1>Intro</h1>"
            "</body></html>"
        )
        structure = content_processor.extract_html_structure(html)
        ids = [h["id"] for h in structure["headings"]]
        assert ids == ["intro", "intro_2", "intro_3"]

    def test_extract_structure_does_not_disambiguate_explicit_ids(
        self, content_processor: ContentProcessor
    ):
        """Explicit heading ids should pass through unchanged (no _2 suffix)."""
        html = (
            "<html><body>"
            '<h1 id="real-anchor">Intro</h1>'
            "<h1>Other</h1>"
            "</body></html>"
        )
        structure = content_processor.extract_html_structure(html)
        ids = [h["id"] for h in structure["headings"]]
        assert ids[0] == "real-anchor"
        assert ids[1] == "other"


class TestExtractHtmlLinksFiltering:
    """Test ``extract_html_links`` drops non-navigable URI schemes.

    LLM agents shouldn't be told to follow ``javascript:``, ``mailto:``,
    ``tel:``, ``data:``, ``blob:``, or ``vbscript:`` URLs — they aren't
    fetchable as ZIM entries and ``javascript:``/``vbscript:``/``data:``
    are exfiltration risks if surfaced into a downstream tool call.
    """

    @pytest.mark.parametrize(
        "scheme",
        ["javascript:", "mailto:", "tel:", "data:", "blob:", "vbscript:"],
    )
    def test_non_navigable_scheme_dropped(
        self, content_processor: ContentProcessor, scheme: str
    ):
        """Each NON_NAVIGABLE scheme must be stripped from results."""
        evil = f"{scheme}payload"
        html = (
            f'<html><body><a href="{evil}">click</a>'
            '<a href="real.html">good</a></body></html>'
        )
        links = content_processor.extract_html_links(html)
        all_urls = [
            link["url"]
            for bucket in ("internal_links", "external_links")
            for link in links.get(bucket, [])
        ]
        assert evil not in all_urls, f"non-navigable {scheme!r} link leaked"
        assert "real.html" in all_urls, "navigable link should still be present"

    @pytest.mark.parametrize(
        "scheme_upper",
        ["JavaScript:alert(1)", "MAILTO:foo@bar", "Tel:+15551234"],
    )
    def test_non_navigable_scheme_case_insensitive(
        self, content_processor: ContentProcessor, scheme_upper: str
    ):
        """Filter must be case-insensitive — Java**S**cript: still blocked."""
        html = f'<html><body><a href="{scheme_upper}">x</a></body></html>'
        links = content_processor.extract_html_links(html)
        all_urls = [
            link["url"]
            for bucket in ("internal_links", "external_links")
            for link in links.get(bucket, [])
        ]
        assert scheme_upper not in all_urls


def test_html_to_plain_text_compact_extracts_infobox():
    proc = ContentProcessor()
    html = (
        "<div>"
        '<table class="infobox">'
        "<tr><th>Born</th><td>1879</td></tr>"
        "<tr><th>Died</th><td>1955</td></tr>"
        "</table>"
        "<p>Einstein was a physicist.</p>"
        "</div>"
    )
    out = proc.html_to_plain_text(html, compact=True)
    assert "Born" in out
    assert "1879" in out
    assert "physicist" in out
    # No pipe-soup table
    assert "|" not in out


def test_html_to_plain_text_non_compact_preserves_legacy():
    proc = ContentProcessor()
    html = '<table class="infobox"><tr><th>K</th><td>V</td></tr></table><p>Body</p>'
    out_default = proc.html_to_plain_text(html)
    assert "K" in out_default and "V" in out_default
    assert "Body" in out_default
    # Default behavior must be byte-identical to legacy (no compact flag = compact=False)
    assert out_default == proc.html_to_plain_text(html, compact=False)


def test_html_to_plain_text_compact_replaces_large_tables():
    proc = ContentProcessor()
    rows = "".join("<tr><td>cell</td></tr>" for _ in range(20))
    html = f"<div><table>{rows}</table><p>After table.</p></div>"
    out = proc.html_to_plain_text(html, compact=True)
    assert "Table 1:" in out
    assert "compact=False to expand" in out
    assert "After table." in out


class TestResolveHeadingId:
    """Test the four-step heading-id resolution chain.

    Order: direct ``id`` → descendant anchor (mw-headline) → preceding
    anchor → slugified text. The ``id_source`` return value lets callers
    distinguish synthetic slugs from real anchors that the source HTML
    actually references.
    """

    def test_direct_id_attribute_wins(self):
        """A direct ``id`` on the heading is the highest-priority source."""
        from bs4 import BeautifulSoup

        from openzim_mcp.content_processor import resolve_heading_id

        soup = BeautifulSoup('<h2 id="real">Section</h2>', "html.parser")
        h = soup.find("h2")
        assert h is not None
        anchor_id, source = resolve_heading_id(h)
        assert (anchor_id, source) == ("real", "id")

    def test_mw_headline_descendant_anchor(self):
        """Wikipedia ``<span class="mw-headline" id="X">`` inside <h2>."""
        from bs4 import BeautifulSoup

        from openzim_mcp.content_processor import resolve_heading_id

        soup = BeautifulSoup(
            '<h2><span class="mw-headline" id="My_Section">Section</span></h2>',
            "html.parser",
        )
        h = soup.find("h2")
        assert h is not None
        anchor_id, source = resolve_heading_id(h)
        assert (anchor_id, source) == ("My_Section", "descendant_anchor")

    def test_preceding_named_anchor(self):
        """Older HTML: ``<a name="X"></a>`` immediately before the heading."""
        from bs4 import BeautifulSoup

        from openzim_mcp.content_processor import resolve_heading_id

        soup = BeautifulSoup(
            '<div><a name="anchor1"></a><h2>Section</h2></div>', "html.parser"
        )
        h = soup.find("h2")
        assert h is not None
        anchor_id, source = resolve_heading_id(h)
        assert (anchor_id, source) == ("anchor1", "preceding_anchor")

    def test_synthetic_slug_fallback(self):
        """No anchor anywhere → synthetic slug with source ``slug``."""
        from bs4 import BeautifulSoup

        from openzim_mcp.content_processor import resolve_heading_id

        soup = BeautifulSoup("<h2>Plain Heading</h2>", "html.parser")
        h = soup.find("h2")
        assert h is not None
        anchor_id, source = resolve_heading_id(h)
        assert (anchor_id, source) == ("plain-heading", "slug")

    def test_priority_id_beats_descendant(self):
        """Direct ``id`` wins over any descendant anchor."""
        from bs4 import BeautifulSoup

        from openzim_mcp.content_processor import resolve_heading_id

        soup = BeautifulSoup(
            '<h2 id="direct"><span id="descendant">x</span></h2>',
            "html.parser",
        )
        h = soup.find("h2")
        assert h is not None
        anchor_id, _ = resolve_heading_id(h)
        assert anchor_id == "direct"

    def test_priority_descendant_beats_preceding(self):
        """Descendant anchor wins over preceding-sibling anchor."""
        from bs4 import BeautifulSoup

        from openzim_mcp.content_processor import resolve_heading_id

        soup = BeautifulSoup(
            '<div><a name="prev"></a><h2><span id="inside">x</span></h2></div>',
            "html.parser",
        )
        h = soup.find("h2")
        assert h is not None
        _, source = resolve_heading_id(h)
        assert source == "descendant_anchor"
