"""LiveView component for the review page."""

from __future__ import annotations

import logging
from datetime import UTC
from datetime import datetime
from typing import Any

from pyview import LiveView
from pyview.meta import PyViewMeta
from pyview.template.live_template import RenderedContent
from pyview.template.live_template import SocketWithComponents
from sqlalchemy import and_
from sqlalchemy import func
from sqlalchemy.sql import select

from fenliu.database import get_db
from fenliu.models import BlockedHashtag
from fenliu.models import BlockedUser
from fenliu.models import HashtagStream
from fenliu.models import Post
from fenliu.schemas import HashtagStreamResponse
from fenliu.schemas import PostResponse
from fenliu.services.blocklist_cache import add_blocked_hashtag
from fenliu.services.blocklist_cache import add_blocked_user
from fenliu.services.export_eligibility import _matches_pattern
from fenliu.services.export_eligibility import reject_blocked_posts
from fenliu.services.fediverse import FediverseClient
from fenliu.utils.html import clean_url
from fenliu.utils.html import escape_html
from fenliu.utils.html import sanitize_post_content
from fenliu.utils.html import strip_html_tags
from fenliu.utils.template_helpers import generate_header

logger = logging.getLogger(__name__)


class StringRenderedContent(RenderedContent):
    """Simple RenderedContent implementation for raw HTML strings."""

    def __init__(self, html: str) -> None:
        """Initialize StringRenderedContent with HTML string.

        Args:
            html: The HTML string to render.

        """
        self._html = html

    def tree(self) -> dict[str, Any]:
        """Return tree representation of the HTML."""
        # For static HTML, return a simple tree structure
        return {"s": [self._html]}

    def text(self, socket: SocketWithComponents | None = None) -> str:  # noqa: ARG002
        """Return the HTML string."""
        return self._html


