"""
Server-Sent Events (SSE) live reload for Bengal dev server.

Provides browser hot reload functionality via SSE, enabling automatic page
refresh when site content changes. Supports full page reload and CSS-only
hot reload (no page refresh for style changes).

Features:
- SSE endpoint (/__bengal_reload__) for push notifications
- JavaScript client auto-injected into HTML pages
- Full page reload on content/template changes
- CSS-only hot reload (no flicker, preserves scroll position)
- Scroll position preservation across reloads
- Automatic reconnection with exponential backoff
- Keepalive messages to prevent connection timeout
- Generation counter for reliable event delivery

Classes:
LiveReloadMixin: Mixin adding SSE handling to HTTP request handlers

Functions:
    notify_clients_reload: Trigger full page reload on all clients
    send_reload_payload: Send structured reload event with metadata
    set_reload_action: Set next reload type (reload, reload-css)
    inject_live_reload_into_response: Inject script into HTTP response bytes

Constants:
LIVE_RELOAD_SCRIPT: JavaScript client code injected into HTML

Architecture:
The live reload system uses a generation counter and condition variable
for efficient event distribution (no per-client queues):

BuildTrigger → notify_clients_reload() → increment generation
                                       → notify_all() on condition

SSE Handler → wait on condition with timeout (keepalive interval)
           → if generation changed: send event
           → else: send keepalive comment

Client Connection Lifecycle:
1. EventSource connects to /__bengal_reload__
2. Server sends retry interval and connected comment
3. Client waits for events, handles reload/reload-css
4. On disconnect: client reconnects with exponential backoff
5. On reconnect: last_seen_generation = current (no replay)

SSE Protocol:
Client → Server: GET /__bengal_reload__ (Accept: text/event-stream)
Server → Client: retry: 2000\n\n (reconnect delay)
Server → Client: : connected\n\n (comment, ignored by client)
Server → Client: data: reload\n\n (triggers location.reload())
Server → Client: data: {"action":"reload-css",...}\n\n (CSS hot reload)
Server → Client: : keepalive\n\n (comment, keeps connection alive)

Environment Variables:
BENGAL_SSE_KEEPALIVE_SECS: Keepalive interval in seconds (default: 15)
BENGAL_DISABLE_RELOAD_EVENTS: Suppress reload events (diagnostic)

Related:
- bengal/server/asgi_app.py: Uses LIVE_RELOAD_SCRIPT, run_sse_loop
- bengal/server/build_trigger.py: Calls notify_clients_reload
- bengal/server/reload_controller.py: Decides reload type

"""

from __future__ import annotations

import json
import os
import threading
from collections.abc import Callable
from io import BufferedIOBase
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, Protocol

from bengal.errors import ErrorCode
from bengal.utils.observability.logger import get_logger

if TYPE_CHECKING:
    pass


class HTTPHandlerProtocol(Protocol):
    """Protocol for HTTP request handler methods used by LiveReloadMixin."""

    path: str
    wfile: BufferedIOBase
    # Class-level cache attributes from LiveReloadMixin
    _html_cache: dict[tuple[str, float], bytes]
    _html_cache_max_size: int
    _html_cache_lock: threading.Lock

    def send_response(self, code: int, message: str | None = None) -> None: ...
    def send_header(self, keyword: str, value: str) -> None: ...
    def end_headers(self) -> None: ...
    def send_error(
        self, code: int, message: str | None = None, explain: str | None = None
    ) -> None: ...
    def translate_path(self, path: str) -> str: ...


logger = get_logger(__name__)

# Global reload generation and condition to wake clients
_reload_generation: int = 0
_last_action: str = "reload"
_reload_sent_count: int = 0
_reload_condition = threading.Condition()

# Shutdown flag to signal SSE handlers to exit
_shutdown_requested: bool = False


# Diagnostic: allow suppressing reload events via environment variable
def _reload_events_disabled() -> bool:
    try:
        val = (os.environ.get("BENGAL_DISABLE_RELOAD_EVENTS", "") or "").strip().lower()
        return val in ("1", "true", "yes", "on")
    except Exception:
        return False


