"""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 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.models import QueueStatus
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.services.post_storage import save_fetched_posts
from fenliu.services.review import create_review_feedback
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"
        # Pagination
        self.page: int = 1
        self.page_size: int = 20
        self.total_matching: int = 0
        self.socket: Any = None

    async def mount(self, socket, session) -> None:
        """Initialize the LiveView."""
        await super().mount(socket, session)
        logger.info("ReviewLiveView mounting")
        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 and page."""
        logger.info(f"Loading posts with filters: {self.filters}, page: {self.page}")
        try:
            with get_db() as db:
                base_conditions = [Post.reviewed.is_(False)]
                base_conditions.extend(self._build_score_conditions())

                count_stmt = select(func.count()).select_from(Post).where(*base_conditions)
                count_stmt = self._apply_stream_filter_to_stmt(count_stmt)
                self.total_matching = db.scalar(count_stmt) or 0

                # Clamp page to valid range
                total_pages = max(1, (self.total_matching + self.page_size - 1) // self.page_size)
                self.page = min(self.page, total_pages)

                stmt = select(Post).where(*base_conditions)
                stmt = self._apply_stream_filter_to_stmt(stmt)
                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(self.page_size)
                    .offset((self.page - 1) * self.page_size)
                )
                posts = db.execute(stmt).scalars().all()
                logger.info(f"Loaded {len(posts)} posts (page {self.page}/{total_pages})")

                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,
            "bulk_approve": self.handle_bulk_approve,
            "bulk_reject": self.handle_bulk_reject,
            "prev_page": self.handle_prev_page,
            "next_page": self.handle_next_page,
        }

        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.page = 1
        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.page = 1
        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:
                            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 = QueueStatus.PENDING
                    create_review_feedback(post, "approved", db)
                    db.commit()

            # Remove from current posts list
            self.posts = [p for p in self.posts if p["id"] != post_id]
            await self.load_stats()
            await self._reload_if_empty()
        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
                    create_review_feedback(post, "rejected", db)
                    db.commit()

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

    async def handle_bulk_approve(self, _payload: dict | None = None) -> None:
        """Approve all posts currently visible on the page."""
        post_ids = [p["id"] for p in self.posts]
        if not post_ids:
            return
        try:
            with get_db() as db:
                posts = db.execute(select(Post).where(Post.id.in_(post_ids))).scalars().all()
                now = datetime.now(UTC)
                for post in posts:
                    post.reviewed = True
                    post.approved = True
                    post.reviewed_at = now
                    post.queue_status = QueueStatus.PENDING
                    create_review_feedback(post, "approved", db)
                db.commit()
            self.posts = []
            await self.load_stats()
            await self._reload_if_empty()
        except Exception as e:
            logger.error(f"Error bulk approving posts: {e}")
            self.error = f"Failed to bulk approve: {e}"

    async def handle_bulk_reject(self, _payload: dict | None = None) -> None:
        """Reject all posts currently visible on the page."""
        post_ids = [p["id"] for p in self.posts]
        if not post_ids:
            return
        try:
            with get_db() as db:
                posts = db.execute(select(Post).where(Post.id.in_(post_ids))).scalars().all()
                now = datetime.now(UTC)
                for post in posts:
                    post.reviewed = True
                    post.approved = False
                    post.reviewed_at = now
                    if post.queue_status == QueueStatus.PENDING:
                        post.queue_status = None
                    create_review_feedback(post, "rejected", db)
                db.commit()
            self.posts = []
            await self.load_stats()
            await self._reload_if_empty()
        except Exception as e:
            logger.error(f"Error bulk rejecting posts: {e}")
            self.error = f"Failed to bulk reject: {e}"

    async def _reload_if_empty(self) -> None:
        """Reload the current page if empty but more unreviewed posts exist."""
        if not self.posts and self.stats["unreviewed_posts"] > 0:
            await self.load_posts()

    async def handle_prev_page(self, _payload: dict | None = None) -> None:
        """Navigate to the previous page."""
        if self.page > 1:
            self.page -= 1
            await self.load_posts()

    async def handle_next_page(self, _payload: dict | None = None) -> None:
        """Navigate to the next page."""
        total_pages = max(1, (self.total_matching + self.page_size - 1) // self.page_size)
        if self.page < total_pages:
            self.page += 1
            await self.load_posts()

    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)
                    create_review_feedback(
                        post, "score_adjusted", db, manual_score=self.manual_score, reviewer_notes=self.reviewer_notes
                    )
                    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()
            await self._reload_if_empty()
        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_table_footer(self) -> str:
        """Render pagination info + bulk action buttons as a single footer row."""
        count = len(self.posts)
        total_pages = max(1, (self.total_matching + self.page_size - 1) // self.page_size)

        if self.total_matching > self.page_size:
            b = "px-3 py-1.5 rounded border text-sm font-medium transition-colors"
            off_cls = f"{b} border-gray-200 text-gray-400 cursor-not-allowed"
            on_cls = f"{b} border-gray-300 text-gray-700 hover:bg-gray-50"
            prev_btn = (
                f'<button class="{off_cls}" disabled>← Prev</button>'
                if self.page <= 1
                else f'<button phx-click="prev_page" class="{on_cls}">← Prev</button>'
            )
            next_btn = (
                f'<button class="{off_cls}" disabled>Next →</button>'
                if self.page >= total_pages
                else f'<button phx-click="next_page" class="{on_cls}">Next →</button>'
            )
            info = (
                f'<span class="text-sm text-gray-500">'
                f"Page {self.page} of {total_pages}"
                f" &nbsp;·&nbsp; {self.total_matching} unreviewed</span>"
            )
            pagination = f'<div class="flex items-center gap-2">{info}{prev_btn}{next_btn}</div>'
        else:
            pagination = f'<span class="text-sm text-gray-500">{self.total_matching} unreviewed</span>'

        return f"""
                <div class="flex items-center justify-between mt-4">
                  {pagination}
                  <div class="flex gap-3">
                    <button phx-click="bulk_reject"
                      class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium">
                      <i class="fas fa-times mr-2"></i>Reject All ({count})
                    </button>
                    <button phx-click="bulk_approve"
                      class="px-4 py-2 bg-green-600 text-white rounded-lg
                        hover:bg-green-700 transition-colors font-medium">
                      <i class="fas fa-check-double mr-2"></i>Approve All ({count})
                    </button>
                  </div>
                </div>"""

    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() + self._render_table_footer()

    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 id="top" 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 class="mt-6 text-center">
                  <a href="#top" class="text-sm text-indigo-600 hover:text-indigo-900">↑ Back to top</a>
                </div>
              </div>
            </main>
        """

        html += self._render_modal()

        return StringRenderedContent(html)