class ReviewLiveView(LiveView):
    """LiveView for manual review of posts."""

    # Magic number constants
    MAX_SCORE = 100
    HIGH_SCORE_THRESHOLD = 75
    MEDIUM_SCORE_THRESHOLD = 50
    LOW_SCORE_THRESHOLD = 25
    MEDIA_COUNT_DOUBLE = 2
    MEDIA_COUNT_TRIPLE = 3
    MEDIA_COUNT_MULTIPLE = 4
    LAST_VISIBLE_INDEX = 3

    def __init__(self) -> None:
        """Initialize LiveView state."""
        super().__init__()
        self.streams: list[dict[str, Any]] = []
        self.streams_by_id: dict[int, dict[str, Any]] = {}
        self.posts: list[dict[str, Any]] = []
        self.filters: dict[str, Any] = {
            "stream_id": None,
            "min_score": None,
            "max_score": None,
        }
        self.loading = False
        self.stats: dict[str, Any] = {
            "total_posts": 0,
            "unreviewed_posts": 0,
            "approved_posts": 0,
            "rejected_posts": 0,
        }
        self.error: str | None = None
        self.selected_post_id: int | None = None
        self.modal_open = False
        self.manual_score: int | None = None
        self.reviewer_notes: str = ""
        # Reblog control blocklists (lowercased for fast lookup)
        self.blocked_user_identifiers: set[str] = set()
        self.blocked_hashtags: set[str] = set()
        # Store blocked users with their pattern types for pattern matching
        self.blocked_users_with_patterns: list[tuple[str, str]] = []  # (identifier, pattern_type)
        # Sorting
        self.sort_by: str = "created_at"  # "created_at" | "spam_score"
        self.sort_dir: str = "desc"  # "asc" | "desc"

    async def mount(self, socket, session) -> None:
        """Initialize the LiveView."""
        await super().mount(socket, session)
        logger.info("ReviewLiveView mounting")
        self.socket = socket
        socket.context = self
        await self.load_streams()
        await self.load_blocklists()
        await self.load_posts()
        await self.load_stats()
        self.loading = False

    async def handle_params(self, url: Any, params: dict, socket: Any) -> None:  # noqa: ARG002
        """Handle URL parameters."""
        logger.info(f"handle_params called with params: {params}")
        # Handle any URL parameters if needed

    async def load_streams(self) -> None:
        """Load hashtag streams from database."""
        try:
            with get_db() as db:
                stmt = select(HashtagStream).order_by(HashtagStream.created_at.desc())
                result = db.execute(stmt)
                streams = result.scalars().all()
                self.streams = [
                    HashtagStreamResponse.model_validate(stream).model_dump(mode="json") for stream in streams
                ]
                self.streams_by_id = {s["id"]: s for s in self.streams}
        except Exception as e:
            logger.error(f"Error loading streams: {e}")
            self.error = f"Failed to load streams: {e}"
            self.streams = []
            self.streams_by_id = {}

    def _apply_stream_filter_to_stmt(self, stmt: Any) -> Any:
        """Apply the stream_id filter to a SELECT statement.

        Returns the statement unchanged if the filter value is absent or invalid.

        """
        raw = self.filters["stream_id"]
        if not raw:
            return stmt
        try:
            stream_id = int(raw)
            stmt = stmt.where(Post.stream_id == stream_id)
            logger.info(f"Applied stream filter: stream_id={stream_id}")
        except (ValueError, TypeError):
            logger.warning(f"Invalid stream filter value: {raw}")
        return stmt

    def _build_score_conditions(self) -> list:
        """Return SQLAlchemy conditions for min/max spam score filters."""
        conditions = []
        raw_min = self.filters["min_score"]
        raw_max = self.filters["max_score"]
        logger.info(f"Min score filter: {raw_min}, Max score filter: {raw_max}")
        if raw_min is not None:
            try:
                conditions.append(Post.spam_score >= int(raw_min))
                logger.info(f"Applied min_score filter: >= {raw_min}")
            except (ValueError, TypeError):
                logger.warning(f"Invalid min_score filter value: {raw_min}")
        if raw_max is not None:
            try:
                conditions.append(Post.spam_score <= int(raw_max))
                logger.info(f"Applied max_score filter: <= {raw_max}")
            except (ValueError, TypeError):
                logger.warning(f"Invalid max_score filter value: {raw_max}")
        return conditions

    async def load_blocklists(self) -> None:
        """Load blocked users and hashtags for display annotations."""
        try:
            with get_db() as db:
                users = db.execute(select(BlockedUser)).scalars().all()
                # Store identifiers for backward compatibility and pattern info for matching
                self.blocked_user_identifiers = {u.account_identifier.lower() for u in users}
                self.blocked_users_with_patterns = [(u.account_identifier.lower(), u.pattern_type) for u in users]

                tags = db.execute(select(BlockedHashtag)).scalars().all()
                self.blocked_hashtags = {t.hashtag.lower() for t in tags}
        except Exception as e:
            logger.error(f"Error loading blocklists: {e}")
            self.blocked_user_identifiers = set()
            self.blocked_hashtags = set()
            self.blocked_users_with_patterns = []

    async def load_posts(self) -> None:
        """Load posts based on current filters."""
        logger.info(f"Loading posts with filters: {self.filters}")
        try:
            with get_db() as db:
                stmt = select(Post).where(Post.reviewed.is_(False))

                stmt = self._apply_stream_filter_to_stmt(stmt)

                conditions = self._build_score_conditions()
                if conditions:
                    stmt = stmt.where(and_(*conditions))
                    logger.info(f"Applied conditions: {conditions}")

                sort_col = Post.spam_score if self.sort_by == "spam_score" else Post.created_at
                stmt = stmt.order_by(sort_col.asc() if self.sort_dir == "asc" else sort_col.desc()).limit(50)
                result = db.execute(stmt)
                posts = result.scalars().all()
                logger.info(f"Loaded {len(posts)} posts")

                self.posts = [
                    self._annotate_post(PostResponse.model_validate(post).model_dump(mode="json")) for post in posts
                ]
        except Exception as e:
            logger.error(f"Error loading posts: {e}")
            self.error = f"Failed to load posts: {e}"
            self.posts = []

    async def load_stats(self) -> None:
        """Load statistics."""
        try:
            with get_db() as db:
                # Total posts
                total_posts: int = db.scalar(select(func.count()).select_from(Post))

                # Unreviewed posts
                unreviewed_posts: int = db.scalar(
                    select(func.count()).select_from(Post).where(Post.reviewed.is_(False))
                )

                # Approved posts
                approved_posts: int = db.scalar(
                    select(func.count())
                    .select_from(Post)
                    .where(
                        Post.reviewed.is_(True),
                        Post.approved.is_(True),
                    )
                )

                # Rejected posts
                rejected_posts: int = db.scalar(
                    select(func.count())
                    .select_from(Post)
                    .where(
                        Post.reviewed.is_(True),
                        Post.approved.is_(False),
                    )
                )

                self.stats = {
                    "total_posts": total_posts,
                    "unreviewed_posts": unreviewed_posts,
                    "approved_posts": approved_posts,
                    "rejected_posts": rejected_posts,
                }
        except Exception as e:
            logger.error(f"Error loading stats: {e}")
            self.error = f"Failed to load stats: {e}"
            self.stats = {
                "total_posts": 0,
                "unreviewed_posts": 0,
                "approved_posts": 0,
                "rejected_posts": 0,
            }

    async def handle_event(self, event: str, payload: dict, socket: Any) -> None:
        """Handle LiveView events."""
        logger.info(f"Handling event: {event} with payload: {payload}")
        logger.info(f"Socket: {socket}, type: {type(socket)}")

        # Dispatch table for event handlers
        event_handlers = {
            "filter_changed": self.handle_filter_change,
            "apply_filters": self.handle_apply_filters,
            "clear_filters": self.handle_clear_filters,
            "refresh": self.handle_refresh,
            "refresh_blocklists": self.handle_refresh_blocklists,
            "approve_post": self.handle_approve_post,
            "reject_post": self.handle_reject_post,
            "open_score_modal": self.handle_open_score_modal,
            "close_modal": self.handle_close_modal,
            "update_manual_score": self.handle_update_manual_score,
            "save_manual_score": self.handle_save_manual_score,
            "block_user": self.handle_block_user,
            "block_hashtag": self.handle_block_hashtag,
            "set_sort": self.handle_set_sort,
            "reject_blocked_posts": self.handle_reject_blocked_posts,
        }

        handler = event_handlers.get(event)
        if handler:
            logger.info(f"Handling {event}: payload={payload}")
            await handler(payload)
        else:
            logger.warning(f"Unknown event: {event}")

    def _normalize_filter_value(self, value: Any) -> Any:
        """Normalize filter value from form payload.

        Form values can come as lists (e.g., ['50']) or strings.
        Convert lists to single values, empty strings to None.
        """
        if isinstance(value, list):
            if len(value) == 0:
                return None
            # Get first element and process it
            value = value[0]

        if isinstance(value, str):
            if value.strip() == "":
                return None
            # Try to convert numeric strings to int
            try:
                return int(value)
            except (ValueError, TypeError):
                return value

        return value

    def _apply_filter_old_format(self, payload: dict) -> None:
        """Handle old-style phx-value filter format (field + value keys)."""
        field = payload["field"]
        value = self._normalize_filter_value(payload["value"])
        if field in self.filters:
            self.filters[field] = value
            logger.info(f"Updated filter {field} to {value} (old format). Filters: {self.filters}")

    def _apply_filter_new_format(self, payload: dict) -> None:
        """Handle new form-based filter format (filter field names as keys)."""
        for field in self.filters:
            if field in payload:
                value = self._normalize_filter_value(payload[field])
                self.filters[field] = value
                logger.info(f"Updated filter {field} to {value}. Filters: {self.filters}")

    async def handle_filter_change(self, payload: dict) -> None:
        """Handle filter change event."""
        logger.info(f"Filter change payload: {payload}")
        # Support both formats:
        # 1. New form format: payload contains filter field names as keys
        # 2. Old phx-value-field format: payload contains 'field' and 'value' keys
        if "field" in payload and "value" in payload:
            self._apply_filter_old_format(payload)
        else:
            self._apply_filter_new_format(payload)

    async def handle_apply_filters(self, payload: dict) -> None:
        """Apply filters and reload posts."""
        logger.info(f"handle_apply_filters called with payload: {payload}")
        # Update filters from form submission
        for field in self.filters:
            if field in payload:
                value = self._normalize_filter_value(payload[field])
                self.filters[field] = value
        logger.info(f"Updated filters: {self.filters}")
        self.loading = True
        await self.load_posts()
        self.loading = False

    async def handle_clear_filters(self, _payload: dict | None = None) -> None:
        """Clear all filters.

        Args:
            _payload: Optional event payload (not used).

        """
        self.filters = {
            "stream_id": None,
            "min_score": None,
            "max_score": None,
        }
        self.loading = True
        await self.load_posts()
        self.loading = False

    async def handle_refresh(self, _payload: dict | None = None) -> None:
        """Refresh all data by fetching new posts and reloading.

        Args:
            _payload: Optional event payload (not used).

        """
        self.loading = True
        await self._fetch_all_active_streams()
        await self.load_streams()
        await self.load_posts()
        await self.load_stats()
        self.loading = False

    async def handle_refresh_blocklists(self, _payload: dict | None = None) -> None:
        """Refresh blocklists from database and re-annotate current posts.

        This allows users to add blocks in Settings and have them apply to the
        current review page without losing their place.

        Args:
            _payload: Optional event payload (not used).

        """
        try:
            await self.load_blocklists()
            # Re-annotate all current posts with updated blocklists
            self.posts = [self._annotate_post(p) for p in self.posts]
            logger.info("Blocklists refreshed and posts re-annotated")
        except Exception as e:
            logger.error(f"Error refreshing blocklists: {e}")
            self.error = f"Failed to refresh blocklists: {e}"

    async def _fetch_all_active_streams(self) -> None:
        """Fetch new posts from all active hashtag streams."""
        try:
            with get_db() as db:
                # Get all active streams
                stmt = select(HashtagStream).where(HashtagStream.active.is_(True))
                result = db.execute(stmt)
                active_streams = result.scalars().all()

                if not active_streams:
                    logger.info("No active streams to fetch")
                    return

                logger.info(f"Fetching posts for {len(active_streams)} active streams")

                for stream in active_streams:
                    try:
                        client = FediverseClient(instance=stream.instance)
                        try:
                            # Import here to avoid circular imports
                            from fenliu.api import _save_fetched_posts  # noqa: PLC0415

                            processed_posts = await client.fetch_and_process_hashtag(stream.hashtag, limit=50)
                            posts_saved = _save_fetched_posts(db, processed_posts, stream.id, stream.instance)

                            stream.last_check = datetime.now(UTC)
                            db.commit()

                            logger.info(
                                f"Fetched {len(processed_posts)} posts for #{stream.hashtag}, saved {posts_saved}"
                            )
                        finally:
                            await client.close()
                    except Exception as e:
                        db.rollback()
                        logger.error(f"Error fetching posts for stream {stream.hashtag}: {e}")
                        # Continue with next stream even if one fails
                        continue

        except Exception as e:
            logger.error(f"Error in _fetch_all_active_streams: {e}")
            self.error = f"Failed to fetch posts: {e}"

    async def handle_reject_blocked_posts(self, _payload: dict | None = None) -> None:
        """Reject all unreviewed posts that match any active reblog-control filter."""
        try:
            with get_db() as db:
                count = reject_blocked_posts(db)
                db.commit()
            logger.info(f"Auto-rejected {count} blocked posts from review page")
            await self.load_posts()
            await self.load_stats()
        except Exception as e:
            logger.error(f"Error rejecting blocked posts: {e}")
            self.error = f"Failed to reject blocked posts: {e}"

    async def handle_set_sort(self, payload: dict) -> None:
        """Handle a column-header sort click, toggling direction on re-click."""
        col = payload.get("col", "created_at")
        if col not in {"created_at", "spam_score"}:
            return
        if self.sort_by == col:
            self.sort_dir = "asc" if self.sort_dir == "desc" else "desc"
        else:
            self.sort_by = col
            self.sort_dir = "desc"
        await self.load_posts()

    async def handle_block_user(self, payload: dict) -> None:
        """Add a post's author to the blocked-users list with exact pattern matching."""
        account = (payload.get("account") or "").strip()
        if not account:
            return
        try:
            with get_db() as db:
                existing = db.execute(
                    select(BlockedUser).where(
                        (BlockedUser.account_identifier == account) & (BlockedUser.pattern_type == "exact")
                    )
                ).scalar_one_or_none()
                if existing is None:
                    db.add(BlockedUser(account_identifier=account, pattern_type="exact"))
                    db.commit()
                    # Update cache after successful DB commit
                    add_blocked_user(account)
            self.blocked_user_identifiers.add(account.lower())
            self.blocked_users_with_patterns.append((account.lower(), "exact"))
            # Re-annotate current posts so the row updates live
            self.posts = [self._annotate_post(p) for p in self.posts]
        except Exception as e:
            logger.error(f"Error blocking user {account}: {e}")
            self.error = f"Failed to block user: {e}"

    async def handle_block_hashtag(self, payload: dict) -> None:
        """Add a hashtag to the blocked-hashtags list."""
        raw = (payload.get("hashtag") or "").strip().lstrip("#").lower()
        if not raw:
            return
        try:
            with get_db() as db:
                existing = db.execute(select(BlockedHashtag).where(BlockedHashtag.hashtag == raw)).scalar_one_or_none()
                if existing is None:
                    db.add(BlockedHashtag(hashtag=raw))
                    db.commit()
                    # Update cache after successful DB commit
                    add_blocked_hashtag(raw)
            self.blocked_hashtags.add(raw)
            # Re-annotate current posts so the row updates live
            self.posts = [self._annotate_post(p) for p in self.posts]
        except Exception as e:
            logger.error(f"Error blocking hashtag #{raw}: {e}")
            self.error = f"Failed to block hashtag: {e}"

    async def handle_approve_post(self, payload: dict) -> None:
        """Approve a post."""
        raw = payload.get("post_id")
        if raw is None:
            return
        try:
            post_id = int(raw)
        except (TypeError, ValueError):
            return

        try:
            with get_db() as db:
                stmt = select(Post).where(Post.id == post_id)
                result = db.execute(stmt)
                post = result.scalar_one_or_none()

                if post:
                    post.reviewed = True
                    post.approved = True
                    post.reviewed_at = datetime.now(UTC)
                    post.queue_status = "pending"
                    db.commit()

            # Remove from current posts list
            self.posts = [p for p in self.posts if p["id"] != post_id]
            await self.load_stats()
        except Exception as e:
            logger.error(f"Error approving post {post_id}: {e}")
            self.error = f"Failed to approve post: {e}"

    async def handle_reject_post(self, payload: dict) -> None:
        """Reject a post."""
        raw = payload.get("post_id")
        if raw is None:
            return
        try:
            post_id = int(raw)
        except (TypeError, ValueError):
            return

        try:
            with get_db() as db:
                stmt = select(Post).where(Post.id == post_id)
                result = db.execute(stmt)
                post = result.scalar_one_or_none()

                if post:
                    post.reviewed = True
                    post.approved = False
                    post.reviewed_at = datetime.now(UTC)
                    post.queue_status = None
                    db.commit()

            # Remove from current posts list
            self.posts = [p for p in self.posts if p["id"] != post_id]
            await self.load_stats()
        except Exception as e:
            logger.error(f"Error rejecting post {post_id}: {e}")
            self.error = f"Failed to reject post: {e}"

    async def handle_open_score_modal(self, payload: dict) -> None:
        """Open score adjustment modal."""
        raw = payload.get("post_id")
        if raw is None:
            return
        try:
            post_id = int(raw)
        except (TypeError, ValueError):
            return

        self.selected_post_id = post_id
        self.modal_open = True
        self.reviewer_notes = ""

        # Pre-fill with current spam score for the post
        try:
            post = next((p for p in self.posts if p["id"] == post_id), None)
            if post:
                self.manual_score = post.get("spam_score", 50)
            else:
                self.manual_score = 50
        except Exception as e:
            logger.error(f"Error loading post score: {e}")
            self.manual_score = 50

    async def handle_close_modal(self, _payload: dict | None = None) -> None:
        """Close the modal.

        Args:
            _payload: Optional event payload (not used).

        """
        self.modal_open = False
        self.selected_post_id = None
        self.manual_score = None
        self.reviewer_notes = ""

    async def handle_update_manual_score(self, payload: dict) -> None:
        """Update manual score value."""
        value = payload.get("value")
        if value is not None:
            try:
                score = int(value)
                if 0 <= score <= self.MAX_SCORE:
                    self.manual_score = score
            except (ValueError, TypeError):
                pass

        notes = payload.get("notes")
        if notes is not None:
            self.reviewer_notes = notes

    async def handle_save_manual_score(self, _payload: dict | None = None) -> None:
        """Save manual score adjustment.

        Args:
            _payload: Optional event payload (not used).

        """
        if self.selected_post_id is None or self.manual_score is None:
            return

        try:
            with get_db() as db:
                stmt = select(Post).where(Post.id == self.selected_post_id)
                result = db.execute(stmt)
                post = result.scalar_one_or_none()

                if post:
                    post.manual_spam_score = self.manual_score
                    post.reviewer_notes = self.reviewer_notes
                    post.reviewed = True
                    post.reviewed_at = datetime.now(UTC)
                    db.commit()

            selected = self.selected_post_id
            await self.handle_close_modal()
            # Remove from current posts list after closing modal
            self.posts = [p for p in self.posts if p["id"] != selected]
            await self.load_stats()
        except Exception as e:
            logger.error(f"Error saving manual score: {e}")
            self.error = f"Failed to save score: {e}"

    def get_score_class(self, score: int) -> str:
        """Get CSS class for score."""
        if score >= self.HIGH_SCORE_THRESHOLD:
            return "score-very-high"
        elif score >= self.MEDIUM_SCORE_THRESHOLD:
            return "score-high"
        elif score >= self.LOW_SCORE_THRESHOLD:
            return "score-medium"
        else:
            return "score-low"

    def _get_media_grid_class(self, display_count: int) -> str:
        """Return the CSS grid class for a given number of displayed media items."""
        if display_count == self.MEDIA_COUNT_DOUBLE:
            return "double"
        if display_count == self.MEDIA_COUNT_TRIPLE:
            return "triple"
        if display_count >= self.MEDIA_COUNT_MULTIPLE:
            return "multiple"
        return "single"

    def _render_media_item(self, media: dict, index: int, media_count: int) -> str:
        """Render a single media item within a media grid."""
        media_type = media.get("type", "image")
        preview_url = media.get("preview_url") or media.get("url", "")
        description = media.get("description", "Media attachment")
        original_url = media.get("url") or preview_url

        safe_preview_url = clean_url(preview_url)
        safe_original_url = clean_url(original_url)
        safe_description = escape_html(description)

        extra_div = ""
        if media_count > self.MEDIA_COUNT_MULTIPLE and index == self.LAST_VISIBLE_INDEX:
            extra_div = f'<div class="media-count">+{media_count - self.MEDIA_COUNT_MULTIPLE}</div>'

        return f"""
                <div class="media-item {media_type}">
                    <a href="{safe_original_url}" target="_blank" rel="noopener noreferrer">
                        <img src="{safe_preview_url}" alt="{safe_description}" loading="lazy" />
                    </a>
                    {extra_div}
                </div>
            """

    def render_media_attachments(self, attachments: list[dict]) -> str:
        """Render media attachments HTML."""
        if not attachments:
            return ""

        media_count = len(attachments)
        display_count = min(media_count, self.MEDIA_COUNT_MULTIPLE)
        grid_class = self._get_media_grid_class(display_count)

        items_html = "".join(self._render_media_item(attachments[i], i, media_count) for i in range(display_count))

        description_html = ""
        if attachments[0].get("description"):
            desc = escape_html(attachments[0]["description"])
            description_html = f'<div class="media-description">{desc}</div>'

        return f"""
        <div class="media-attachments">
            <div class="media-grid {grid_class}">
        {items_html}
            </div>
        {description_html}
        </div>"""

    def _annotate_post(self, post: dict) -> dict:
        """Attach blocking metadata to a post dict for display purposes."""
        blocked_reasons: list[str] = []

        # Check author with pattern matching support
        username = (post.get("author_username") or "").strip().lower()
        instance = (post.get("instance") or "").strip().lower()
        if username:
            candidates = {username, f"@{username}", f"{username}@{instance}", f"@{username}@{instance}"}
            # Check pattern-based blocking
            for identifier, pattern_type in self.blocked_users_with_patterns:
                for candidate in candidates:
                    if _matches_pattern(candidate, identifier, pattern_type):
                        blocked_reasons.append("user")
                        break
                if "user" in blocked_reasons:
                    break

        # Check hashtags
        tags: list = post.get("hashtags") or []
        blocked_tags = {t.strip().lstrip("#").lower() for t in tags if t} & self.blocked_hashtags
        if blocked_tags:
            blocked_reasons.append("hashtag")

        post["_blocked_reasons"] = blocked_reasons
        post["_blocked_tags"] = blocked_tags
        return post

    def render_stream_hashtag(self, post: dict) -> str:
        """Render stream hashtag for a post."""
        stream_id = post.get("stream_id")
        if not stream_id:
            return "None"

        stream = next((s for s in self.streams if s["id"] == stream_id), None)
        if not stream:
            return "None"

        hashtag = escape_html(stream.get("hashtag", ""))
        instance = escape_html(stream.get("instance", ""))
        return f"#{hashtag} ({instance})"

    def _get_stream_hashtag(self, post: dict) -> str:
        """Return the stream hashtag for a post (lowercased), or empty string."""
        stream_id = post.get("stream_id")
        stream = next((s for s in self.streams if s["id"] == stream_id), None)
        return (stream.get("hashtag") or "").lower() if stream else ""

    def _filter_post_hashtags(self, hashtags: list, stream_hashtag: str) -> list[str]:
        """Filter and normalise hashtags, removing the stream tag and invalid entries."""
        filtered = []
        for tag in hashtags:
            if not tag or not isinstance(tag, str):
                continue
            tag_lower = tag.strip().lower()
            if tag_lower and not tag_lower.endswith(stream_hashtag):
                clean_tag = tag[1:] if tag.startswith("#") else tag
                filtered.append(clean_tag)
        return filtered

    def render_other_hashtags(self, post: dict) -> str:
        """Render other hashtags for a post, striking through blocked ones with inline block buttons."""
        hashtags = post.get("hashtags")
        if not hashtags or not isinstance(hashtags, list):
            return ""

        stream_hashtag = self._get_stream_hashtag(post)
        filtered_tags = self._filter_post_hashtags(hashtags, stream_hashtag)

        if not filtered_tags:
            return ""

        post_id = post["id"]
        blocked_tags: set = post.get("_blocked_tags") or set()
        tag_parts = []
        for tag in filtered_tags:
            escaped = escape_html(tag)
            tag_lower = tag.lower()
            if tag_lower in blocked_tags:
                tag_parts.append(
                    '<span class="inline-flex items-center px-1.5 py-0.5 text-xs rounded'
                    ' bg-red-100 text-red-500 mr-1 mb-1 line-through"'
                    ' title="Blocked hashtag">#' + escaped + "</span>"
                )
            else:
                block_btn = (
                    f'<button phx-click="block_hashtag" phx-value-hashtag="{escaped}"'
                    f' phx-value-post_id="{post_id}"'
                    ' class="ml-0.5 text-gray-300 hover:text-orange-500 transition-colors"'
                    f' title="Block #{escaped}">'
                    '<i class="fas fa-ban text-xs"></i></button>'
                )
                tag_parts.append(
                    '<span class="inline-flex items-center px-1.5 py-0.5 text-xs'
                    ' rounded bg-gray-100 text-gray-700 mr-1 mb-1">#' + escaped + block_btn + "</span>"
                )
        return f'<div class="mt-1">{"".join(tag_parts)}</div>'

    def _render_author_avatar(self, author_name: str, author_url: str) -> str:
        """Render the author avatar wrapped in a link or div."""
        initial = author_name[0].upper() if author_name else "?"
        avatar = f"""
                              <div class="avatar-placeholder" title="{escape_html(author_name)}">
                                {initial}
                              </div>"""
        if author_url:
            return (
                f'<a href="{author_url}" target="_blank" class="flex-shrink-0">'
                f"{avatar}\n                            </a>"
            )
        return f'<div class="flex-shrink-0">{avatar}\n                            </div>'

    def _render_author_name(self, author_name: str, author_url: str) -> str:
        """Render the author display name as a link or plain div."""
        name = escape_html(author_name)
        if author_url:
            return (
                f'<a href="{author_url}" target="_blank" '
                'class="font-medium text-gray-900 hover:text-indigo-600 '
                f'hover:underline block sm:inline">{name}</a>'
            )
        return f'<div class="font-medium text-gray-900 block sm:inline">{name}</div>'

    def _render_author_username(self, author_username: str, author_url: str) -> str:
        """Render the author @username as a link or plain div."""
        if not author_username:
            return ""
        text = f"@{escape_html(author_username)}"
        if author_url:
            return (
                f'<a href="{author_url}" target="_blank" '
                'class="text-gray-500 text-sm hover:text-indigo-500 '
                f'hover:underline block sm:inline sm:ml-1">{text}</a>'
            )
        return f'<div class="text-gray-500 text-sm block sm:inline sm:ml-1">{text}</div>'

    def _render_author_username_with_block(
        self, post: dict, author_username: str, instance: str, author_url: str
    ) -> str:
        """Render the author @username with an inline block-user button."""
        if not author_username:
            return ""
        post_id = post["id"]
        text = f"@{escape_html(author_username)}"
        username_lower = author_username.strip().lower()
        bare = username_lower.lstrip("@")
        account = f"@{bare}@{instance.lower()}" if instance else f"@{bare}"
        candidates = {username_lower, f"@{username_lower}", bare, f"@{bare}", account}
        # Check if already blocked using pattern matching
        already_blocked = False
        for identifier, pattern_type in self.blocked_users_with_patterns:
            for candidate in candidates:
                if _matches_pattern(candidate, identifier, pattern_type):
                    already_blocked = True
                    break
            if already_blocked:
                break

        username_html: str
        if author_url:
            username_html = (
                f'<a href="{author_url}" target="_blank" '
                'class="text-gray-500 text-sm hover:text-indigo-500 '
                f'hover:underline">{text}</a>'
            )
        else:
            username_html = f'<span class="text-gray-500 text-sm">{text}</span>'

        if already_blocked:
            block_btn = (
                '<span class="ml-1 text-red-400 cursor-default align-middle"'
                ' title="Author already blocked">'
                '<i class="fas fa-user-slash text-xs"></i></span>'
            )
        else:
            block_btn = (
                f'<button phx-click="block_user" phx-value-account="{escape_html(account)}"'
                f' phx-value-post_id="{post_id}"'
                ' class="ml-1 text-gray-300 hover:text-red-500 transition-colors align-middle"'
                f' title="Block {escape_html(account)}">'
                '<i class="fas fa-user-slash text-xs"></i></button>'
            )

        return '<div class="flex items-center sm:ml-1 mt-0.5">' + username_html + block_btn + "</div>"

    def _render_external_link_button(self, post_url: str) -> str:
        """Render the external-link button for a post, or empty string."""
        if not post_url:
            return ""
        btn_class = (
            "btn-external p-1 sm:p-2 rounded-md text-sm font-medium transition-colors w-full sm:w-auto text-center"
        )
        return f"""
                              <a
                                href="{post_url}"
                                target="_blank"
                                class="{btn_class}"
                                title="View original post"
                              >
                                <i class="fas fa-external-link-alt"></i>
                              </a>"""

    def _render_header(self) -> str:
        """Render the header section with title and buttons."""
        refresh_class = (
            "inline-flex items-center px-4 py-2 border border-transparent text-sm "
            "font-medium rounded-md shadow-sm text-white bg-indigo-600 "
            "hover:bg-indigo-700 focus:outline-none focus:ring-2 "
            "focus:ring-offset-2 focus:ring-indigo-500"
        )
        back_class = (
            "inline-flex items-center px-4 py-2 border border-gray-300 text-sm "
            "font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 "
            "focus:outline-none focus:ring-2 focus:ring-offset-2 "
            "focus:ring-indigo-500"
        )
        reject_blocked_class = (
            "inline-flex items-center px-4 py-2 border border-transparent text-sm "
            "font-medium rounded-md shadow-sm text-white bg-amber-600 "
            "hover:bg-amber-700 focus:outline-none focus:ring-2 "
            "focus:ring-offset-2 focus:ring-amber-500"
        )
        # Count how many currently-visible posts are blocked
        blocked_count = sum(1 for p in self.posts if p.get("_blocked_reasons"))
        reject_btn = ""
        if blocked_count:
            reject_btn = (
                f'<button phx-click="reject_blocked_posts" class="{reject_blocked_class}" '
                f'title="Reject all {blocked_count} blocked post(s) in current view">'
                f'<i class="fas fa-ban mr-2"></i> Reject blocked ({blocked_count})'
                f"</button>"
            )
        return f"""
                <!-- Header -->
                <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6">
                  <div>
                    <h1 class="text-2xl font-bold text-gray-900">Manual Review</h1>
                    <p class="mt-1 text-sm text-gray-600">
                      Review and moderate posts flagged by the spam detection system
                    </p>
                  </div>
                  <div class="mt-4 sm:mt-0 flex flex-wrap gap-2">
                    {reject_btn}
                    <button phx-click="refresh" class="{refresh_class}">
                      <i class="fas fa-sync-alt mr-2"></i> Refresh
                    </button>
                    <button onclick="window.location.href='/dashboard'" class="{back_class}">
                      <i class="fas fa-arrow-left mr-2"></i> Back to Dashboard
                    </button>
                  </div>
                </div>"""

    def _render_stats_cards(self) -> str:
        """Render the stats cards section."""
        total = self.stats["total_posts"]
        unreviewed = self.stats["unreviewed_posts"]
        approved = self.stats["approved_posts"]
        rejected = self.stats["rejected_posts"]

        return f"""
                <!-- Stats Cards -->
                <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
                  <div class="stat-card water-wave">
                    <div class="flex items-center">
                      <div class="flex-shrink-0">
                        <i class="fas fa-database text-indigo-600 text-xl"></i>
                      </div>
                      <div class="ml-4">
                        <h3 class="text-lg font-medium text-gray-900">{total}</h3>
                        <p class="text-sm text-gray-500">Total Posts</p>
                      </div>
                    </div>
                  </div>

                  <div class="stat-card water-wave">
                    <div class="flex items-center">
                      <div class="flex-shrink-0">
                        <i class="fas fa-clock text-yellow-600 text-xl"></i>
                      </div>
                      <div class="ml-4">
                        <h3 class="text-lg font-medium text-gray-900">{unreviewed}</h3>
                        <p class="text-sm text-gray-500">Unreviewed</p>
                      </div>
                    </div>
                  </div>

                  <div class="stat-card water-wave">
                    <div class="flex items-center">
                      <div class="flex-shrink-0">
                        <i class="fas fa-check-circle text-green-600 text-xl"></i>
                      </div>
                      <div class="ml-4">
                        <h3 class="text-lg font-medium text-gray-900">{approved}</h3>
                        <p class="text-sm text-gray-500">Approved</p>
                      </div>
                    </div>
                  </div>

                  <div class="stat-card water-wave">
                    <div class="flex items-center">
                      <div class="flex-shrink-0">
                        <i class="fas fa-times-circle text-red-600 text-xl"></i>
                      </div>
                      <div class="ml-4">
                        <h3 class="text-lg font-medium text-gray-900">{rejected}</h3>
                        <p class="text-sm text-gray-500">Rejected</p>
                      </div>
                    </div>
                  </div>
                </div>"""

    def _render_filters(self) -> str:
        """Render the filters section."""
        # Build stream options
        stream_options = ""
        for stream in self.streams:
            selected = "selected" if str(stream["id"]) == str(self.filters["stream_id"]) else ""
            hashtag = escape_html(stream["hashtag"])
            instance = escape_html(stream["instance"])
            stream_options += f'<option value="{stream["id"]}" {selected}>#{hashtag} ({instance})</option>'

        select_class = (
            "block w-full pl-3 pr-10 py-2 text-base border-gray-300 "
            "focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 "
            "sm:text-sm rounded-md"
        )
        input_class = (
            "block w-full pl-3 pr-10 py-2 text-base border-gray-300 "
            "focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 "
            "sm:text-sm rounded-md"
        )
        apply_class = (
            "inline-flex items-center px-4 py-2 border border-transparent "
            "text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 "
            "hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 "
            "focus:ring-indigo-500"
        )
        clear_class = (
            "inline-flex items-center px-4 py-2 border border-gray-300 "
            "text-sm font-medium rounded-md text-gray-700 bg-white "
            "hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 "
            "focus:ring-indigo-500"
        )

        min_score = self.filters["min_score"] or ""
        max_score = self.filters["max_score"] or ""

        return f"""
                <!-- Filters -->
                <form phx-submit="apply_filters" class="bg-white shadow rounded-lg p-6 mb-6">
                  <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
                    <div>
                      <label for="streamFilter" class="block text-sm font-medium text-gray-700 mb-1">
                        Filter by Stream
                      </label>
                      <div class="relative">
                        <select id="streamFilter" name="stream_id" phx-change="filter_changed" class="{select_class}">
                          <option value="">All Streams</option>
                          {stream_options}
                        </select>
                      </div>
                    </div>

                    <div>
                      <label for="minScore" class="block text-sm font-medium text-gray-700 mb-1">Min Score</label>
                      <div class="relative">
                        <input type="number" id="minScore" name="min_score" min="0" max="100" value="{min_score}"
                          phx-change="filter_changed" phx-debounce="300" class="{input_class}" placeholder="0" />
                      </div>
                    </div>

                    <div>
                      <label for="maxScore" class="block text-sm font-medium text-gray-700 mb-1">Max Score</label>
                      <div class="relative">
                        <input type="number" id="maxScore" name="max_score" min="0" max="100" value="{max_score}"
                          phx-change="filter_changed" phx-debounce="300" class="{input_class}" placeholder="100" />
                      </div>
                    </div>
                  </div>

                  <div class="mt-6 flex justify-end space-x-3">
                    <button type="submit" class="{apply_class}">Apply Filters</button>
                    <button type="button" phx-click="clear_filters" class="{clear_class}">Clear Filters</button>
                  </div>
                </form>"""

    def _render_posts_section(self) -> str:
        """Render the posts section (loading, error, empty, or table)."""
        if self.loading:
            return self._render_loading_state()
        elif self.error:
            return self._render_error_state()
        elif not self.posts:
            return self._render_empty_state()
        else:
            return self._render_posts_table()

    def _render_loading_state(self) -> str:
        """Render the loading state."""
        return """
                <!-- Loading State -->
                <div class="flex justify-center items-center py-12">
                  <div class="loading-spinner"></div>
                  <p class="ml-3 text-gray-600">Loading posts...</p>
                </div>"""

    def _render_error_state(self) -> str:
        """Render the error state."""
        error_msg = escape_html(self.error)
        return f"""
                <!-- Error State -->
                <div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
                  <div class="flex">
                    <div class="flex-shrink-0">
                      <i class="fas fa-exclamation-circle text-red-400"></i>
                    </div>
                    <div class="ml-3">
                      <h3 class="text-sm font-medium text-red-800">Error</h3>
                      <div class="mt-2 text-sm text-red-700">
                        <p>{error_msg}</p>
                      </div>
                    </div>
                  </div>
                </div>"""

    def _render_empty_state(self) -> str:
        """Render the empty state."""
        return """
                <!-- Empty State -->
                <div class="text-center py-12">
                  <i class="fas fa-inbox text-gray-400 text-4xl mb-4"></i>
                  <p class="text-gray-600">No posts to review</p>
                  <p class="text-sm text-gray-500 mt-1">All posts have been reviewed or no posts match your filters</p>
                </div>"""

    def _sort_header(self, label: str, col: str, extra_class: str = "") -> str:
        """Render a clickable sortable column header with direction indicator."""
        is_active = self.sort_by == col
        arrow_icon = ("fa-sort-up" if self.sort_dir == "asc" else "fa-sort-down") if is_active else "fa-sort"
        arrow_class = "text-indigo-600" if is_active else "text-gray-300 group-hover:text-indigo-400"
        base = "px-3 py-3 sm:px-6 sm:py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
        cls = f"{base} {extra_class}".strip()
        return (
            f'<th scope="col" class="{cls}">'
            f'<button phx-click="set_sort" phx-value-col="{col}" '
            f'class="flex items-center gap-1 group hover:text-indigo-600 transition-colors whitespace-nowrap">'
            f'{label} <i class="fas {arrow_icon} {arrow_class} transition-colors"></i>'
            f"</button></th>"
        )

    def _render_posts_table(self) -> str:
        """Render the posts table with content."""
        # Define header classes to break long lines
        header_base_class = (
            "px-3 py-3 sm:px-6 sm:py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
        )
        content_header = f'<th scope="col" class="{header_base_class}">Post</th>'
        score_header = self._sort_header("Score", "spam_score")
        date_header = self._sort_header("Date", "created_at", "hidden sm:table-cell")
        stream_header = f'<th scope="col" class="{header_base_class} hidden md:table-cell">Stream & Hashtags</th>'
        engagement_header = f'<th scope="col" class="{header_base_class} hidden lg:table-cell">Engagement</th>'
        actions_header = f'<th scope="col" class="{header_base_class}">Actions</th>'

        # Build table rows
        rows = ""
        for post in self.posts:
            rows += self._render_post_row(post)

        return f"""
                <!-- Posts Table -->
                <div class="bg-white shadow rounded-lg overflow-hidden">
                  <div>
                    <table class="w-full divide-y divide-gray-200">
                      <thead class="bg-gray-50">
                        <tr>
                          {content_header}
                          {score_header}
                          {date_header}
                          {stream_header}
                          {engagement_header}
                          {actions_header}
                        </tr>
                      </thead>
                      <tbody class="bg-white divide-y divide-gray-200">
                        {rows}
                      </tbody>
                    </table>
                  </div>
                </div>"""

    def _render_block_badges(self, post: dict) -> str:
        """Render warning badges when a post is affected by reblog controls."""
        reasons: list[str] = post.get("_blocked_reasons") or []
        if not reasons:
            return ""
        parts = []
        if "user" in reasons:
            parts.append(
                '<span class="inline-flex items-center px-1.5 py-0.5 text-xs'
                ' rounded bg-red-100 text-red-700 font-medium">'
                '<i class="fas fa-user-slash mr-1"></i>User blocked</span>'
            )
        if "hashtag" in reasons:
            parts.append(
                '<span class="inline-flex items-center px-1.5 py-0.5 text-xs'
                ' rounded bg-orange-100 text-orange-700 font-medium">'
                '<i class="fas fa-hashtag mr-1"></i>Tag blocked</span>'
            )
        return '<div class="flex flex-wrap gap-1 mt-1">' + "".join(parts) + "</div>"

    def _render_post_row(self, post: dict) -> str:
        """Render a single post row."""
        post_id = post["id"]
        score = post["spam_score"]
        score_class = self.get_score_class(score)

        # Sanitize content
        content_preview = sanitize_post_content(strip_html_tags(post["content"]), 150)

        # Media attachments
        attachments = post.get("media_attachments")
        media_html = self.render_media_attachments(attachments) if attachments and isinstance(attachments, list) else ""

        # Hashtag cells
        stream_hashtag = self.render_stream_hashtag(post)
        other_hashtags = self.render_other_hashtags(post)

        # Author info
        author_name = post.get("author_display_name") or post.get("author_username") or "Unknown"
        author_username = post.get("author_username", "")
        instance = post.get("instance", "")
        author_url = clean_url(post.get("author_url", ""))

        # Date/time
        created_at_raw = post.get("created_at")
        if created_at_raw:
            try:
                if isinstance(created_at_raw, str):
                    dt = datetime.fromisoformat(created_at_raw.replace("Z", "+00:00"))
                else:
                    dt = created_at_raw
                date_display = dt.strftime("%Y-%m-%d %H:%M")
            except Exception:
                date_display = str(created_at_raw)[:16]
        else:
            date_display = "—"

        # Engagement metrics
        replies = post.get("replies", 0)
        boosts = post.get("boosts", 0)
        likes = post.get("likes", 0)

        # Post URL
        post_url = clean_url(post.get("url", ""))

        # Blocking state
        blocked_reasons: list[str] = post.get("_blocked_reasons") or []
        is_blocked = bool(blocked_reasons)
        row_class = (
            "hover:bg-gray-50 transition-colors"
            if not is_blocked
            else "bg-amber-50 border-l-4 border-amber-400 hover:bg-amber-100 transition-colors"
        )
        block_badges = self._render_block_badges(post)

        # CSS classes
        score_span_class = (
            f"px-2 py-0.5 sm:px-3 sm:py-1 inline-flex text-xs leading-5 font-semibold rounded-full {score_class}"
        )
        stream_cell_class = (
            "px-3 py-3 sm:px-6 sm:py-4 text-sm text-gray-500 hidden md:table-cell stream-cell table-cell-content"
        )

        # Build author HTML using helpers
        author_avatar_html = self._render_author_avatar(author_name, author_url)
        author_name_html = self._render_author_name(author_name, author_url)
        author_username_html = self._render_author_username_with_block(post, author_username, instance, author_url)
        external_button = self._render_external_link_button(post_url)

        # Combined author line; date shown inline only below sm (sm+ has its own Date column)
        author_line = (
            f'<div class="flex items-center gap-2 mb-2">'
            f"{author_avatar_html}"
            f'<div class="min-w-0">'
            f"{author_name_html}"
            f"{author_username_html}"
            f"</div>"
            f'<span class="ml-auto text-xs text-gray-400 whitespace-nowrap sm:hidden">{date_display}</span>'
            f"</div>"
        )

        return f"""
                        <tr id="post-{post_id}" class="{row_class}">
                          <td class="px-3 py-3 sm:px-4 sm:py-4 w-full">
                            <div class="text-sm text-gray-900 break-words">
                              {author_line}
                              <div class="text-gray-600 mb-1">{content_preview}</div>
                              {media_html}
                              {block_badges}
                            </div>
                          </td>
                          <td class="px-2 py-3 sm:px-3 sm:py-4 score-cell whitespace-nowrap">
                            <span class="{score_span_class}">
                              {score}
                            </span>
                          </td>
                          <td class="px-2 py-3 sm:px-3 sm:py-4 text-xs text-gray-500
                               hidden sm:table-cell whitespace-nowrap date-cell">
                            {date_display}
                          </td>
                          <td class="{stream_cell_class}">
                            {stream_hashtag}
                            {other_hashtags}
                          </td>
                          <td class="px-2 py-3 sm:px-3 sm:py-4 hidden lg:table-cell engagement-cell">
                            <div class="engagement-metrics">
                              <div class="engagement-item engagement-replies" title="Replies">
                                <i class="fas fa-reply"></i>
                                <span class="engagement-count">{replies}</span>
                              </div>
                              <div class="engagement-item engagement-boosts" title="Boosts">
                                <i class="fas fa-retweet"></i>
                                <span class="engagement-count">{boosts}</span>
                              </div>
                              <div class="engagement-item engagement-likes" title="Likes">
                                <i class="fas fa-heart"></i>
                                <span class="engagement-count">{likes}</span>
                              </div>
                            </div>
                          </td>
                          <td class="px-2 py-3 sm:px-3 sm:py-4 text-sm font-medium actions-cell">
                            <div class="flex flex-col gap-1 items-start">
                              <button
                                phx-click="approve_post"
                                phx-value-post_id="{post_id}"
                                class="btn-approve p-1 sm:p-2 rounded-md text-sm font-medium
                                 transition-colors w-full sm:w-auto"
                                title="Approve"
                              >
                                <i class="fas fa-check"></i>
                              </button>
                              <button
                                phx-click="reject_post"
                                phx-value-post_id="{post_id}"
                                class="btn-reject p-1 sm:p-2 rounded-md text-sm font-medium
                                 transition-colors w-full sm:w-auto"
                                title="Reject"
                              >
                                <i class="fas fa-times"></i>
                              </button>
                {external_button}
                              <button
                                phx-click="open_score_modal"
                                phx-value-post_id="{post_id}"
                                class="btn-score p-1 sm:p-2 rounded-md text-sm font-medium
                                 transition-colors w-full sm:w-auto"
                                title="Adjust Score"
                              >
                                <i class="fas fa-edit"></i>
                              </button>
                            </div>
                          </td>
                        </tr>"""

    def _render_modal(self) -> str:
        """Render the score adjustment modal if open."""
        if not self.modal_open:
            return ""

        current_score = self.manual_score or 0
        notes = escape_html(self.reviewer_notes)

        save_button_class = (
            "w-full inline-flex justify-center rounded-md border border-transparent shadow-sm "
            "px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 "
            "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 "
            "sm:ml-3 sm:w-auto sm:text-sm"
        )
        cancel_button_class = (
            "mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm "
            "px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 "
            "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 "
            "sm:mt-0 sm:w-auto sm:text-sm"
        )
        textarea_class = (
            "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
        )

        return f"""
                <!-- Score Adjustment Modal -->
                <div class="fixed inset-0 overflow-y-auto" style="z-index: 1000;">
                  <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
                    <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>

                    <span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>

                    <div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4
                           text-left overflow-hidden shadow-xl transform transition-all
                           sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
                      <div>
                        <div class="mt-3 text-center sm:mt-0 sm:text-left">
                          <h3 class="text-lg leading-6 font-medium text-gray-900">Adjust Spam Score</h3>
                          <div class="mt-2">
                            <p class="text-sm text-gray-500">Set a manual spam score for this post (0-100)</p>
                          </div>
                        </div>
                      </div>

                      <div class="mt-5">
                        <div class="mb-4">
                          <label for="manualScore" class="block text-sm font-medium text-gray-700 mb-1">
                            Spam Score
                          </label>
                          <div class="flex items-center">
                            <input
                              type="range"
                              id="manualScore"
                              min="0"
                              max="100"
                              value="{current_score}"
                              phx-change="update_manual_score"
                              phx-value-field="value"
                              class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
                            />
                            <span class="ml-4 text-lg font-semibold w-12">{current_score}</span>
                          </div>
                          <div class="flex justify-between text-xs text-gray-500 mt-1">
                            <span>0 (Not Spam)</span>
                            <span>100 (Definitely Spam)</span>
                          </div>
                        </div>

                        <div class="mb-4">
                          <label for="reviewerNotes" class="block text-sm font-medium text-gray-700 mb-1">
                            Reviewer Notes (Optional)
                          </label>
                          <textarea
                            id="reviewerNotes"
                            rows="3"
                            phx-change="update_manual_score"
                            phx-value-field="notes"
                            class="{textarea_class}"
                            placeholder="Add any notes about this review decision..."
                          >{notes}</textarea>
                        </div>
                      </div>

                      <div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
                        <button
                          type="button"
                          phx-click="save_manual_score"
                          class="{save_button_class}"
                        >
                          Save Score
                        </button>
                        <button
                          type="button"
                          phx-click="close_modal"
                          class="{cancel_button_class}"
                        >
                          Cancel
                        </button>
                      </div>
                    </div>
                  </div>
                </div>"""

    async def render(self, assigns: Any, meta: PyViewMeta) -> RenderedContent:  # noqa: ARG002
        """Render the LiveView HTML."""
        # Start with the main HTML structure
        html = f"""
{generate_header("review")}

            <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
              <div class="px-4 py-6 sm:px-0">
                {self._render_header()}
                {self._render_stats_cards()}
                {self._render_filters()}

                {self._render_posts_section()}
              </div>
            </main>
        """

        # Add modal if needed
        html += self._render_modal()

        html += """
              </div>
            </main>
        """

        return StringRenderedContent(html)