def _get_keepalive_interval() -> float:
    """Get keepalive interval from env (default 15s, clamped 5-120)."""
    try:
        ka_env = os.environ.get("BENGAL_SSE_KEEPALIVE_SECS", "15").strip()
        return float(max(5, min(120, int(ka_env))))
    except Exception as e:
        logger.debug("keepalive_env_parse_failed", error=str(e))
        return 15.0


def run_sse_loop(
    write_fn: Callable[[bytes], None],
    *,
    keepalive_interval: float | None = None,
) -> tuple[int, int]:
    """
    Run the SSE event loop, yielding retry, connected, data events, and keepalives.

    Transport-agnostic: call write_fn with each chunk of bytes to send.
    The caller is responsible for headers; this sends the stream body only.

    Args:
        write_fn: Called with each SSE frame (bytes). Must handle write + flush.
        keepalive_interval: Seconds between keepalives (default from env, 5-120).

    Returns:
        Tuple of (messages_sent, keepalives_sent).

    Raises:
        BrokenPipeError, ConnectionResetError: Propagated from write_fn on disconnect.
    """
    interval = keepalive_interval if keepalive_interval is not None else _get_keepalive_interval()
    message_count = 0
    keepalive_count = 0

    # Send opening frames
    write_fn(b"retry: 2000\n\n")
    write_fn(b": connected\n\n")

    with _reload_condition:
        last_seen_generation = _reload_generation

    while True:
        with _reload_condition:
            if _shutdown_requested:
                break
            _reload_condition.wait(timeout=interval)
            if _shutdown_requested:
                break
            current_generation = _reload_generation

        if current_generation != last_seen_generation:
            write_fn(f"data: {_last_action}\n\n".encode())
            message_count += 1
            last_seen_generation = current_generation
        else:
            write_fn(b": keepalive\n\n")
            keepalive_count += 1

    return (message_count, keepalive_count)


# Live reload script to inject into HTML pages
LIVE_RELOAD_SCRIPT = r"""
<script>
(function() {
    // Bengal Live Reload
    let backoffMs = 1000;
    const maxBackoffMs = 10000;

    function connect() {
        const source = new EventSource('/__bengal_reload__');
        // Ensure the connection is closed on page unload/navigation to free server threads quickly
        const closeSource = () => { try { source.close(); } catch (e) {} };
        window.addEventListener('beforeunload', closeSource, { once: true });
        window.addEventListener('pagehide', closeSource, { once: true });

        source.onmessage = function(event) {
            let payload = null;
            try { payload = JSON.parse(event.data); } catch (e) {}

            const action = payload && payload.action ? payload.action : event.data;
            const changedPaths = (payload && payload.changedPaths) || [];
            const reason = (payload && payload.reason) || '';

            if (action === 'reload') {
                console.log('🔄 Bengal: Reloading page...');
                // Save scroll position before reload
                sessionStorage.setItem('bengal_scroll_x', window.scrollX.toString());
                sessionStorage.setItem('bengal_scroll_y', window.scrollY.toString());
                location.reload();
            } else if (action === 'reload-css') {
                console.log('🎨 Bengal: Reloading CSS...', reason || '', changedPaths);
                const links = document.querySelectorAll('link[rel="stylesheet"]');
                const now = Date.now();
                links.forEach(link => {
                    const href = link.getAttribute('href');
                    if (!href) return;
                    const url = new URL(href, window.location.origin);
                    // If targeted list provided, only reload those
                    if (changedPaths.length > 0) {
                        const path = url.pathname.replace(/^\//, '');
                        if (!changedPaths.includes(path)) return;
                    }
                    // Bust cache with a version param
                    url.searchParams.set('v', now.toString());
                    // Replace the link to trigger reload
                    const newLink = link.cloneNode();
                    newLink.href = url.toString();
                    newLink.onload = () => {
                        // Remove old link after new CSS loads
                        link.remove();
                    };
                    link.parentNode.insertBefore(newLink, link.nextSibling);
                });
            } else if (action === 'reload-page') {
                console.log('📄 Bengal: Reloading current page...');
                // Save scroll position before reload
                sessionStorage.setItem('bengal_scroll_x', window.scrollX.toString());
                sessionStorage.setItem('bengal_scroll_y', window.scrollY.toString());
                location.reload();
            }
        };

        // Restore scroll position after page load
        window.addEventListener('load', function() {
            const scrollX = sessionStorage.getItem('bengal_scroll_x');
            const scrollY = sessionStorage.getItem('bengal_scroll_y');
            if (scrollX !== null && scrollY !== null) {
                window.scrollTo(parseInt(scrollX, 10), parseInt(scrollY, 10));
                // Clear stored position after restoring
                sessionStorage.removeItem('bengal_scroll_x');
                sessionStorage.removeItem('bengal_scroll_y');
            }
        });

        source.onopen = function() {
            backoffMs = 1000; // reset on successful connection
            console.log('🚀 Bengal: Live reload connected');
        };

        source.onerror = function() {
            console.log('⚠️  Bengal: Live reload disconnected - retrying soon');
            try { source.close(); } catch (e) {}
            setTimeout(connect, backoffMs);
            backoffMs = Math.min(maxBackoffMs, Math.floor(backoffMs * 1.5));
        };
    }

    connect();
})();
</script>
"""


