"""
Tests for the Author Replies feature.
"""

import tempfile
import unittest
from pathlib import Path


class ReplyRouteTest(unittest.TestCase):
    """Test reply routes render correctly and return expected status codes."""

    def setUp(self):
        from madblog.app import app
        from madblog.config import config

        self.app = app
        self.config = config

        self._tmpdir = tempfile.TemporaryDirectory()
        self.addCleanup(self._tmpdir.cleanup)

        root = Path(self._tmpdir.name)
        markdown_dir = root / "markdown"
        markdown_dir.mkdir(parents=True, exist_ok=True)

        # Create an article
        (markdown_dir / "my-post.md").write_text(
            "\n".join(
                [
                    "[//]: # (title: My Post)",
                    "[//]: # (published: 2025-07-01)",
                    "",
                    "# My Post",
                    "Article body.",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        # Create replies directory and a reply
        replies_dir = root / "replies" / "my-post"
        replies_dir.mkdir(parents=True, exist_ok=True)

        (replies_dir / "thanks-alice.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://mastodon.social/users/alice/statuses/123)",
                    "[//]: # (published: 2025-07-02)",
                    "",
                    "# Re: Thanks Alice",
                    "Thank you for your thoughtful response!",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        # Create a guestbook reply
        guestbook_replies_dir = root / "replies" / "_guestbook"
        guestbook_replies_dir.mkdir(parents=True, exist_ok=True)

        (guestbook_replies_dir / "welcome.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://mastodon.social/users/bob/statuses/456)",
                    "[//]: # (published: 2025-07-03)",
                    "",
                    "# Welcome",
                    "Welcome to my blog!",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        self._orig_content_dir = config.content_dir
        self._orig_link = config.link
        self._orig_title = config.title

        config.content_dir = str(root)
        config.link = "https://example.com"
        config.title = "Test Blog"

        self.app.pages_dir = markdown_dir
        self.app.replies_dir = root / "replies"

    def tearDown(self):
        self.config.content_dir = self._orig_content_dir
        self.config.link = self._orig_link
        self.config.title = self._orig_title

    def test_reply_route_renders(self):
        """Requesting /reply/<article>/<reply> returns 200 with rendered content."""
        with self.app.test_client() as client:
            resp = client.get("/reply/my-post/thanks-alice")
            self.assertEqual(resp.status_code, 200)
            html = resp.get_data(as_text=True)
            self.assertIn("Thank you for your thoughtful response!", html)
            self.assertIn("Re: Thanks Alice", html)

    def test_reply_backlink_rendered(self):
        """The reply page should show a back-link to the reply-to URL."""
        with self.app.test_client() as client:
            resp = client.get("/reply/my-post/thanks-alice")
            html = resp.get_data(as_text=True)
            self.assertIn("https://mastodon.social/users/alice/statuses/123", html)

    def test_reply_raw_markdown(self):
        """Requesting /reply/<article>/<reply>.md returns raw Markdown."""
        with self.app.test_client() as client:
            resp = client.get("/reply/my-post/thanks-alice.md")
            self.assertEqual(resp.status_code, 200)
            text = resp.get_data(as_text=True)
            self.assertIn("Thank you for your thoughtful response!", text)
            self.assertEqual(resp.mimetype, "text/markdown")

    def test_reply_404_nonexistent(self):
        """Requesting a non-existent reply returns 404."""
        with self.app.test_client() as client:
            resp = client.get("/reply/my-post/nonexistent")
            self.assertEqual(resp.status_code, 404)

    def test_reply_404_nonexistent_article(self):
        """Requesting a reply under a non-existent article slug returns 404."""
        with self.app.test_client() as client:
            resp = client.get("/reply/no-such-article/thanks-alice")
            self.assertEqual(resp.status_code, 404)

    def test_guestbook_reply_route(self):
        """Guestbook replies under _guestbook pseudo-slug are accessible."""
        with self.app.test_client() as client:
            resp = client.get("/reply/_guestbook/welcome")
            self.assertEqual(resp.status_code, 200)
            html = resp.get_data(as_text=True)
            self.assertIn("Welcome to my blog!", html)

    def test_reply_cache_headers(self):
        """Reply responses include standard cache headers."""
        with self.app.test_client() as client:
            resp = client.get("/reply/my-post/thanks-alice")
            self.assertIn("Last-Modified", resp.headers)
            self.assertIn("ETag", resp.headers)
            self.assertIn("Cache-Control", resp.headers)

    def test_reply_published_date(self):
        """The reply page should show the published date."""
        with self.app.test_client() as client:
            resp = client.get("/reply/my-post/thanks-alice")
            html = resp.get_data(as_text=True)
            self.assertIn("Jul 02, 2025", html)

    def test_reply_ap_redirect_split_domain(self):
        """AP client on blog domain gets 302 to AP-domain reply URL."""
        from unittest.mock import MagicMock

        mock_integration = MagicMock()
        mock_integration.reply_file_to_url.return_value = (
            "https://ap.example.com/reply/my-post/thanks-alice"
        )

        self.app._ap_integration = mock_integration
        self.app.activitypub_handler = MagicMock()

        orig_ap_link = self.config.activitypub_link
        self.config.activitypub_link = "https://ap.example.com"
        try:
            with self.app.test_client() as client:
                resp = client.get(
                    "/reply/my-post/thanks-alice",
                    headers={
                        "Accept": "application/activity+json",
                        "Host": "example.com",
                    },
                )
                self.assertEqual(resp.status_code, 302)
                self.assertEqual(
                    resp.headers["Location"],
                    "https://ap.example.com/reply/my-post/thanks-alice",
                )
        finally:
            self.config.activitypub_link = orig_ap_link
            del self.app._ap_integration
            del self.app.activitypub_handler

    def test_reply_no_redirect_html_client(self):
        """HTML client on blog domain does NOT get redirected."""
        from unittest.mock import MagicMock

        mock_integration = MagicMock()
        self.app._ap_integration = mock_integration
        self.app.activitypub_handler = MagicMock()

        orig_ap_link = self.config.activitypub_link
        self.config.activitypub_link = "https://ap.example.com"
        try:
            with self.app.test_client() as client:
                resp = client.get(
                    "/reply/my-post/thanks-alice",
                    headers={
                        "Accept": "text/html",
                        "Host": "example.com",
                    },
                )
                self.assertEqual(resp.status_code, 200)
        finally:
            self.config.activitypub_link = orig_ap_link
            del self.app._ap_integration
            del self.app.activitypub_handler


class RepliesExcludedFromHomeTest(unittest.TestCase):
    """Test that replies do not appear in the home page listing."""

    def setUp(self):
        from madblog.app import app
        from madblog.config import config

        self.app = app
        self.config = config

        self._tmpdir = tempfile.TemporaryDirectory()
        self.addCleanup(self._tmpdir.cleanup)

        root = Path(self._tmpdir.name)

        # Use content_dir directly as pages_dir (no markdown/ subdir)
        # This is the case where replies_dir is a subdirectory of pages_dir.
        (root / "article.md").write_text(
            "\n".join(
                [
                    "[//]: # (title: My Article)",
                    "[//]: # (published: 2025-07-01)",
                    "",
                    "# My Article",
                    "Body.",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        replies_dir = root / "replies" / "article"
        replies_dir.mkdir(parents=True, exist_ok=True)
        (replies_dir / "reply1.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://example.com/article/article)",
                    "[//]: # (published: 2025-07-02)",
                    "",
                    "# Reply 1",
                    "A reply body.",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        self._orig_content_dir = config.content_dir
        self._orig_link = config.link
        self._orig_title = config.title

        config.content_dir = str(root)
        config.link = "https://example.com"
        config.title = "Test Blog"

        # Simulate fallback mode: pages_dir == content_dir
        self.app.pages_dir = root
        self.app.replies_dir = root / "replies"

    def tearDown(self):
        self.config.content_dir = self._orig_content_dir
        self.config.link = self._orig_link
        self.config.title = self._orig_title

    def test_replies_excluded_from_pages_listing(self):
        """Files under replies/ should not appear in _get_pages_from_files()."""
        with self.app.app_context():
            pages = self.app._get_pages_from_files()

        titles = [p.get("title") for p in pages]
        self.assertIn("My Article", titles)
        self.assertNotIn("Reply 1", titles)


class ReplyMetadataParsingTest(unittest.TestCase):
    """Test that reply metadata is parsed correctly."""

    def setUp(self):
        from madblog.app import app
        from madblog.config import config

        self.app = app
        self.config = config

        self._tmpdir = tempfile.TemporaryDirectory()
        self.addCleanup(self._tmpdir.cleanup)

        root = Path(self._tmpdir.name)

        replies_dir = root / "replies" / "my-post"
        replies_dir.mkdir(parents=True, exist_ok=True)

        (replies_dir / "test-reply.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://remote.social/statuses/999)",
                    "[//]: # (published: 2025-07-10)",
                    "[//]: # (title: Test Reply Title)",
                    "",
                    "# Test Reply Title",
                    "Reply content here.",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        self._orig_content_dir = config.content_dir
        self._orig_link = config.link

        config.content_dir = str(root)
        config.link = "https://example.com"

        self.app.replies_dir = root / "replies"

    def tearDown(self):
        self.config.content_dir = self._orig_content_dir
        self.config.link = self._orig_link

    def test_reply_to_extracted(self):
        """The reply-to metadata field is correctly parsed."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata("my-post", "test-reply")
        self.assertEqual(metadata["reply-to"], "https://remote.social/statuses/999")

    def test_reply_uri_scheme(self):
        """The URI uses the /reply/ scheme."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata("my-post", "test-reply")
        self.assertEqual(metadata["uri"], "/reply/my-post/test-reply")

    def test_reply_title_parsed(self):
        """The title is parsed from metadata."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata("my-post", "test-reply")
        self.assertEqual(metadata["title"], "Test Reply Title")

    def test_reply_published_date(self):
        """The published datetime is parsed from metadata."""
        import datetime

        with self.app.app_context():
            metadata = self.app._parse_reply_metadata("my-post", "test-reply")
        self.assertEqual(
            metadata["published"],
            datetime.datetime(2025, 7, 10, tzinfo=datetime.timezone.utc),
        )


class ReplyTitleInferenceTest(unittest.TestCase):
    """Test title inference for replies with various content layouts."""

    def setUp(self):
        from madblog.app import app
        from madblog.config import config

        self.app = app
        self.config = config

        self._tmpdir = tempfile.TemporaryDirectory()
        self.addCleanup(self._tmpdir.cleanup)

        root = Path(self._tmpdir.name)
        replies_dir = root / "replies" / "some-post"
        replies_dir.mkdir(parents=True, exist_ok=True)

        # Reply with heading but no title metadata
        (replies_dir / "heading-only.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://example.com/article/some-post)",
                    "[//]: # (published: 2025-07-01)",
                    "",
                    "# My Heading Title",
                    "Some content.",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        # Reply with no heading and no title metadata
        (replies_dir / "no-title.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://example.com/article/some-post)",
                    "[//]: # (published: 2025-07-01)",
                    "",
                    "Just a quick reply with no heading.",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        self._orig_content_dir = config.content_dir
        self._orig_link = config.link
        config.content_dir = str(root)
        config.link = "https://example.com"
        self.app.replies_dir = root / "replies"

    def tearDown(self):
        self.config.content_dir = self._orig_content_dir
        self.config.link = self._orig_link

    def test_title_inferred_from_heading(self):
        """Title is inferred from # heading when no title metadata is present."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata("some-post", "heading-only")
        self.assertEqual(metadata["title"], "My Heading Title")

    def test_title_falls_back_to_slug(self):
        """Title falls back to the reply slug when no heading or metadata."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata("some-post", "no-title")
        self.assertEqual(metadata["title"], "no-title")


class LikeOfMetadataParsingTest(unittest.TestCase):
    """Test that like-of metadata is parsed correctly from replies."""

    def setUp(self):
        from madblog.app import app
        from madblog.config import config

        self.app = app
        self.config = config

        self._tmpdir = tempfile.TemporaryDirectory()
        self.addCleanup(self._tmpdir.cleanup)

        root = Path(self._tmpdir.name)

        replies_dir = root / "replies" / "my-post"
        replies_dir.mkdir(parents=True, exist_ok=True)

        # Reply with like-of only
        (replies_dir / "like-only.md").write_text(
            "\n".join(
                [
                    "[//]: # (like-of: https://remote.social/statuses/42)",
                    "[//]: # (published: 2025-07-10)",
                    "",
                    "# I liked this",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        # Reply with both reply-to and like-of
        (replies_dir / "reply-and-like.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://remote.social/statuses/999)",
                    "[//]: # (like-of: https://remote.social/statuses/42)",
                    "[//]: # (published: 2025-07-10)",
                    "",
                    "# Reply with like",
                    "Some content.",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        # Reply with no like-of
        (replies_dir / "no-like.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://remote.social/statuses/999)",
                    "[//]: # (published: 2025-07-10)",
                    "",
                    "# Plain reply",
                    "Content.",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        self._orig_content_dir = config.content_dir
        self._orig_link = config.link

        config.content_dir = str(root)
        config.link = "https://example.com"

        self.app.replies_dir = root / "replies"

    def tearDown(self):
        self.config.content_dir = self._orig_content_dir
        self.config.link = self._orig_link

    def test_like_of_extracted(self):
        """The like-of metadata field is correctly parsed."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata("my-post", "like-only")
        self.assertEqual(metadata["like-of"], "https://remote.social/statuses/42")

    def test_reply_to_and_like_of_coexist(self):
        """Both reply-to and like-of can be present in the same file."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata("my-post", "reply-and-like")
        self.assertEqual(metadata["reply-to"], "https://remote.social/statuses/999")
        self.assertEqual(metadata["like-of"], "https://remote.social/statuses/42")

    def test_like_of_absent_when_not_set(self):
        """like-of is not present in metadata when not set in the file."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata("my-post", "no-like")
        self.assertNotIn("like-of", metadata)


class LikeOfPageMetadataParsingTest(unittest.TestCase):
    """Test that like-of metadata is parsed correctly from article pages."""

    def setUp(self):
        from madblog.app import app
        from madblog.config import config

        self.app = app
        self.config = config

        self._tmpdir = tempfile.TemporaryDirectory()
        self.addCleanup(self._tmpdir.cleanup)

        root = Path(self._tmpdir.name)

        pages_dir = root / "pages"
        pages_dir.mkdir(parents=True, exist_ok=True)

        (pages_dir / "like-post.md").write_text(
            "\n".join(
                [
                    "[//]: # (like-of: https://remote.social/statuses/55)",
                    "[//]: # (published: 2025-07-10)",
                    "",
                    "# I liked this post",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        self._orig_content_dir = config.content_dir
        self._orig_link = config.link

        config.content_dir = str(root)
        config.link = "https://example.com"

        self.app.pages_dir = pages_dir

    def tearDown(self):
        self.config.content_dir = self._orig_content_dir
        self.config.link = self._orig_link

    def test_like_of_extracted_from_page(self):
        """The like-of metadata field is correctly parsed from a page."""
        with self.app.app_context():
            metadata = self.app._parse_page_metadata("like-post")
        self.assertEqual(metadata["like-of"], "https://remote.social/statuses/55")


class ThreadingModelTest(unittest.TestCase):
    """Tests for the threading model."""

    def test_build_thread_tree_empty(self):
        """Empty inputs return empty tree."""
        from madblog.reactions import build_thread_tree

        tree = build_thread_tree([], [], [], "https://example.com/article/test")
        self.assertEqual(tree, [])

    def test_author_replies_become_root_nodes(self):
        """Author replies to the article URL become root nodes."""
        from madblog.reactions import build_thread_tree, ReactionType

        replies = [
            {
                "slug": "reply-1",
                "title": "Reply 1",
                "reply_to": "https://example.com/article/test",
                "published": None,
                "content_html": "<p>Content</p>",
                "permalink": "/reply/test/reply-1",
                "full_url": "https://example.com/reply/test/reply-1",
            }
        ]

        tree = build_thread_tree([], [], replies, "https://example.com/article/test")

        self.assertEqual(len(tree), 1)
        self.assertEqual(tree[0].reaction_type, ReactionType.AUTHOR_REPLY)
        self.assertEqual(tree[0].item["slug"], "reply-1")

    def test_nested_replies_become_children(self):
        """A reply to another reply becomes a child node."""
        from madblog.reactions import build_thread_tree

        replies = [
            {
                "slug": "reply-1",
                "title": "Reply 1",
                "reply_to": "https://example.com/article/test",
                "published": None,
                "content_html": "<p>First reply</p>",
                "permalink": "/reply/test/reply-1",
                "full_url": "https://example.com/reply/test/reply-1",
            },
            {
                "slug": "reply-2",
                "title": "Reply 2",
                "reply_to": "https://example.com/reply/test/reply-1",
                "published": None,
                "content_html": "<p>Nested reply</p>",
                "permalink": "/reply/test/reply-2",
                "full_url": "https://example.com/reply/test/reply-2",
            },
        ]

        tree = build_thread_tree([], [], replies, "https://example.com/article/test")

        self.assertEqual(len(tree), 1)
        self.assertEqual(tree[0].item["slug"], "reply-1")
        self.assertEqual(len(tree[0].children), 1)
        self.assertEqual(tree[0].children[0].item["slug"], "reply-2")

    def test_ap_reply_threads_under_parent(self):
        """An AP reply targeting an author reply nests under it."""
        from unittest.mock import MagicMock
        from madblog.reactions import build_thread_tree, ReactionType

        author_reply = {
            "slug": "thanks",
            "title": "Thanks",
            "reply_to": "https://mastodon.social/users/alice/statuses/100",
            "published": "2026-01-02T00:00:00+00:00",
            "content_html": "<p>Thanks for the comment!</p>",
            "permalink": "/reply/post/thanks",
            "full_url": "https://example.com/reply/post/thanks",
        }

        # Top-level AP reply to the article
        ap_top = MagicMock()
        ap_top.object_id = "https://mastodon.social/users/alice/statuses/100"
        ap_top.activity_id = "https://mastodon.social/users/alice/statuses/100/activity"
        ap_top.interaction_type = MagicMock(value="reply")
        ap_top.target_resource = "https://example.com/article/post"
        ap_top.published = None
        ap_top.created_at = None

        # Nested AP reply targeting the author reply
        ap_nested = MagicMock()
        ap_nested.object_id = "https://mastodon.social/users/alice/statuses/200"
        ap_nested.activity_id = (
            "https://mastodon.social/users/alice/statuses/200/activity"
        )
        ap_nested.interaction_type = MagicMock(value="reply")
        ap_nested.target_resource = "https://example.com/reply/post/thanks"
        ap_nested.published = None
        ap_nested.created_at = None

        tree = build_thread_tree(
            webmentions=[],
            ap_interactions=[ap_top, ap_nested],
            author_replies=[author_reply],
            article_url="https://example.com/article/post",
        )

        # Only the top-level AP reply should be a root
        self.assertEqual(len(tree), 1)
        self.assertEqual(tree[0].reaction_type, ReactionType.AP_INTERACTION)
        self.assertEqual(
            tree[0].identity,
            "https://mastodon.social/users/alice/statuses/100",
        )

        # Author reply is child of top-level AP reply
        self.assertEqual(len(tree[0].children), 1)
        self.assertEqual(tree[0].children[0].reaction_type, ReactionType.AUTHOR_REPLY)

        # Nested AP reply is grandchild (child of author reply)
        self.assertEqual(len(tree[0].children[0].children), 1)
        self.assertEqual(
            tree[0].children[0].children[0].reaction_type,
            ReactionType.AP_INTERACTION,
        )
        self.assertEqual(
            tree[0].children[0].children[0].identity,
            "https://mastodon.social/users/alice/statuses/200",
        )

    def test_ap_reply_threads_via_ap_alias(self):
        """AP reply threads under author reply when AP domain differs from blog domain."""
        from unittest.mock import MagicMock
        from madblog.reactions import build_thread_tree, ReactionType

        # config.link = https://blog.example.com
        # config.activitypub_link = https://ap.example.com
        author_reply = {
            "slug": "thanks",
            "title": "Thanks",
            "reply_to": "https://mastodon.social/users/alice/statuses/100",
            "published": "2026-01-02T00:00:00+00:00",
            "content_html": "<p>Thanks!</p>",
            "permalink": "/reply/post/thanks",
            "full_url": "https://blog.example.com/reply/post/thanks",
            "ap_full_url": "https://ap.example.com/reply/post/thanks",
        }

        ap_top = MagicMock()
        ap_top.object_id = "https://mastodon.social/users/alice/statuses/100"
        ap_top.activity_id = "https://mastodon.social/users/alice/statuses/100/activity"
        ap_top.interaction_type = MagicMock(value="reply")
        ap_top.target_resource = "https://blog.example.com/article/post"
        ap_top.published = None
        ap_top.created_at = None

        # Nested reply targets the AP-domain URL, not the blog-domain URL
        ap_nested = MagicMock()
        ap_nested.object_id = "https://mastodon.social/users/alice/statuses/200"
        ap_nested.activity_id = (
            "https://mastodon.social/users/alice/statuses/200/activity"
        )
        ap_nested.interaction_type = MagicMock(value="reply")
        ap_nested.target_resource = "https://ap.example.com/reply/post/thanks"
        ap_nested.published = None
        ap_nested.created_at = None

        tree = build_thread_tree(
            webmentions=[],
            ap_interactions=[ap_top, ap_nested],
            author_replies=[author_reply],
            article_url="https://blog.example.com/article/post",
        )

        # Only the top-level AP reply should be a root
        self.assertEqual(len(tree), 1)

        # Author reply is child of top-level AP reply
        self.assertEqual(len(tree[0].children), 1)
        self.assertEqual(tree[0].children[0].reaction_type, ReactionType.AUTHOR_REPLY)
        # Identity uses the blog domain (full_url), not the AP domain
        self.assertEqual(
            tree[0].children[0].identity,
            "https://blog.example.com/reply/post/thanks",
        )

        # Nested AP reply found via AP alias → grandchild
        self.assertEqual(len(tree[0].children[0].children), 1)
        self.assertEqual(
            tree[0].children[0].children[0].identity,
            "https://mastodon.social/users/alice/statuses/200",
        )

    def test_ap_like_stays_root(self):
        """A like interaction should remain at root level, not thread."""
        from unittest.mock import MagicMock
        from madblog.reactions import build_thread_tree, ReactionType

        ap_like = MagicMock()
        ap_like.object_id = "https://mastodon.social/users/bob/statuses/300"
        ap_like.activity_id = "https://mastodon.social/users/bob/statuses/300/activity"
        ap_like.interaction_type = MagicMock(value="like")
        ap_like.target_resource = "https://example.com/article/post"
        ap_like.published = None
        ap_like.created_at = None

        tree = build_thread_tree(
            webmentions=[],
            ap_interactions=[ap_like],
            author_replies=[],
            article_url="https://example.com/article/post",
        )

        self.assertEqual(len(tree), 1)
        self.assertEqual(tree[0].reaction_type, ReactionType.AP_INTERACTION)
        self.assertEqual(tree[0].children, [])

    def test_collect_reply_object_ids(self):
        """_collect_reply_object_ids returns object_ids of reply/quote interactions."""
        from unittest.mock import MagicMock
        from madblog.replies._mixin import RepliesMixin

        reply = MagicMock()
        reply.interaction_type = MagicMock(value="reply")
        reply.object_id = "https://remote.social/statuses/100"

        like = MagicMock()
        like.interaction_type = MagicMock(value="like")
        like.object_id = "https://remote.social/statuses/200"

        quote = MagicMock()
        quote.interaction_type = MagicMock(value="quote")
        quote.object_id = "https://remote.social/statuses/300"

        ids = RepliesMixin._collect_reply_object_ids([reply, like, quote])
        self.assertEqual(
            ids,
            {
                "https://remote.social/statuses/100",
                "https://remote.social/statuses/300",
            },
        )

    def test_nested_fediverse_reply_threads_on_article(self):
        """A fediverse reply-to-reply chains correctly on the article page.

        Scenario: article → author reply → fediverse reply A → fediverse reply B
        Both A and B should appear in the thread tree, with B as child of A.
        """
        from unittest.mock import MagicMock
        from madblog.reactions import build_thread_tree, ReactionType

        author_reply = {
            "slug": "ar1",
            "title": "Author Reply",
            "reply_to": "https://example.com/article/post",
            "published": "2026-01-01T00:00:00+00:00",
            "content_html": "<p>Author reply</p>",
            "permalink": "/reply/post/ar1",
            "full_url": "https://example.com/reply/post/ar1",
        }

        # Fediverse reply A targets the author reply
        ap_a = MagicMock()
        ap_a.object_id = "https://mastodon.social/users/alice/statuses/100"
        ap_a.activity_id = "https://mastodon.social/users/alice/statuses/100/activity"
        ap_a.interaction_type = MagicMock(value="reply")
        ap_a.target_resource = "https://example.com/reply/post/ar1"
        ap_a.published = "2026-01-02T00:00:00+00:00"
        ap_a.created_at = None

        # Fediverse reply B targets reply A's Mastodon URL
        ap_b = MagicMock()
        ap_b.object_id = "https://mastodon.social/users/bob/statuses/200"
        ap_b.activity_id = "https://mastodon.social/users/bob/statuses/200/activity"
        ap_b.interaction_type = MagicMock(value="reply")
        ap_b.target_resource = "https://mastodon.social/users/alice/statuses/100"
        ap_b.published = "2026-01-03T00:00:00+00:00"
        ap_b.created_at = None

        tree = build_thread_tree(
            webmentions=[],
            ap_interactions=[ap_a, ap_b],
            author_replies=[author_reply],
            article_url="https://example.com/article/post",
        )

        # Author reply is root
        self.assertEqual(len(tree), 1)
        self.assertEqual(tree[0].reaction_type, ReactionType.AUTHOR_REPLY)

        # Reply A is child of author reply
        self.assertEqual(len(tree[0].children), 1)
        self.assertEqual(
            tree[0].children[0].identity,
            "https://mastodon.social/users/alice/statuses/100",
        )

        # Reply B is child of reply A
        self.assertEqual(len(tree[0].children[0].children), 1)
        self.assertEqual(
            tree[0].children[0].children[0].identity,
            "https://mastodon.social/users/bob/statuses/200",
        )

    def test_fediverse_url_aliases_canonical_to_pretty(self):
        """Canonical /users/user/statuses/ID produces /@user aliases."""
        from madblog.reactions import _fediverse_url_aliases

        aliases = _fediverse_url_aliases(
            "https://mastodon.social/users/alice/statuses/12345"
        )
        self.assertIn("https://mastodon.social/@alice/statuses/12345", aliases)
        self.assertIn("https://mastodon.social/@alice/12345", aliases)

    def test_fediverse_url_aliases_pretty_to_canonical(self):
        """Pretty /@user/statuses/ID produces /users/user canonical alias."""
        from madblog.reactions import _fediverse_url_aliases

        aliases = _fediverse_url_aliases(
            "https://mastodon.social/@alice/statuses/12345"
        )
        self.assertEqual(
            aliases, ["https://mastodon.social/users/alice/statuses/12345"]
        )

    def test_fediverse_url_aliases_short_pretty_to_canonical(self):
        """Short pretty /@user/ID produces /users/user/statuses canonical alias."""
        from madblog.reactions import _fediverse_url_aliases

        aliases = _fediverse_url_aliases("https://mastodon.social/@alice/12345")
        self.assertEqual(
            aliases, ["https://mastodon.social/users/alice/statuses/12345"]
        )

    def test_fediverse_url_aliases_non_mastodon(self):
        """Non-Mastodon URLs produce no aliases."""
        from madblog.reactions import _fediverse_url_aliases

        self.assertEqual(_fediverse_url_aliases("https://example.com/article/post"), [])
        self.assertEqual(_fediverse_url_aliases(""), [])

    def test_author_reply_to_pretty_mastodon_url(self):
        """Author reply using /@user pretty URL threads under AP interaction
        that uses /users/user canonical URL."""
        from unittest.mock import MagicMock
        from madblog.reactions import build_thread_tree, ReactionType

        # AP interaction uses canonical object_id
        ap = MagicMock()
        ap.object_id = "https://mastodon.social/users/alice/statuses/100"
        ap.activity_id = "https://mastodon.social/users/alice/statuses/100/activity"
        ap.interaction_type = MagicMock(value="reply")
        ap.target_resource = "https://example.com/article/post"
        ap.published = "2026-01-01T00:00:00+00:00"
        ap.created_at = None

        # Author reply uses pretty /@user URL as reply-to
        author_reply = {
            "slug": "ar1",
            "title": "Author Reply",
            "reply_to": "https://mastodon.social/@alice/statuses/100",
            "published": "2026-01-02T00:00:00+00:00",
            "content_html": "<p>Reply</p>",
            "permalink": "/reply/post/ar1",
            "full_url": "https://example.com/reply/post/ar1",
        }

        tree = build_thread_tree(
            webmentions=[],
            ap_interactions=[ap],
            author_replies=[author_reply],
            article_url="https://example.com/article/post",
        )

        # AP interaction is root, author reply is its child
        self.assertEqual(len(tree), 1)
        self.assertEqual(tree[0].reaction_type, ReactionType.AP_INTERACTION)
        self.assertEqual(len(tree[0].children), 1)
        self.assertEqual(tree[0].children[0].reaction_type, ReactionType.AUTHOR_REPLY)

    def test_reaction_anchor_id_stable(self):
        """Anchor IDs are stable and deterministic."""
        from madblog.reactions import reaction_anchor_id

        id1 = reaction_anchor_id("wm", "https://example.com/post/123")
        id2 = reaction_anchor_id("wm", "https://example.com/post/123")
        self.assertEqual(id1, id2)
        self.assertTrue(id1.startswith("wm-"))


class ArticleRepliesCollectionTest(unittest.TestCase):
    """Tests for _get_article_replies()."""

    def setUp(self):
        from madblog.app import app
        from madblog.config import config

        self.app = app
        self.config = config

        self._tmpdir = tempfile.TemporaryDirectory()
        self.addCleanup(self._tmpdir.cleanup)

        root = Path(self._tmpdir.name)
        markdown_dir = root / "markdown"
        markdown_dir.mkdir(parents=True, exist_ok=True)

        # Create an article
        (markdown_dir / "test-article.md").write_text(
            "[//]: # (title: Test Article)\n\n# Test Article\nContent.",
            encoding="utf-8",
        )

        # Create replies
        replies_dir = root / "replies" / "test-article"
        replies_dir.mkdir(parents=True, exist_ok=True)

        (replies_dir / "first-reply.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://example.com/article/test-article)",
                    "[//]: # (published: 2025-07-01)",
                    "",
                    "# First Reply",
                    "First reply content.",
                ]
            ),
            encoding="utf-8",
        )

        (replies_dir / "second-reply.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://example.com/article/test-article)",
                    "[//]: # (published: 2025-07-02)",
                    "",
                    "# Second Reply",
                    "Second reply content.",
                ]
            ),
            encoding="utf-8",
        )

        self._orig_content_dir = config.content_dir
        self._orig_link = config.link
        config.content_dir = str(root)
        config.link = "https://example.com"
        self.app.pages_dir = markdown_dir
        self.app.replies_dir = root / "replies"

    def tearDown(self):
        self.config.content_dir = self._orig_content_dir
        self.config.link = self._orig_link

    def test_get_article_replies_returns_list(self):
        """_get_article_replies returns a list of reply dicts."""
        with self.app.app_context():
            replies = self.app._get_article_replies("test-article")

        self.assertIsInstance(replies, list)
        self.assertEqual(len(replies), 2)

    def test_get_article_replies_sorted_by_date(self):
        """Replies are sorted by published date ascending."""
        with self.app.app_context():
            replies = self.app._get_article_replies("test-article")

        self.assertEqual(replies[0]["slug"], "first-reply")
        self.assertEqual(replies[1]["slug"], "second-reply")

    def test_get_article_replies_contains_expected_keys(self):
        """Each reply dict contains expected keys."""
        with self.app.app_context():
            replies = self.app._get_article_replies("test-article")

        reply = replies[0]
        self.assertIn("slug", reply)
        self.assertIn("title", reply)
        self.assertIn("reply_to", reply)
        self.assertIn("published", reply)
        self.assertIn("content_html", reply)
        self.assertIn("permalink", reply)
        self.assertIn("full_url", reply)

    def test_get_article_replies_empty_for_nonexistent(self):
        """Returns empty list for articles without replies."""
        with self.app.app_context():
            replies = self.app._get_article_replies("nonexistent-article")

        self.assertEqual(replies, [])


class StandaloneLikeWithDerivedReplyToTest(unittest.TestCase):
    """Regression: standalone likes must be excluded even when reply-to is auto-derived."""

    def setUp(self):
        from madblog.app import app
        from madblog.config import config

        self.app = app
        self.config = config

        self._tmpdir = tempfile.TemporaryDirectory()
        self.addCleanup(self._tmpdir.cleanup)

        root = Path(self._tmpdir.name)
        markdown_dir = root / "markdown"
        markdown_dir.mkdir(parents=True, exist_ok=True)

        # Create the article so that _parse_reply_metadata auto-derives reply-to
        (markdown_dir / "my-article.md").write_text(
            "[//]: # (title: My Article)\n\n# My Article\nContent.",
            encoding="utf-8",
        )

        replies_dir = root / "replies" / "my-article"
        replies_dir.mkdir(parents=True, exist_ok=True)

        # Standalone like with NO explicit reply-to and NO body content.
        # _parse_reply_metadata will auto-derive reply-to from the article.
        (replies_dir / "like-someuser.md").write_text(
            "\n".join(
                [
                    "[//]: # (like-of: https://remote.social/statuses/12345)",
                    "[//]: # (published: 2025-07-10)",
                ]
            ),
            encoding="utf-8",
        )

        # A real reply (should still be included)
        (replies_dir / "real-reply.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://example.com/article/my-article)",
                    "[//]: # (published: 2025-07-11)",
                    "",
                    "# A Real Reply",
                    "Some actual content.",
                ]
            ),
            encoding="utf-8",
        )

        self._orig_content_dir = config.content_dir
        self._orig_link = config.link
        config.content_dir = str(root)
        config.link = "https://example.com"
        self.app.pages_dir = markdown_dir
        self.app.replies_dir = root / "replies"

    def tearDown(self):
        self.config.content_dir = self._orig_content_dir
        self.config.link = self._orig_link

    def test_standalone_like_excluded_despite_derived_reply_to(self):
        """A like-of file with no content is excluded even when reply-to is auto-derived."""
        with self.app.app_context():
            replies = self.app._get_article_replies("my-article")

        slugs = [r["slug"] for r in replies]
        self.assertNotIn("like-someuser", slugs)
        self.assertIn("real-reply", slugs)
        self.assertEqual(len(replies), 1)


class InlineReactionsRenderingTest(unittest.TestCase):
    """Tests for inline reactions rendering on article pages."""

    def setUp(self):
        from madblog.app import app
        from madblog.config import config

        self.app = app
        self.config = config

        self._tmpdir = tempfile.TemporaryDirectory()
        self.addCleanup(self._tmpdir.cleanup)

        root = Path(self._tmpdir.name)
        markdown_dir = root / "markdown"
        markdown_dir.mkdir(parents=True, exist_ok=True)

        # Create an article
        (markdown_dir / "article-with-reply.md").write_text(
            "\n".join(
                [
                    "[//]: # (title: Article With Reply)",
                    "[//]: # (published: 2025-07-01)",
                    "",
                    "# Article With Reply",
                    "Article body content.",
                ]
            ),
            encoding="utf-8",
        )

        # Create a reply to this article
        replies_dir = root / "replies" / "article-with-reply"
        replies_dir.mkdir(parents=True, exist_ok=True)

        (replies_dir / "author-reply.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://example.com/article/article-with-reply)",
                    "[//]: # (published: 2025-07-02)",
                    "",
                    "# Author Response",
                    "Thanks for reading!",
                ]
            ),
            encoding="utf-8",
        )

        self._orig_content_dir = config.content_dir
        self._orig_link = config.link
        config.content_dir = str(root)
        config.link = "https://example.com"
        self.app.pages_dir = markdown_dir
        self.app.replies_dir = root / "replies"

        self.client = self.app.test_client()

    def tearDown(self):
        self.config.content_dir = self._orig_content_dir
        self.config.link = self._orig_link

    def test_article_page_renders_author_reply_inline(self):
        """Article page includes author reply in reactions section."""
        rsp = self.client.get("/article/article-with-reply")
        self.assertEqual(rsp.status_code, 200)

        html = rsp.get_data(as_text=True)
        self.assertIn("reaction-author-reply", html)
        self.assertIn("Thanks for reading!", html)

    def test_reactions_section_has_heading(self):
        """Reactions section includes a heading when reactions exist."""
        rsp = self.client.get("/article/article-with-reply")
        html = rsp.get_data(as_text=True)
        self.assertIn("reactions-section", html)

    def test_author_reply_has_permalink_anchor(self):
        """Author replies have an anchor ID for permalinking."""
        rsp = self.client.get("/article/article-with-reply")
        html = rsp.get_data(as_text=True)
        self.assertIn('id="reply-', html)


class PermalinkNavigationTest(unittest.TestCase):
    """Tests for reaction permalink and in-page navigation."""

    def test_anchor_id_format(self):
        """Anchor IDs follow the expected format."""
        from madblog.reactions import reaction_anchor_id

        wm_anchor = reaction_anchor_id("wm", "https://example.com/post/123")
        ap_anchor = reaction_anchor_id("ap", "https://mastodon.social/statuses/456")
        reply_anchor = reaction_anchor_id(
            "reply", "https://blog.example.com/reply/art/r1"
        )

        self.assertTrue(wm_anchor.startswith("wm-"))
        self.assertTrue(ap_anchor.startswith("ap-"))
        self.assertTrue(reply_anchor.startswith("reply-"))

        # All should have 12-char hex suffix
        self.assertEqual(len(wm_anchor), len("wm-") + 12)
        self.assertEqual(len(ap_anchor), len("ap-") + 12)
        self.assertEqual(len(reply_anchor), len("reply-") + 12)

    def test_anchor_ids_are_deterministic(self):
        """Same input produces same anchor ID."""
        from madblog.reactions import reaction_anchor_id

        url = "https://example.com/unique/post"
        id1 = reaction_anchor_id("wm", url)
        id2 = reaction_anchor_id("wm", url)
        self.assertEqual(id1, id2)

    def test_different_urls_produce_different_anchors(self):
        """Different URLs produce different anchor IDs."""
        from madblog.reactions import reaction_anchor_id

        id1 = reaction_anchor_id("wm", "https://example.com/post/1")
        id2 = reaction_anchor_id("wm", "https://example.com/post/2")
        self.assertNotEqual(id1, id2)


class ReplyFederationTest(unittest.TestCase):
    """Tests for reply federation."""

    def test_reply_file_to_url_activitypub(self):
        """ActivityPubIntegration.reply_file_to_url generates correct URLs."""
        from unittest.mock import MagicMock
        from madblog.activitypub._integration import ActivityPubIntegration

        handler = MagicMock()
        integration = ActivityPubIntegration(
            handler=handler,
            pages_dir="/content/markdown",
            base_url="https://example.com",
            replies_dir="/content/replies",
        )

        url = integration.reply_file_to_url("/content/replies/my-post/reply-1.md")
        self.assertEqual(url, "https://example.com/reply/my-post/reply-1")

    def test_reply_file_to_url_webmentions(self):
        """FileWebmentionsStorage.reply_file_to_url generates correct URLs."""
        from madblog.webmentions._storage import FileWebmentionsStorage

        with tempfile.TemporaryDirectory() as tmpdir:
            content_dir = Path(tmpdir) / "markdown"
            mentions_dir = Path(tmpdir) / "mentions"
            replies_dir = Path(tmpdir) / "replies"
            content_dir.mkdir()

            storage = FileWebmentionsStorage(
                content_dir=content_dir,
                mentions_dir=mentions_dir,
                base_url="https://example.com",
                replies_dir=replies_dir,
            )

            url = storage.reply_file_to_url(
                str(replies_dir / "article" / "my-reply.md")
            )
            self.assertEqual(url, "https://example.com/reply/article/my-reply")

    def test_on_content_change_skips_replies(self):
        """on_content_change skips files under replies_dir."""
        from unittest.mock import MagicMock
        from madblog.activitypub._integration import ActivityPubIntegration
        from madblog.monitor import ChangeType

        handler = MagicMock()
        integration = ActivityPubIntegration(
            handler=handler,
            pages_dir="/content/markdown",
            base_url="https://example.com",
            replies_dir="/content/replies",
        )

        # Mock file_to_url to track if it's called
        integration.file_to_url = MagicMock(
            return_value="https://example.com/article/test"
        )

        # Call with a reply file path
        integration.on_content_change(
            ChangeType.ADDED, "/content/replies/article/reply.md"
        )

        # file_to_url should NOT be called for reply files
        integration.file_to_url.assert_not_called()

    def test_build_reply_object_sets_note_type(self):
        """build_reply_object creates a Note with in_reply_to."""
        from unittest.mock import MagicMock, patch
        from madblog.activitypub._integration import ActivityPubIntegration

        handler = MagicMock()
        handler.followers_url = "https://example.com/followers"
        handler.storage.get_interactions.return_value = []

        integration = ActivityPubIntegration(
            handler=handler,
            pages_dir="/content/markdown",
            base_url="https://example.com",
            replies_dir="/content/replies",
        )

        # Create a temp reply file
        import tempfile
        import os

        with tempfile.TemporaryDirectory() as tmpdir:
            reply_file = os.path.join(tmpdir, "reply.md")
            with open(reply_file, "w") as f:
                f.write(
                    "[//]: # (reply-to: https://mastodon.social/status/123)\n\n"
                    "Thank you for your comment!"
                )

            with patch.object(integration, "_clean_content", return_value="Thank you!"):
                obj, activity_type = integration.build_reply_object(
                    reply_file,
                    "https://example.com/reply/art/r1",
                    "https://example.com/actor",
                )

            self.assertEqual(obj.type, "Note")
            self.assertIsNone(obj.name)
            self.assertEqual(obj.in_reply_to, "https://mastodon.social/status/123")
            self.assertEqual(activity_type, "Create")

    def test_reply_to_derived_from_directory_structure(self):
        """When reply-to is not set, it is derived from the article slug."""
        from unittest.mock import MagicMock, patch
        from madblog.activitypub._integration import ActivityPubIntegration

        handler = MagicMock()
        handler.followers_url = "https://example.com/followers"
        handler.storage.get_interactions.return_value = []

        with tempfile.TemporaryDirectory() as tmpdir:
            replies_dir = Path(tmpdir) / "replies"
            article_dir = replies_dir / "my-article"
            article_dir.mkdir(parents=True)

            reply_file = article_dir / "my-reply.md"
            reply_file.write_text("This is a reply without explicit reply-to\n")

            integration = ActivityPubIntegration(
                handler=handler,
                pages_dir=str(Path(tmpdir) / "markdown"),
                base_url="https://example.com",
                replies_dir=str(replies_dir),
            )

            with patch.object(
                integration, "_clean_content", return_value="This is a reply"
            ):
                obj, _ = integration.build_reply_object(
                    str(reply_file),
                    "https://example.com/reply/my-article/my-reply",
                    "https://example.com/actor",
                )

            # in_reply_to should be the AP object URL derived from slug
            self.assertEqual(obj.in_reply_to, "https://example.com/article/my-article")

    def test_explicit_reply_to_overrides_derived(self):
        """An explicit reply-to metadata takes precedence over directory derivation."""
        from unittest.mock import MagicMock, patch
        from madblog.activitypub._integration import ActivityPubIntegration

        handler = MagicMock()
        handler.followers_url = "https://example.com/followers"
        handler.storage.get_interactions.return_value = []

        with tempfile.TemporaryDirectory() as tmpdir:
            replies_dir = Path(tmpdir) / "replies"
            article_dir = replies_dir / "my-article"
            article_dir.mkdir(parents=True)

            reply_file = article_dir / "my-reply.md"
            reply_file.write_text(
                "[//]: # (reply-to: https://mastodon.social/@user/12345)\n\n"
                "Replying to a specific fediverse post\n"
            )

            integration = ActivityPubIntegration(
                handler=handler,
                pages_dir=str(Path(tmpdir) / "markdown"),
                base_url="https://example.com",
                replies_dir=str(replies_dir),
            )

            with patch.object(
                integration,
                "_clean_content",
                return_value="Replying to a specific fediverse post",
            ):
                obj, _ = integration.build_reply_object(
                    str(reply_file),
                    "https://example.com/reply/my-article/my-reply",
                    "https://example.com/actor",
                )

            # Explicit reply-to should be used, not derived
            self.assertEqual(obj.in_reply_to, "https://mastodon.social/@user/12345")

    def test_unlisted_post_with_title_prepends_bold_link(self):
        """Unlisted posts (no reply-to) with a title prepend a bold link at top."""
        from unittest.mock import MagicMock, patch
        from madblog.activitypub._integration import ActivityPubIntegration

        handler = MagicMock()
        handler.followers_url = "https://example.com/followers"
        handler.storage.get_interactions.return_value = []

        with tempfile.TemporaryDirectory() as tmpdir:
            replies_dir = Path(tmpdir) / "replies"
            replies_dir.mkdir(parents=True)

            # Top-level unlisted post with title
            reply_file = replies_dir / "my-unlisted.md"
            reply_file.write_text(
                "[//]: # (title: My Unlisted Post)\n\n"
                "# My Unlisted Post\n"
                "Content of the unlisted post.\n"
            )

            integration = ActivityPubIntegration(
                handler=handler,
                pages_dir=str(Path(tmpdir) / "markdown"),
                base_url="https://example.com",
                replies_dir=str(replies_dir),
            )

            with patch.object(
                integration,
                "_clean_content",
                return_value="Content of the unlisted post.",
            ):
                obj, _ = integration.build_reply_object(
                    str(reply_file),
                    "https://example.com/reply/my-unlisted",
                    "https://example.com/actor",
                    public_url="https://example.com/reply/my-unlisted",
                )

            # No in_reply_to for unlisted posts
            self.assertIsNone(obj.in_reply_to)
            # Content should start with bold title link
            self.assertTrue(
                obj.content.startswith(
                    '<p><strong><a href="https://example.com/reply/my-unlisted">'
                    "My Unlisted Post</a></strong></p>"
                )
            )

    def test_unlisted_post_without_title_appends_bare_url(self):
        """Unlisted posts (no reply-to) without a title append bare URL at bottom."""
        from unittest.mock import MagicMock, patch
        from madblog.activitypub._integration import ActivityPubIntegration

        handler = MagicMock()
        handler.followers_url = "https://example.com/followers"
        handler.storage.get_interactions.return_value = []

        with tempfile.TemporaryDirectory() as tmpdir:
            replies_dir = Path(tmpdir) / "replies"
            replies_dir.mkdir(parents=True)

            # Top-level unlisted post without title (slug becomes fallback title)
            reply_file = replies_dir / "my-unlisted.md"
            reply_file.write_text("Just some content without a heading.\n")

            integration = ActivityPubIntegration(
                handler=handler,
                pages_dir=str(Path(tmpdir) / "markdown"),
                base_url="https://example.com",
                replies_dir=str(replies_dir),
            )

            with patch.object(
                integration, "_clean_content", return_value="Just some content."
            ):
                obj, _ = integration.build_reply_object(
                    str(reply_file),
                    "https://example.com/reply/my-unlisted",
                    "https://example.com/actor",
                    public_url="https://example.com/reply/my-unlisted",
                )

            # No in_reply_to for unlisted posts
            self.assertIsNone(obj.in_reply_to)
            # Content should start with bare URL link (ensures link preview card)
            self.assertTrue(
                obj.content.startswith(
                    '<p><a href="https://example.com/reply/my-unlisted">'
                    "https://example.com/reply/my-unlisted</a></p>"
                )
            )


class CountReactionsTest(unittest.TestCase):
    """Tests for the count_reactions helper."""

    @staticmethod
    def _make_node(reaction_type, type_val=None):
        from madblog.reactions import ThreadNode, ReactionType

        if reaction_type == "author_reply":
            item = {"full_url": "https://example.com/reply/a/r1"}
            return ThreadNode(
                item=item,
                reaction_type=ReactionType.AUTHOR_REPLY,
                identity=item["full_url"],
            )

        from unittest.mock import MagicMock

        item = MagicMock()
        if reaction_type == "webmention":
            mt = MagicMock()
            mt.value = type_val or "mention"
            item.mention_type = mt
            return ThreadNode(
                item=item,
                reaction_type=ReactionType.WEBMENTION,
                identity=f"wm-{id(item)}",
            )
        else:
            it = MagicMock()
            it.value = type_val or "reply"
            item.interaction_type = it
            return ThreadNode(
                item=item,
                reaction_type=ReactionType.AP_INTERACTION,
                identity=f"ap-{id(item)}",
            )

    def test_empty_tree(self):
        from madblog.reactions import count_reactions

        counts = count_reactions([])
        self.assertEqual(counts["total"], 0)

    def test_counts_all_types(self):
        from madblog.reactions import count_reactions

        like_wm = self._make_node("webmention", "like")
        repost_wm = self._make_node("webmention", "repost")
        reply_ap = self._make_node("ap_interaction", "reply")
        boost_ap = self._make_node("ap_interaction", "boost")
        quote_ap = self._make_node("ap_interaction", "quote")
        mention_ap = self._make_node("ap_interaction", "mention")
        author = self._make_node("author_reply")

        roots = [like_wm, repost_wm, reply_ap, boost_ap, quote_ap, mention_ap, author]
        counts = count_reactions(roots)

        self.assertEqual(counts["total"], 7)
        self.assertEqual(counts["likes"], 1)
        self.assertEqual(counts["boosts"], 2)  # repost + boost
        self.assertEqual(counts["replies"], 1)
        self.assertEqual(counts["quotes"], 1)
        self.assertEqual(counts["mentions"], 1)
        self.assertEqual(counts["webmentions"], 2)
        self.assertEqual(counts["author_replies"], 1)

    def test_counts_nested_children(self):
        from madblog.reactions import count_reactions

        root = self._make_node("ap_interaction", "reply")
        child = self._make_node("ap_interaction", "like")
        root.children.append(child)

        counts = count_reactions([root])
        self.assertEqual(counts["total"], 2)
        self.assertEqual(counts["replies"], 1)
        self.assertEqual(counts["likes"], 1)


class ReplyToFallbackTest(unittest.TestCase):
    """Tests for reply-to fallback when metadata is missing."""

    def setUp(self):
        from madblog.app import app
        from madblog.config import config

        self.app = app
        self.config = config

        self._tmpdir = tempfile.TemporaryDirectory()
        self.addCleanup(self._tmpdir.cleanup)

        root = Path(self._tmpdir.name)
        markdown_dir = root / "markdown"
        markdown_dir.mkdir(parents=True, exist_ok=True)

        # Create an article
        (markdown_dir / "existing-article.md").write_text(
            "\n".join(
                [
                    "[//]: # (title: Existing Article)",
                    "[//]: # (published: 2025-07-01)",
                    "",
                    "# Existing Article",
                    "Article body.",
                ]
            ),
            encoding="utf-8",
        )

        # Create replies directory with replies under different subfolders
        replies_dir = root / "replies"

        # Reply under a subfolder matching an existing article (no reply-to)
        (replies_dir / "existing-article").mkdir(parents=True, exist_ok=True)
        (replies_dir / "existing-article" / "no-reply-to.md").write_text(
            "\n".join(
                [
                    "[//]: # (published: 2025-07-02)",
                    "",
                    "# Reply Without Explicit Target",
                    "This reply has no reply-to metadata.",
                ]
            ),
            encoding="utf-8",
        )

        # Reply under a subfolder NOT matching any existing article
        (replies_dir / "nonexistent-article").mkdir(parents=True, exist_ok=True)
        (replies_dir / "nonexistent-article" / "orphan-reply.md").write_text(
            "\n".join(
                [
                    "[//]: # (published: 2025-07-03)",
                    "",
                    "# Orphan Reply",
                    "This reply is under a folder with no matching article.",
                ]
            ),
            encoding="utf-8",
        )

        self._orig_content_dir = config.content_dir
        self._orig_link = config.link
        self._orig_title = config.title

        config.content_dir = str(root)
        config.link = "https://example.com"
        config.title = "Test Blog"

        self.app.pages_dir = markdown_dir
        self.app.replies_dir = replies_dir

    def tearDown(self):
        self.config.content_dir = self._orig_content_dir
        self.config.link = self._orig_link
        self.config.title = self._orig_title

    def test_reply_to_fallback_when_article_exists(self):
        """reply-to is derived from article slug when article file exists."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata("existing-article", "no-reply-to")
        self.assertEqual(
            metadata["reply-to"],
            "https://example.com/article/existing-article",
        )

    def test_no_fallback_when_article_missing(self):
        """reply-to is NOT set when the article file does not exist."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata(
                "nonexistent-article", "orphan-reply"
            )
        self.assertNotIn("reply-to", metadata)

    def test_rendered_page_shows_in_reply_to_with_fallback(self):
        """Rendered reply page shows 'In reply to' when fallback is applied."""
        with self.app.test_client() as client:
            resp = client.get("/reply/existing-article/no-reply-to")
            self.assertEqual(resp.status_code, 200)
            html = resp.get_data(as_text=True)
            self.assertIn("In reply to", html)
            self.assertIn("https://example.com/article/existing-article", html)

    def test_rendered_page_no_in_reply_to_when_no_fallback(self):
        """Rendered reply page does NOT show 'In reply to' when no fallback."""
        with self.app.test_client() as client:
            resp = client.get("/reply/nonexistent-article/orphan-reply")
            self.assertEqual(resp.status_code, 200)
            html = resp.get_data(as_text=True)
            self.assertNotIn("In reply to", html)

    def test_explicit_reply_to_not_overridden(self):
        """Explicit reply-to metadata is not overridden by fallback."""
        # Create a reply with explicit reply-to
        replies_dir = self.app.replies_dir
        (replies_dir / "existing-article" / "explicit-target.md").write_text(
            "\n".join(
                [
                    "[//]: # (reply-to: https://mastodon.social/@user/123)",
                    "[//]: # (published: 2025-07-04)",
                    "",
                    "# Explicit Target",
                    "This reply has an explicit reply-to.",
                ]
            ),
            encoding="utf-8",
        )

        with self.app.app_context():
            metadata = self.app._parse_reply_metadata(
                "existing-article", "explicit-target"
            )
        self.assertEqual(
            metadata["reply-to"],
            "https://mastodon.social/@user/123",
        )


class CollectInteractionCountsTest(unittest.TestCase):
    """Tests for collect_interaction_counts function."""

    def test_empty_tree_returns_empty_dict(self):
        """An empty tree returns an empty counts dict."""
        from madblog.reactions import collect_interaction_counts

        result = collect_interaction_counts([], lambda _: [])
        self.assertEqual(result, {})

    def test_webmention_nodes_queried(self):
        """Webmention nodes are queried for interaction counts using source."""
        from madblog.reactions import (
            ReactionType,
            ThreadNode,
            collect_interaction_counts,
        )

        class FakeWebmention:
            source = "https://example.com/wm"

        class FakeLike:
            interaction_type = type("IT", (), {"value": "like"})()

        node = ThreadNode(
            item=FakeWebmention(),
            reaction_type=ReactionType.WEBMENTION,
            identity="https://example.com/wm",
        )

        def mock_get_interactions(target):
            if target == "https://example.com/wm":
                return [FakeLike()]
            return []

        result = collect_interaction_counts([node], mock_get_interactions)
        self.assertIn("https://example.com/wm", result)
        self.assertEqual(result["https://example.com/wm"]["likes"], 1)

    def test_author_reply_nodes_queried(self):
        """Author reply nodes are queried for interaction counts using full_url."""
        from madblog.reactions import (
            ReactionType,
            ThreadNode,
            collect_interaction_counts,
        )

        class FakeLike:
            interaction_type = type("IT", (), {"value": "like"})()

        reply = {"full_url": "https://example.com/reply/test/reply1"}
        node = ThreadNode(
            item=reply,
            reaction_type=ReactionType.AUTHOR_REPLY,
            identity="https://example.com/reply/test/reply1",
        )

        def mock_get_interactions(target):
            if target == "https://example.com/reply/test/reply1":
                return [FakeLike(), FakeLike()]
            return []

        result = collect_interaction_counts([node], mock_get_interactions)
        self.assertIn("https://example.com/reply/test/reply1", result)
        self.assertEqual(result["https://example.com/reply/test/reply1"]["likes"], 2)

    def test_ap_interaction_counts_collected(self):
        """AP interaction nodes have their counts collected."""
        from madblog.reactions import (
            ReactionType,
            ThreadNode,
            collect_interaction_counts,
        )

        class FakeInteraction:
            object_id = "https://mastodon.social/statuses/123"
            interaction_type = type("IT", (), {"value": "reply"})()

        class FakeLike:
            interaction_type = type("IT", (), {"value": "like"})()

        class FakeBoost:
            interaction_type = type("IT", (), {"value": "boost"})()

        node = ThreadNode(
            item=FakeInteraction(),
            reaction_type=ReactionType.AP_INTERACTION,
            identity="https://mastodon.social/statuses/123",
        )

        def mock_get_interactions(target):
            if target == "https://mastodon.social/statuses/123":
                return [FakeLike(), FakeLike(), FakeBoost()]
            return []

        result = collect_interaction_counts([node], mock_get_interactions)
        self.assertIn("https://mastodon.social/statuses/123", result)
        counts = result["https://mastodon.social/statuses/123"]
        self.assertEqual(counts["likes"], 2)
        self.assertEqual(counts["boosts"], 1)
        self.assertEqual(counts["total"], 3)

    def test_nested_nodes_processed(self):
        """Child nodes in the tree are also processed."""
        from madblog.reactions import (
            ReactionType,
            ThreadNode,
            collect_interaction_counts,
        )

        class FakeInteraction:
            def __init__(self, oid):
                self.object_id = oid
                self.interaction_type = type("IT", (), {"value": "reply"})()

        class FakeLike:
            interaction_type = type("IT", (), {"value": "like"})()

        parent = ThreadNode(
            item=FakeInteraction("https://example.com/1"),
            reaction_type=ReactionType.AP_INTERACTION,
            identity="https://example.com/1",
        )
        child = ThreadNode(
            item=FakeInteraction("https://example.com/2"),
            reaction_type=ReactionType.AP_INTERACTION,
            identity="https://example.com/2",
        )
        parent.children.append(child)

        def mock_get_interactions(target):
            if target == "https://example.com/2":
                return [FakeLike()]
            return []

        result = collect_interaction_counts([parent], mock_get_interactions)
        self.assertIn("https://example.com/2", result)
        self.assertEqual(result["https://example.com/2"]["likes"], 1)

    def test_duplicate_object_ids_queried_once(self):
        """Same object_id appearing multiple times is only queried once."""
        from madblog.reactions import (
            ReactionType,
            ThreadNode,
            collect_interaction_counts,
        )

        class FakeInteraction:
            object_id = "https://example.com/dup"
            interaction_type = type("IT", (), {"value": "reply"})()

        node1 = ThreadNode(
            item=FakeInteraction(),
            reaction_type=ReactionType.AP_INTERACTION,
            identity="https://example.com/dup",
        )
        node2 = ThreadNode(
            item=FakeInteraction(),
            reaction_type=ReactionType.AP_INTERACTION,
            identity="https://example.com/dup",
        )

        calls = []

        def mock_get_interactions(target):
            calls.append(target)
            return []

        collect_interaction_counts([node1, node2], mock_get_interactions)
        self.assertEqual(len(calls), 1)

    def test_url_translation_for_author_replies(self):
        """Author reply URLs are translated from blog URL to AP URL."""
        from madblog.reactions import (
            ReactionType,
            ThreadNode,
            collect_interaction_counts,
        )

        class FakeLike:
            interaction_type = type("IT", (), {"value": "like"})()

        reply = {
            "full_url": "https://blog.example.com/reply/a1/test",
            "ap_full_url": "https://ap.example.com/reply/a1/test",
        }
        node = ThreadNode(
            item=reply,
            reaction_type=ReactionType.AUTHOR_REPLY,
            identity="https://blog.example.com/reply/a1/test",
        )

        calls = []

        def mock_get_interactions(target):
            calls.append(target)
            if target == "https://ap.example.com/reply/a1/test":
                return [FakeLike()]
            return []

        result = collect_interaction_counts(
            [node],
            mock_get_interactions,
            blog_url="https://blog.example.com",
            ap_url="https://ap.example.com",
        )
        # Should query using ap_full_url
        self.assertEqual(calls, ["https://ap.example.com/reply/a1/test"])
        # Result should be keyed by full_url (display key)
        self.assertIn("https://blog.example.com/reply/a1/test", result)
        self.assertEqual(result["https://blog.example.com/reply/a1/test"]["likes"], 1)

    def test_url_translation_for_webmentions(self):
        """Local webmention source URLs are translated to AP URLs."""
        from madblog.reactions import (
            ReactionType,
            ThreadNode,
            collect_interaction_counts,
        )

        class FakeWebmention:
            source = "https://blog.example.com/article/b6"

        class FakeLike:
            interaction_type = type("IT", (), {"value": "like"})()

        node = ThreadNode(
            item=FakeWebmention(),
            reaction_type=ReactionType.WEBMENTION,
            identity="https://blog.example.com/article/b6",
        )

        calls = []

        def mock_get_interactions(target):
            calls.append(target)
            if target == "https://ap.example.com/article/b6":
                return [FakeLike()]
            return []

        result = collect_interaction_counts(
            [node],
            mock_get_interactions,
            blog_url="https://blog.example.com",
            ap_url="https://ap.example.com",
        )
        # Should query using translated AP URL
        self.assertEqual(calls, ["https://ap.example.com/article/b6"])
        # Result should be keyed by source URL (display key)
        self.assertIn("https://blog.example.com/article/b6", result)

    def test_author_reply_children_counted(self):
        """Author reply children in the tree are counted."""
        from madblog.reactions import (
            ReactionType,
            ThreadNode,
            collect_interaction_counts,
        )

        class FakeInteraction:
            object_id = "https://mastodon.social/statuses/123"
            interaction_type = type("IT", (), {"value": "reply"})()

        parent = ThreadNode(
            item=FakeInteraction(),
            reaction_type=ReactionType.AP_INTERACTION,
            identity="https://mastodon.social/statuses/123",
        )
        author_reply = ThreadNode(
            item={"full_url": "https://example.com/reply/a1/test"},
            reaction_type=ReactionType.AUTHOR_REPLY,
            identity="https://example.com/reply/a1/test",
        )
        parent.children.append(author_reply)

        result = collect_interaction_counts([parent], lambda _: [])
        # Parent should have author_replies count of 1
        self.assertIn("https://mastodon.social/statuses/123", result)
        self.assertEqual(
            result["https://mastodon.social/statuses/123"]["author_replies"], 1
        )
        self.assertEqual(result["https://mastodon.social/statuses/123"]["total"], 1)


class TopLevelUnlistedPostTest(unittest.TestCase):
    """Test top-level unlisted posts (files directly in replies/)."""

    def setUp(self):
        from madblog.app import app
        from madblog.config import config

        self.app = app
        self.config = config

        self._tmpdir = tempfile.TemporaryDirectory()
        self.addCleanup(self._tmpdir.cleanup)

        root = Path(self._tmpdir.name)
        markdown_dir = root / "markdown"
        markdown_dir.mkdir(parents=True, exist_ok=True)

        replies_dir = root / "replies"
        replies_dir.mkdir(parents=True, exist_ok=True)

        # Create a top-level unlisted post with heading
        (replies_dir / "unlisted-with-title.md").write_text(
            "\n".join(
                [
                    "[//]: # (published: 2025-07-01)",
                    "",
                    "# My Unlisted Post",
                    "This is an unlisted post with a heading.",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        # Create a top-level unlisted post without heading
        (replies_dir / "unlisted-no-title.md").write_text(
            "\n".join(
                [
                    "[//]: # (published: 2025-07-02)",
                    "",
                    "Just some content without a heading.",
                    "",
                ]
            ),
            encoding="utf-8",
        )

        self._orig_content_dir = config.content_dir
        self._orig_link = config.link
        self._orig_title = config.title

        config.content_dir = str(root)
        config.link = "https://example.com"
        config.title = "Test Blog"

        self.app.pages_dir = markdown_dir
        self.app.replies_dir = replies_dir

    def tearDown(self):
        self.config.content_dir = self._orig_content_dir
        self.config.link = self._orig_link
        self.config.title = self._orig_title

    def test_toplevel_unlisted_route_renders(self):
        """Requesting /reply/<slug> returns 200 for top-level unlisted posts."""
        with self.app.test_client() as client:
            resp = client.get("/reply/unlisted-with-title")
            self.assertEqual(resp.status_code, 200)
            html = resp.get_data(as_text=True)
            self.assertIn("My Unlisted Post", html)
            self.assertIn("This is an unlisted post with a heading.", html)

    def test_toplevel_unlisted_raw_markdown(self):
        """Requesting /reply/<slug>.md returns raw Markdown."""
        with self.app.test_client() as client:
            resp = client.get("/reply/unlisted-with-title.md")
            self.assertEqual(resp.status_code, 200)
            text = resp.get_data(as_text=True)
            self.assertIn("This is an unlisted post with a heading.", text)
            self.assertEqual(resp.mimetype, "text/markdown")

    def test_toplevel_unlisted_404_nonexistent(self):
        """Requesting a non-existent top-level unlisted post returns 404."""
        with self.app.test_client() as client:
            resp = client.get("/reply/nonexistent")
            self.assertEqual(resp.status_code, 404)

    def test_toplevel_unlisted_uri_scheme(self):
        """The URI uses /reply/<slug> scheme for top-level posts."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata(None, "unlisted-with-title")
        self.assertEqual(metadata["uri"], "/reply/unlisted-with-title")

    def test_toplevel_unlisted_no_reply_to(self):
        """Top-level unlisted posts have no reply-to."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata(None, "unlisted-with-title")
        self.assertNotIn("reply-to", metadata)

    def test_toplevel_unlisted_title_parsed(self):
        """The title is parsed from heading for top-level posts."""
        with self.app.app_context():
            metadata = self.app._parse_reply_metadata(None, "unlisted-with-title")
        self.assertEqual(metadata["title"], "My Unlisted Post")


class MediaAttachmentRenderingTest(unittest.TestCase):
    """Tests for media attachment rendering in reactions."""

    def setUp(self):
        from madblog.app import app

        self.app = app

    def test_ap_image_attachment_rendered(self):
        """AP interaction with image attachment renders media grid."""
        from flask import render_template_string

        template = """
        {%- from 'reactions.html' import render_media_attachments -%}
        {{ render_media_attachments(metadata) }}
        """
        metadata = {
            "raw_object": {
                "attachment": [
                    {
                        "type": "Document",
                        "mediaType": "image/jpeg",
                        "url": "https://example.com/image.jpg",
                        "name": "Test image",
                    }
                ]
            }
        }

        with self.app.app_context():
            html = render_template_string(template, metadata=metadata)

        self.assertIn("reaction-media-grid", html)
        self.assertIn("reaction-media-item", html)
        self.assertIn("https://example.com/image.jpg", html)
        self.assertIn('alt="Test image"', html)
        self.assertIn('loading="lazy"', html)
        self.assertIn('referrerpolicy="no-referrer"', html)

    def test_ap_video_attachment_rendered(self):
        """AP interaction with video attachment renders video element."""
        from flask import render_template_string

        template = """
        {%- from 'reactions.html' import render_media_attachments -%}
        {{ render_media_attachments(metadata) }}
        """
        metadata = {
            "raw_object": {
                "attachment": [
                    {
                        "type": "Video",
                        "mediaType": "video/mp4",
                        "url": "https://example.com/video.mp4",
                    }
                ]
            }
        }

        with self.app.app_context():
            html = render_template_string(template, metadata=metadata)

        self.assertIn("<video", html)
        self.assertIn("https://example.com/video.mp4", html)
        self.assertIn('type="video/mp4"', html)

    def test_ap_audio_attachment_rendered(self):
        """AP interaction with audio attachment renders audio element."""
        from flask import render_template_string

        template = """
        {%- from 'reactions.html' import render_media_attachments -%}
        {{ render_media_attachments(metadata) }}
        """
        metadata = {
            "raw_object": {
                "attachment": [
                    {
                        "type": "Audio",
                        "mediaType": "audio/mpeg",
                        "url": "https://example.com/audio.mp3",
                    }
                ]
            }
        }

        with self.app.app_context():
            html = render_template_string(template, metadata=metadata)

        self.assertIn("<audio", html)
        self.assertIn("https://example.com/audio.mp3", html)

    def test_wm_mf2_photo_rendered(self):
        """Webmention with mf2 photo renders media grid."""
        from flask import render_template_string

        template = """
        {%- from 'reactions.html' import render_media_attachments -%}
        {{ render_media_attachments(metadata) }}
        """
        metadata = {
            "mf2": {
                "photo": [
                    "https://example.com/photo1.jpg",
                    "https://example.com/photo2.jpg",
                ]
            }
        }

        with self.app.app_context():
            html = render_template_string(template, metadata=metadata)

        self.assertIn("reaction-media-grid", html)
        self.assertIn("https://example.com/photo1.jpg", html)
        self.assertIn("https://example.com/photo2.jpg", html)
        self.assertIn('data-count="2"', html)

    def test_overflow_attachments_collapsed(self):
        """More than 4 attachments shows overflow toggle."""
        from flask import render_template_string

        template = """
        {%- from 'reactions.html' import render_media_attachments -%}
        {{ render_media_attachments(metadata) }}
        """
        metadata = {
            "mf2": {
                "photo": [
                    "https://example.com/1.jpg",
                    "https://example.com/2.jpg",
                    "https://example.com/3.jpg",
                    "https://example.com/4.jpg",
                    "https://example.com/5.jpg",
                    "https://example.com/6.jpg",
                ]
            }
        }

        with self.app.app_context():
            html = render_template_string(template, metadata=metadata)

        self.assertIn("reaction-media-overflow", html)
        self.assertIn("reaction-media-toggle", html)
        self.assertIn("+2 more", html)

    def test_empty_metadata_no_output(self):
        """Empty metadata produces no media grid."""
        from flask import render_template_string

        template = """
        {%- from 'reactions.html' import render_media_attachments -%}
        {{ render_media_attachments(metadata) }}
        """

        with self.app.app_context():
            html = render_template_string(template, metadata={})

        self.assertNotIn("reaction-media-grid", html)

    def test_document_type_with_image_mediatype(self):
        """Document type with image mediaType renders as image."""
        from flask import render_template_string

        template = """
        {%- from 'reactions.html' import render_media_attachments -%}
        {{ render_media_attachments(metadata) }}
        """
        metadata = {
            "raw_object": {
                "attachment": [
                    {
                        "type": "Document",
                        "mediaType": "image/png",
                        "url": "https://example.com/doc.png",
                    }
                ]
            }
        }

        with self.app.app_context():
            html = render_template_string(template, metadata=metadata)

        self.assertIn("<img", html)
        self.assertIn("https://example.com/doc.png", html)

    def test_wm_mf2_photo_with_alt_text(self):
        """Webmention mf2 photo object with alt text renders alt attribute."""
        from flask import render_template_string

        template = """
        {%- from 'reactions.html' import render_media_attachments -%}
        {{ render_media_attachments(metadata) }}
        """
        metadata = {
            "mf2": {
                "photo": [
                    {"value": "https://example.com/photo.jpg", "alt": "A scenic view"}
                ]
            }
        }

        with self.app.app_context():
            html = render_template_string(template, metadata=metadata)

        self.assertIn("https://example.com/photo.jpg", html)
        self.assertIn('alt="A scenic view"', html)