class LiveReloadMixin:
    """
    Mixin class providing SSE-based live reload for HTTP request handlers.

    Designed to be mixed into an HTTP request handler (before SimpleHTTPRequestHandler
    in MRO) to add live reload capabilities. Provides SSE endpoint handling and
    automatic script injection into HTML responses.

    Methods:
        handle_sse(): Handle the /__bengal_reload__ SSE endpoint
        serve_html_with_live_reload(): Serve HTML with injected reload script
        serve_asset_with_cache(): Serve CSS/JS with build-aware caching

    Type Declarations:
        The mixin declares types for attributes provided by SimpleHTTPRequestHandler
        (path, client_address, wfile) to help type checkers understand the interface.

    Important:
        Do NOT add stub methods for send_response, send_header, etc. Python MRO
        resolves this mixin BEFORE SimpleHTTPRequestHandler, so stubs would shadow
        the real implementations.

    Class Attributes:
        _html_cache: LRU cache for injected HTML responses
        _html_cache_max_size: Maximum number of pages to keep in cache
        _html_cache_lock: Thread lock protecting the cache
        _asset_cache: LRU cache for CSS/JS assets (serves during builds)
        _asset_cache_max_size: Maximum number of assets to keep in cache
        _asset_cache_lock: Thread lock protecting the asset cache

    Example:
            >>> class CustomHandler(LiveReloadMixin, SimpleHTTPRequestHandler):
            ...     def do_GET(self):
            ...         if self.path == '/__bengal_reload__':
            ...             self.handle_sse()
            ...         elif self.serve_html_with_live_reload():
            ...             return  # HTML served with script injected
            ...         else:
            ...             super().do_GET()  # Default file serving

    """

    # Type declarations for attributes provided by SimpleHTTPRequestHandler
    # These tell the type checker what to expect when this mixin is used
    path: str
    client_address: tuple[str, int]
    wfile: BufferedIOBase

    # Cache for injected HTML responses (avoids re-reading files on rapid navigation)
    # Key: (file_path_str, mtime), Value: modified_content bytes
    # Defined here in the mixin to avoid circular import with asgi_app
    _html_cache: ClassVar[dict[tuple[str, float], bytes]] = {}
    _html_cache_max_size = 50  # Keep last 50 pages in cache
    _html_cache_lock = threading.Lock()

    # Cache for CSS/JS assets - enables serving during builds when files are being rewritten
    # Key: relative_path (e.g., "assets/css/style.css"), Value: (content_bytes, content_type)
    # This cache persists across builds and is updated after each successful file serve
    _asset_cache: ClassVar[dict[str, tuple[bytes, str]]] = {}
    _asset_cache_max_size = 30  # Keep last 30 assets (CSS/JS files)
    _asset_cache_lock = threading.Lock()

    # NOTE: Do NOT add stub methods here for send_response, send_header, etc.!
    # Python MRO resolves this mixin BEFORE SimpleHTTPRequestHandler, so stubs
    # would shadow the real implementations. The type checker can find the methods
    # from SimpleHTTPRequestHandler in the concrete class's MRO.

    def handle_sse(self: HTTPHandlerProtocol) -> None:
        """
        Handle Server-Sent Events endpoint for live reload.

        Maintains a persistent HTTP connection and sends SSE messages:
        - Keepalive comments (: keepalive) every 30 seconds
        - Reload events (data: reload) when site is rebuilt

        The connection remains open until the client disconnects or an error occurs.

        Note:
            This method blocks until the client disconnects
        """
        client_addr = getattr(self, "client_address", ["unknown", 0])[0]
        logger.info("sse_client_connected", client_address=client_addr)

        message_count = 0
        keepalive_count = 0

        try:
            # Send SSE headers
            self.send_response(200)
            self.send_header("Content-Type", "text/event-stream")
            # Prevent intermediaries from buffering or closing the stream
            # no-transform avoids proxy transformations; private disables shared caches
            self.send_header(
                "Cache-Control",
                "no-store, no-cache, must-revalidate, max-age=0, private, no-transform",
            )
            self.send_header("Connection", "keep-alive")
            # Explicitly disable Nginx/Apache proxy buffering behaviors if present
            self.send_header("X-Accel-Buffering", "no")
            # Allow any origin during local development (dev server only)
            self.send_header("Access-Control-Allow-Origin", "*")
            self.end_headers()

            def write_sse(chunk: bytes) -> None:
                self.wfile.write(chunk)
                self.wfile.flush()

            logger.info(
                "sse_stream_started",
                keepalive_interval_secs=_get_keepalive_interval(),
            )

            # Run event loop; handle early disconnect during handshake or stream
            try:
                message_count, keepalive_count = run_sse_loop(write_sse)
            except (BrokenPipeError, ConnectionResetError) as exc:
                logger.debug(
                    "sse_client_disconnected_error",
                    error_code=ErrorCode.S004.name,
                    client_address=client_addr,
                    error_type=type(exc).__name__,
                    messages_sent=message_count,
                    keepalives_sent=keepalive_count,
                )
        finally:
            logger.info(
                "sse_client_disconnected",
                client_address=client_addr,
                messages_sent=message_count,
                keepalives_sent=keepalive_count,
            )

    def serve_html_with_live_reload(self: HTTPHandlerProtocol) -> bool:
        """
        Serve HTML file with live reload script injected (with caching).

        Uses file modification time caching to avoid re-reading/re-injecting
        unchanged files during rapid navigation.

        Returns:
            True if HTML was served (with or without injection), False if not HTML

        Note:
            Returns False for non-HTML files so the caller can handle them
        """
        # Resolve the actual file path
        path = self.translate_path(self.path)

        # Guard against translate_path returning None (can happen if self.directory is None)
        if path is None:
            return False

        # If path is a directory, look for index.html
        path_obj = Path(path)
        if path_obj.is_dir():
            for index in ["index.html", "index.htm"]:
                index_path = path_obj / index
                if index_path.exists():
                    path = str(index_path)
                    break

        # If not an HTML file at this point, return False to indicate we didn't handle it
        if not path.endswith(".html") and not path.endswith(".htm"):
            return False

        try:
            # Get file modification time for cache key
            mtime = Path(path).stat().st_mtime
            cache_key = (path, mtime)

            # Check cache (class attribute defined in this mixin)
            # Use type(self) to access class attributes through inheritance chain
            cls = type(self)

            # Fast path: try cache under lock
            with cls._html_cache_lock:
                cached = cls._html_cache.get(cache_key)
            if cached is not None:
                modified_content = cached
                logger.debug("html_cache_hit", path=path)
            else:
                # Cache miss - read and inject outside lock
                with open(path, "rb") as f:
                    content = f.read()

                # Inject script before </body> or </html> using shared utility
                from bengal.server.utils import find_html_injection_point

                script_bytes = LIVE_RELOAD_SCRIPT.encode("utf-8")
                injection_idx = find_html_injection_point(content)

                if injection_idx != -1:
                    modified_content = (
                        content[:injection_idx] + script_bytes + content[injection_idx:]
                    )
                else:
                    # No closing tag found - append at end
                    modified_content = content + script_bytes

                # Store in cache under lock (with size control)
                with cls._html_cache_lock:
                    # Double-check if another thread populated it while we were working
                    if cache_key not in cls._html_cache:
                        cls._html_cache[cache_key] = modified_content
                        # Limit cache size (simple FIFO eviction)
                        if len(cls._html_cache) > cls._html_cache_max_size:
                            first_key = next(iter(cls._html_cache))
                            del cls._html_cache[first_key]
                    cache_size = len(cls._html_cache)
                logger.debug("html_cache_miss", path=path, cache_size=cache_size)

            # Send response with injected script
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(modified_content)))
            # Strongly discourage caching injected HTML in dev
            self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
            self.send_header("Pragma", "no-cache")
            self.end_headers()
            self.wfile.write(modified_content)
            return True

        except FileNotFoundError, IsADirectoryError:
            self.send_error(404, "File not found")
            return True
        except Exception as e:
            # If anything goes wrong, log it and return False to fall back to default handling
            logger.warning(
                "live_reload_injection_failed",
                path=self.path,
                error=str(e),
                error_type=type(e).__name__,
            )
            return False

    def _inject_live_reload(self, response: bytes) -> bytes:
        """
        Inject live reload script into an HTTP response.

        This method is provided for test compatibility and wraps the
        module-level inject_live_reload_into_response function.

        Args:
            response: Complete HTTP response (headers + body)

        Returns:
            Modified response with live reload script injected
        """
        return inject_live_reload_into_response(response)

    def _is_cacheable_asset(self, url_path: str) -> bool:
        """
        Check if a URL path is a cacheable asset (CSS/JS).

        These assets are cached to enable serving during builds when
        files may be temporarily unavailable due to atomic rewrites.

        Args:
            url_path: URL path (e.g., "/assets/css/style.css")

        Returns:
            True if the path is a CSS or JS file in the assets directory.
        """
        path_lower = url_path.lower()
        # Only cache CSS/JS files in assets directory
        if "/assets/" not in path_lower:
            return False
        return path_lower.endswith((".css", ".js"))

    def _get_content_type(self, url_path: str) -> str:
        """Get content type for a file path."""
        from bengal.server.utils import get_content_type

        return get_content_type(url_path)

    def serve_asset_with_cache(self: HTTPHandlerProtocol, build_in_progress: bool) -> bool:
        """
        Serve CSS/JS assets with build-aware caching.

        This method provides resilient asset serving during builds:
        1. If file exists and readable → serve it and update cache
        2. If file unavailable during build → serve from cache
        3. If no cache available → return False (fall through to 404)

        The cache ensures CSS/JS remains available even during the brief
        window when atomic file writes are replacing the output files.

        Args:
            build_in_progress: Whether a build is currently active

        Returns:
            True if the asset was served (from file or cache).
            False if not a cacheable asset or no cache available.

        Thread Safety:
            Uses _asset_cache_lock for thread-safe cache access.
        """
        # Check if this is a cacheable asset
        if not self._is_cacheable_asset(self.path):
            return False

        # Normalize path for cache key (strip query params and leading slash)
        cache_key = self.path.split("?")[0].lstrip("/")
        content_type = self._get_content_type(self.path)

        # Resolve the actual file path
        file_path = self.translate_path(self.path)
        if file_path is None:
            return False

        file_path_obj = Path(file_path)
        cls = type(self)

        # Try to read the file
        content: bytes | None = None
        try:
            if file_path_obj.exists() and file_path_obj.is_file():
                content = file_path_obj.read_bytes()
        except (OSError, PermissionError) as e:
            # File might be mid-write or locked
            logger.debug(
                "asset_read_failed_during_build",
                path=self.path,
                error=str(e),
                build_in_progress=build_in_progress,
            )
            content = None

        if content is not None:
            # Successfully read file - serve it and update cache
            with cls._asset_cache_lock:
                cls._asset_cache[cache_key] = (content, content_type)
                # LRU eviction
                if len(cls._asset_cache) > cls._asset_cache_max_size:
                    first_key = next(iter(cls._asset_cache))
                    del cls._asset_cache[first_key]

            self.send_response(200)
            self.send_header("Content-Type", content_type)
            self.send_header("Content-Length", str(len(content)))
            self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
            self.end_headers()
            self.wfile.write(content)
            return True

        # File unavailable - try to serve from cache if build is in progress
        if build_in_progress:
            with cls._asset_cache_lock:
                cached = cls._asset_cache.get(cache_key)

            if cached is not None:
                cached_content, cached_type = cached
                logger.info(
                    "asset_served_from_cache_during_build",
                    path=self.path,
                    cache_key=cache_key,
                    size=len(cached_content),
                )
                self.send_response(200)
                self.send_header("Content-Type", cached_type)
                self.send_header("Content-Length", str(len(cached_content)))
                # Add header indicating this was served from cache
                self.send_header("X-Bengal-Cache", "hit-during-build")
                self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
                self.end_headers()
                self.wfile.write(cached_content)
                return True

            # No cache available during build - log warning
            logger.warning(
                "asset_unavailable_during_build_no_cache",
                path=self.path,
                cache_key=cache_key,
            )

        # Fall through to default handling (will likely 404)
        return False

    @classmethod
    def clear_asset_cache(cls) -> None:
        """
        Clear the asset cache.

        Called after builds complete to ensure fresh assets are served.
        The cache will be repopulated on the next request for each asset.
        """
        with cls._asset_cache_lock:
            cache_size = len(cls._asset_cache)
            cls._asset_cache.clear()
        if cache_size > 0:
            logger.debug("asset_cache_cleared", entries=cache_size)


def shutdown_sse_clients() -> None:
    """
    Signal all SSE handlers to exit gracefully.

    Sets the shutdown flag and wakes all handlers waiting on the condition
    variable. Each handler will check the flag and exit its loop cleanly.

    Thread Safety:
        Safe to call from any thread. Uses condition variable for synchronization.

    Note:
        Should be called BEFORE shutting down the HTTP server to ensure
        SSE connections close cleanly and don't block server shutdown.

    """
    global _shutdown_requested
    with _reload_condition:
        _shutdown_requested = True
        _reload_condition.notify_all()
    logger.info("sse_shutdown_requested")


def reset_sse_shutdown() -> None:
    """
    Reset the shutdown flag for a fresh server start.

    Called when starting a new server instance to clear any previous
    shutdown state. Without this, SSE handlers would exit immediately
    if a previous server instance requested shutdown.

    """
    global _shutdown_requested
    with _reload_condition:
        _shutdown_requested = False
    logger.debug("sse_shutdown_reset")


def notify_clients_reload() -> None:
    """
    Notify all connected SSE clients to trigger a full page reload.

    Increments the global generation counter and wakes all SSE handlers
    waiting on the condition variable. Each handler will send a reload
    event to its connected client.

    Thread Safety:
        Safe to call from any thread (e.g., build handler thread).
        Uses condition variable for synchronization.

    Note:
        Does nothing if BENGAL_DISABLE_RELOAD_EVENTS environment variable
        is set (useful for diagnostic purposes).

    """
    global _reload_generation
    if _reload_events_disabled():
        logger.info("reload_notification_suppressed", reason="env_BENGAL_DISABLE_RELOAD_EVENTS")
        return
    with _reload_condition:
        _reload_generation += 1
        _reload_condition.notify_all()
    logger.info("reload_notification_sent", generation=_reload_generation)


def send_reload_payload(action: str, reason: str, changed_paths: list[str]) -> None:
    """
    Send a structured JSON reload event to all connected SSE clients.

    Provides detailed reload information including the specific files that
    changed, enabling smarter client-side reload behavior (e.g., CSS-only
    reload targets specific stylesheets).

    Args:
        action: Reload type - 'reload' (full), 'reload-css' (stylesheets only),
                or 'reload-page' (explicit full reload)
        reason: Machine-readable reason (e.g., 'css-only', 'content-changed')
        changed_paths: Changed output paths relative to output directory.
                      For CSS reload, client uses these to target specific links.

    Example:
            >>> send_reload_payload(
            ...     action="reload-css",
            ...     reason="css-only",
            ...     changed_paths=["assets/style.css", "assets/print.css"]
            ... )

    """
    global _reload_generation, _last_action
    if _reload_events_disabled():
        logger.info(
            "reload_notification_suppressed",
            reason="env_BENGAL_DISABLE_RELOAD_EVENTS",
            action=action,
        )
        return
    try:
        payload = json.dumps(
            {
                "action": action,
                "reason": reason,
                "changedPaths": changed_paths,
                "generation": _reload_generation + 1,
            }
        )
    except Exception as e:
        logger.warning(
            "reload_payload_serialization_failed",
            error_code=ErrorCode.S003.name,
            action=action,
            reason=reason,
            error=str(e),
        )
        # Fallback to simple action string on serialization failure
        payload = action

    global _reload_sent_count
    with _reload_condition:
        _last_action = payload
        _reload_generation += 1
        _reload_sent_count += 1
        _reload_condition.notify_all()

    logger.info(
        "reload_notification_sent_structured",
        action=action,
        reason=reason,
        changed=min(len(changed_paths), 5),
        changed_paths=changed_paths[:5],
        generation=_reload_generation,
        sent_count=_reload_sent_count,
    )


def set_reload_action(action: str) -> None:
    """
    Set the next reload action type for SSE clients.

    Updates the global action that will be sent with the next reload event.
    Used by ReloadController to specify CSS-only vs full page reload.

    Thread-safe: Protected by _reload_condition for safe concurrent access.

    Args:
        action: One of:
            - 'reload': Full page reload (default)
            - 'reload-css': CSS hot-reload without page refresh
            - 'reload-page': Explicit full reload (alias of 'reload')
            Invalid values are silently replaced with 'reload'.

    """
    global _last_action
    if action not in ("reload", "reload-css", "reload-page"):
        action = "reload"
    with _reload_condition:
        _last_action = action
    logger.debug("reload_action_set", action=action)


def inject_live_reload_into_response(response: bytes) -> bytes:
    """
    Inject live reload script into an HTTP response body.

    Parses the HTTP response, locates </body> or </html> tag, and injects
    the LIVE_RELOAD_SCRIPT before it. Updates Content-Length header to
    reflect the new body size.

    Args:
        response: Complete HTTP response bytes (headers + body).
                 Must be formatted as: headers\r\n\r\nbody

    Returns:
        Modified response with script injected and Content-Length updated.
        Returns original response if injection fails or response is malformed.

    Note:
        This is a fallback method. The preferred approach is using
        LiveReloadMixin.serve_html_with_live_reload() which operates
        on file contents before HTTP response construction.

    """
    try:
        # HTTP response format: headers\r\n\r\nbody
        if b"\r\n\r\n" not in response:
            return response

        headers_end = response.index(b"\r\n\r\n")
        headers = response[:headers_end]
        body = response[headers_end + 4 :]

        # Inject script before </body> or </html> using shared utility
        from bengal.server.utils import find_html_injection_point

        script_bytes = LIVE_RELOAD_SCRIPT.encode("utf-8")
        injection_idx = find_html_injection_point(body)

        if injection_idx != -1:
            modified_body = body[:injection_idx] + script_bytes + body[injection_idx:]
        else:
            # No closing tag found - append at end
            modified_body = body + script_bytes

        # Update Content-Length header if present
        headers_str = headers.decode("latin-1")
        header_lines = headers_str.split("\r\n")
        new_header_lines = []
        content_length_updated = False

        for line in header_lines:
            if line.lower().startswith("content-length:"):
                new_header_lines.append(f"Content-Length: {len(modified_body)}")
                content_length_updated = True
            else:
                new_header_lines.append(line)

        # If Content-Length wasn't present, add it
        if not content_length_updated:
            new_header_lines.append(f"Content-Length: {len(modified_body)}")

        new_headers = "\r\n".join(new_header_lines).encode("latin-1")
        return new_headers + b"\r\n\r\n" + modified_body

    except Exception as e:
        logger.warning(
            "live_reload_response_injection_failed",
            error=str(e),
            error_type=type(e).__name__,
        )
        return response
