import logging
import datetime
import email.utils
import mimetypes
import os
import re
from typing import Optional
from urllib.parse import urljoin, urlparse

from feedgen.feed import FeedGenerator
from flask import (
    Request,
    jsonify,
    request,
    Response,
    send_from_directory as send_from_directory_,
)

from .app import app
from .cache import (
    check_cache_validity,
    generate_etag,
    get_dir_mtime,
    get_guestbook_mtime,
    get_max_mtime,
    make_304_response,
)
from .config import config
from .feeds import FeedAuthor
from .templates import TemplateUtils
from .reactions import build_thread_tree, count_reactions
from ._sorters import PagesSortByTimeGroupedByFolder

logger = logging.getLogger(__name__)
_author_with_email_regex = re.compile(
    r"(.*)\s+<([\w\.\-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)>"
)
_author_with_url_regex = re.compile(r"(.*)\s+<(https?:\/\/[\w\.\-]+\.[a-z]{2,6}\/?)>")


def send_from_directory(
    path: str, file: str, alternative_path: Optional[str] = None, **kwargs
):
    """
    Like `flask.send_from_directory`, but with an optional `alternative_path`.
    """

    if not os.path.exists(os.path.join(path, file)) and alternative_path:
        path = alternative_path
    return send_from_directory_(path, file, **kwargs)


def _get_followers_count() -> int:
    """Get the number of ActivityPub followers, or 0 if AP is disabled."""
    if not config.enable_activitypub:
        return 0
    if not hasattr(app, "activitypub_storage"):
        return 0
    try:
        return len(app.activitypub_storage.get_followers())
    except Exception:
        return 0


@app.route("/", methods=["GET"])
def home_route():
    view_mode = request.args.get("view", config.view_mode)
    if view_mode not in ("cards", "list", "full"):
        view_mode = config.view_mode

    return app.get_pages_response(
        folder="",
        recursive=False,
        sorter=PagesSortByTimeGroupedByFolder,
        with_content=(view_mode == "full"),
        skip_header=True,
        skip_html_head=True,
        template_name="index.html",
        view_mode=view_mode,
        followers_count=_get_followers_count(),
    )


@app.route("/~<path:folder>/", methods=["GET"])
@app.route("/~<path:folder>", methods=["GET"])
def folder_index_route(folder: str):
    """
    Render a folder index page.

    If the folder contains an index.md with content, it is rendered as a page.
    Otherwise, a listing of subfolders and articles is shown.
    """
    view_mode = request.args.get("view", config.view_mode)
    if view_mode not in ("cards", "list", "full"):
        view_mode = config.view_mode

    return app.get_folder_index(
        folder,
        view_mode=view_mode,
        followers_count=_get_followers_count(),
    )


@app.route("/+<path:feed_url>/", methods=["GET"])
@app.route("/+<path:feed_url>", methods=["GET"])
def external_feed_index_route(feed_url: str):
    """
    Render an external feed as an index page.

    Shows all entries from the specified external feed URL.
    """
    logger.debug("External feed route called with feed_url=%s", feed_url)
    view_mode = request.args.get("view", config.view_mode)
    if view_mode not in ("cards", "list", "full"):
        view_mode = config.view_mode

    return app.get_external_feed_index(
        feed_url,
        view_mode=view_mode,
        followers_count=_get_followers_count(),
    )


@app.route("/@<username>", methods=["GET"])
def profile_route(username: str):
    """
    Serve the home page at /@username (the ActivityPub profile URL).

    Instead of redirecting to /, the actual page content is served so that
    Mastodon can find rel="me" links for profile verification. A JavaScript
    redirect is injected to send human users to the canonical home page.
    """
    if config.enable_activitypub and username == config.activitypub_username:
        view_mode = request.args.get("view", config.view_mode)
        if view_mode not in ("cards", "list", "full"):
            view_mode = config.view_mode

        return app.get_pages_response(
            sorter=PagesSortByTimeGroupedByFolder,
            with_content=(view_mode == "full"),
            skip_header=True,
            skip_html_head=True,
            template_name="index.html",
            view_mode=view_mode,
            followers_count=_get_followers_count(),
            meta_redirect_to="/",
        )
    return Response("Not found", status=404, mimetype="text/plain")


@app.route("/img/<img>", methods=["GET"])
def img_route(img: str):
    return send_from_directory(app.img_dir, img, config.default_img_dir)


@app.route("/favicon.ico", methods=["GET"])
def favicon_route():
    return img_route("favicon.ico")


@app.route("/js/<file>", methods=["GET"])
def js_route(file: str):
    return send_from_directory(app.js_dir, file, config.default_js_dir)


@app.route("/pwabuilder-sw.js", methods=["GET"])
def pwa_builder_route():
    return send_from_directory(app.js_dir, "pwabuilder-sw.js", config.default_js_dir)


@app.route("/pwabuilder-sw-register.js", methods=["GET"])
def pwa_builder_register_route():
    return send_from_directory(
        app.js_dir, "pwabuilder-sw-register.js", config.default_js_dir
    )


@app.route("/css/<style>", methods=["GET"])
def css_route(style: str):
    return send_from_directory(app.css_dir, style, config.default_css_dir)


@app.route("/fonts/<file>", methods=["GET"])
def fonts_route(file: str):
    return send_from_directory(app.fonts_dir, file, config.default_fonts_dir)


@app.route("/manifest.json", methods=["GET"])
def manifest_route():
    # If there is a manifest.json in the content directory, use it
    manifest_file = os.path.join(config.content_dir, "manifest.json")
    if os.path.isfile(manifest_file):
        return send_from_directory(config.content_dir, "manifest.json")

    # Otherwise, generate a default manifest.json
    return jsonify(
        {
            "name": config.title,
            "short_name": config.title,
            "icons": [
                {"src": "/img/icon-48.png", "sizes": "48x48", "type": "image/png"},
                {"src": "/img/icon-72.png", "sizes": "72x72", "type": "image/png"},
                {"src": "/img/icon-96.png", "sizes": "96x96", "type": "image/png"},
                {"src": "/img/icon-144.png", "sizes": "144x144", "type": "image/png"},
                {"src": "/img/icon-168.png", "sizes": "168x168", "type": "image/png"},
                {"src": "/img/icon-192.png", "sizes": "192x192", "type": "image/png"},
                {"src": "/img/icon-256.png", "sizes": "256x256", "type": "image/png"},
                {"src": "/img/icon-512.png", "sizes": "512x512", "type": "image/png"},
            ],
            "gcm_sender_id": "",
            "gcm_user_visible_only": True,
            "start_url": "/",
            "permissions": ["gcm"],
            "scope": "",
            "orientation": "portrait",
            "display": "standalone",
            "theme_color": "#000000",
            "background_color": "#ffffff",
        }
    )


@app.route("/article/<path:path>/<article>", methods=["GET"])
def article_with_path_route(path: str, article: str):
    return app.get_page(os.path.join(path, article))


@app.route("/article/<path:path>/<article>.md", methods=["GET"])
def raw_article_with_path_route(path: str, article: str):
    return app.get_page(os.path.join(path, article), as_markdown=True)


@app.route("/article/<article>", methods=["GET"])
def article_route(article: str):
    return article_with_path_route("", article)


@app.route("/article/<article>.md", methods=["GET"])
def raw_article_route(article: str):
    return raw_article_with_path_route("", article)


@app.route("/reply/<slug>", methods=["GET"])
def toplevel_reply_route(slug: str):
    return app.get_reply(None, slug)


@app.route("/reply/<slug>.md", methods=["GET"])
def raw_toplevel_reply_route(slug: str):
    return app.get_reply(None, slug, as_markdown=True)


@app.route("/reply/<path:article_slug>/<reply_slug>", methods=["GET"])
def reply_route(article_slug: str, reply_slug: str):
    return app.get_reply(article_slug, reply_slug)


@app.route("/reply/<path:article_slug>/<reply_slug>.md", methods=["GET"])
def raw_reply_route(article_slug: str, reply_slug: str):
    return app.get_reply(article_slug, reply_slug, as_markdown=True)


def _get_absolute_url(url: str) -> str:
    if not url:
        return ""

    if re.search(r"^https?://", url):
        return url

    return urljoin(config.link or request.host, url)


def _to_feed_datetime(dt: object) -> Optional[datetime.datetime]:
    if not dt:
        return None

    if isinstance(dt, datetime.datetime):
        return (
            dt.replace(tzinfo=datetime.timezone.utc)
            if dt.tzinfo is None
            else dt.astimezone(datetime.timezone.utc)
        )

    if isinstance(dt, datetime.date):
        return datetime.datetime(
            dt.year, dt.month, dt.day, tzinfo=datetime.timezone.utc
        )

    return None


def _to_feed_text(obj: object) -> str:
    if obj is None:
        return ""

    if isinstance(obj, Response):
        return obj.get_data(as_text=True)

    return str(obj)


def _parse_author_info(author: str | FeedAuthor | None, author_url: str | None) -> dict:
    ret = {}
    if isinstance(author, FeedAuthor):
        return {
            "name": author.name,
            "email": author.email,
            "uri": author.uri,
        }

    if author_url:
        if author_url.startswith("mailto:"):
            ret["email"] = author_url[len("mailto:") :]
        else:
            ret["uri"] = author_url

    if author:
        if author.startswith("mailto:"):
            ret["email"] = author[len("mailto:") :]
        elif m := _author_with_email_regex.match(author):
            ret["name"] = m[1]
            ret["email"] = m[2]
        elif m := _author_with_url_regex.match(author):
            ret["name"] = m[1]
            ret["uri"] = m[2]
        else:
            ret["name"] = author

    return ret


def _get_feed(request: Request, feed_type: Optional[str] = None):
    if not feed_type:
        feed_type = request.args.get("type", "rss")

    feed_type = feed_type.lower().strip()
    if feed_type not in {"rss", "atom"}:
        return Response("Invalid feed type", status=400, mimetype="text/plain")

    limit = request.args.get("limit") or config.max_entries_per_feed
    if limit:
        limit = int(limit)

    short_description = "short" in request.args or config.short_feed

    # Get pages data first (includes file_mtime for cache headers)
    pages = app.get_pages(
        with_content=not short_description,
        skip_header=True,
        skip_html_head=True,
    )

    pages = pages[:limit]

    most_recent_mtime = 0.0
    for _, page_data in pages:
        # Only consider local files (those with file_mtime), not external feeds
        if "file_mtime" in page_data:
            most_recent_mtime = max(most_recent_mtime, page_data["file_mtime"])

    # Format the most recent modification time for HTTP headers
    last_modified = (
        email.utils.formatdate(most_recent_mtime, usegmt=True)
        if most_recent_mtime > 0
        else None
    )

    # Generate ETag based on most recent modification time
    etag = generate_etag(most_recent_mtime) if most_recent_mtime > 0 else None

    # Check if the client has a cached version that's still valid
    # Check both If-Modified-Since and If-None-Match headers
    cache_valid = False

    if last_modified and most_recent_mtime > 0:
        if_modified_since = request.headers.get("If-Modified-Since")
        if_none_match = request.headers.get("If-None-Match")

        # Check If-Modified-Since
        if if_modified_since and not cache_valid:
            try:
                cached_timestamp = email.utils.mktime_tz(
                    email.utils.parsedate_tz(if_modified_since)  # type: ignore
                )
                if (
                    cached_timestamp is not None
                    and cached_timestamp >= most_recent_mtime
                ):
                    cache_valid = True
            except (ValueError, TypeError, OverflowError):
                # Invalid If-Modified-Since header, ignore it
                pass

        # Check If-None-Match (ETag)
        if if_none_match and etag and not cache_valid:
            client_etags = [tag.strip() for tag in if_none_match.split(",")]
            if etag in client_etags or "*" in client_etags:
                cache_valid = True

        # Return 304 if cache is valid
        if cache_valid:
            from flask import make_response

            response = make_response("", 304)
            response.headers["Last-Modified"] = last_modified
            if etag:
                response.headers["ETag"] = etag

            # Set Language header for 304 responses too
            if config.language:
                response.headers["Language"] = config.language

            return response

    fg = FeedGenerator()
    fg.id(config.link)
    fg.title(config.title)
    fg.link(href=config.link, rel="alternate")
    fg.description(config.description)
    fg.language(config.language)

    fg.author(
        **_parse_author_info(author=config.author, author_url=config.author_url),
    )

    for category in config.categories:
        fg.category(term=category)

    icon_url = _get_absolute_url(config.logo or "/img/icon.png")
    if icon_url:
        fg.logo(icon_url)

    self_url = _get_absolute_url(f"/feed.{feed_type}")
    fg.link(href=self_url, rel="self")

    if pages:
        updated = _to_feed_datetime(pages[0][1].get("published"))
        if updated:
            fg.updated(updated)

    for _, page in pages:
        uri = page.get("uri", "")
        entry_url = _get_absolute_url(uri)

        fe = fg.add_entry()
        if entry_url:
            fe.id(entry_url)
            fe.link(href=entry_url, rel="alternate")

        fe.title(_to_feed_text(page.get("title", "[No Title]")))

        published = _to_feed_datetime(page.get("published"))
        if published:
            fe.published(published)
            fe.updated(published)

        if page.get("description"):
            fe.summary(_to_feed_text(page.get("description", "")))

        if not short_description:
            fe.content(_to_feed_text(page.get("content", "")), type="html")

        fe.author(
            **_parse_author_info(
                author=page.get("author"), author_url=page.get("author_url")
            ),
        )

        image_url = _get_absolute_url(page.get("image", ""))
        if image_url:
            mime_type, _ = mimetypes.guess_type(image_url)
            if mime_type:
                fe.enclosure(image_url, 0, mime_type)

    # Create response with appropriate content type
    response = (
        Response(fg.atom_str(pretty=True), mimetype="application/atom+xml")
        if feed_type == "atom"
        else Response(fg.rss_str(pretty=True), mimetype="application/rss+xml")
    )

    # Add cache headers
    if last_modified:
        response.headers["Last-Modified"] = last_modified
        response.headers["Cache-Control"] = "public, max-age=0, must-revalidate"

    if etag:
        response.headers["ETag"] = etag

    # Set Language header from global config for feeds
    if config.language:
        response.headers["Language"] = config.language

    return response


@app.route("/feed.<type>", methods=["GET"])
def feed_route(type: str):
    return _get_feed(request, type)


def _get_folder_feed(request: Request, folder: str, feed_type: Optional[str] = None):
    """Generate an RSS/Atom feed for a specific folder (non-recursive)."""
    from flask import abort

    if not feed_type:
        feed_type = request.args.get("type", "rss")

    feed_type = feed_type.lower().strip()
    if feed_type not in {"rss", "atom"}:
        return Response("Invalid feed type", status=400, mimetype="text/plain")

    folder = folder.strip("/")
    folder_dir = app.pages_dir / folder
    if not folder_dir.is_dir():
        abort(404)

    limit = request.args.get("limit") or config.max_entries_per_feed
    if limit:
        limit = int(limit)

    short_description = "short" in request.args or config.short_feed

    pages = app.get_pages(
        folder=folder,
        recursive=False,
        with_content=not short_description,
        skip_header=True,
        skip_html_head=True,
        include_external_feeds=False,
    )

    pages = pages[:limit]

    most_recent_mtime = 0.0
    for _, page_data in pages:
        if "file_mtime" in page_data:
            most_recent_mtime = max(most_recent_mtime, page_data["file_mtime"])

    last_modified = (
        email.utils.formatdate(most_recent_mtime, usegmt=True)
        if most_recent_mtime > 0
        else None
    )
    etag = generate_etag(most_recent_mtime) if most_recent_mtime > 0 else None

    if last_modified and most_recent_mtime > 0:
        if check_cache_validity(most_recent_mtime, etag):
            return make_304_response(last_modified, etag, {})

    folder_meta = app._parse_folder_metadata(folder)
    folder_title = folder_meta.get("title") or folder

    fg = FeedGenerator()
    folder_url = f"{config.link}/~{folder}/"
    fg.id(folder_url)
    fg.title(f"{config.title} - {folder_title}")
    fg.link(href=folder_url, rel="alternate")
    fg.description(folder_meta.get("description") or f"Articles in {folder_title}")
    fg.language(config.language)

    fg.author(
        **_parse_author_info(author=config.author, author_url=config.author_url),
    )

    self_url = _get_absolute_url(f"/~{folder}/feed.{feed_type}")
    fg.link(href=self_url, rel="self")

    if pages:
        updated = _to_feed_datetime(pages[0][1].get("published"))
        if updated:
            fg.updated(updated)

    for _, page in pages:
        uri = page.get("uri", "")
        entry_url = _get_absolute_url(uri)

        fe = fg.add_entry()
        if entry_url:
            fe.id(entry_url)
            fe.link(href=entry_url, rel="alternate")

        fe.title(_to_feed_text(page.get("title", "[No Title]")))

        published = _to_feed_datetime(page.get("published"))
        if published:
            fe.published(published)
            fe.updated(published)

        if page.get("description"):
            fe.summary(_to_feed_text(page.get("description", "")))

        if not short_description:
            fe.content(_to_feed_text(page.get("content", "")), type="html")

        fe.author(
            **_parse_author_info(
                author=page.get("author"), author_url=page.get("author_url")
            ),
        )

        image_url = _get_absolute_url(page.get("image", ""))
        if image_url:
            mime_type, _ = mimetypes.guess_type(image_url)
            if mime_type:
                fe.enclosure(image_url, 0, mime_type)

    response = (
        Response(fg.atom_str(pretty=True), mimetype="application/atom+xml")
        if feed_type == "atom"
        else Response(fg.rss_str(pretty=True), mimetype="application/rss+xml")
    )

    if last_modified:
        response.headers["Last-Modified"] = last_modified
        response.headers["Cache-Control"] = "public, max-age=0, must-revalidate"

    if etag:
        response.headers["ETag"] = etag

    if config.language:
        response.headers["Language"] = config.language

    return response


@app.route("/~<path:folder>/feed.<type>", methods=["GET"])
def folder_feed_route(folder: str, type: str):
    """Generate an RSS/Atom feed for a specific folder."""
    return _get_folder_feed(request, folder, type)


@app.route("/rss", methods=["GET"])
def rss_route():
    """
    This route exists only for backward compatibility.

    It redirects to the /feed route with the appropriate query parameter to generate an RSS feed.
    """
    return _get_feed(request, "rss")


@app.route("/tags", methods=["GET"])
def tags_route():
    from flask import make_response, render_template

    # Use the tag index file mtime for caching
    # The tag index is updated whenever posts change
    tag_index_path = config.resolved_state_dir / "cache" / "tags-index.json"
    mtime = get_max_mtime(tag_index_path, app.pages_dir)

    # Check cache validity
    last_modified = None
    etag = None

    if mtime > 0:
        last_modified = email.utils.formatdate(mtime, usegmt=True)
        etag = generate_etag(mtime)
        if check_cache_validity(mtime, etag):
            return make_304_response(last_modified, etag, {})

    tags = app.tag_index.get_all_tags()
    response = make_response(render_template("tags.html", tags=tags, config=config))

    # Set cache headers
    if mtime > 0:
        response.headers["Last-Modified"] = last_modified
        response.headers["ETag"] = etag
        response.headers["Cache-Control"] = "public, max-age=0, must-revalidate"
    else:
        response.headers["Cache-Control"] = "no-store"

    if config.language:
        response.headers["Language"] = config.language

    return response


@app.route("/tags/<tag>", methods=["GET"])
def tag_posts_route(tag: str):
    from flask import make_response, render_template

    from .tags import normalize_tag as _normalize_tag

    # Use the tag index file mtime for caching
    tag_index_path = config.resolved_state_dir / "cache" / "tags-index.json"
    mtime = get_max_mtime(tag_index_path, app.pages_dir)

    # Check cache validity
    last_modified = None
    etag = None
    if mtime > 0:
        last_modified = email.utils.formatdate(mtime, usegmt=True)
        etag = generate_etag(mtime)
        if check_cache_validity(mtime, etag):
            return make_304_response(last_modified, etag, {})

    canonical = _normalize_tag(tag)
    posts = app.tag_index.get_posts_for_tag(canonical)
    response = make_response(
        render_template(
            "tag_posts.html",
            tag=canonical,
            posts=posts,
            config=config,
        )
    )

    # Set cache headers
    if mtime > 0:
        response.headers["Last-Modified"] = last_modified
        response.headers["ETag"] = etag
        response.headers["Cache-Control"] = "public, max-age=0, must-revalidate"
    else:
        response.headers["Cache-Control"] = "no-store"

    if config.language:
        response.headers["Language"] = config.language

    return response


# ---------------------------------------------------------------------------
# Mastodon-compatible API — Madblog-specific endpoints
# ---------------------------------------------------------------------------


@app.route("/api/v1/tags/<tag>", methods=["GET"])
def mastodon_tag_route(tag: str):
    """Return a Mastodon Tag entity with usage history from the TagIndex."""
    from .tags import normalize_tag as _normalize_tag

    canonical = _normalize_tag(tag)
    posts = app.tag_index.get_posts_for_tag(canonical)

    if not posts:
        return jsonify({"error": "Record not found"}), 404

    # Build per-day usage history (last 7 days)
    from collections import Counter

    today = datetime.date.today()
    day_counts: Counter = Counter()
    for post in posts:
        pub = post.get("published", "")
        if not pub:
            continue
        try:
            pub_date = datetime.date.fromisoformat(str(pub)[:10])
        except (ValueError, TypeError):
            continue
        day_counts[pub_date] += 1

    history = []
    for i in range(7):
        day = today - datetime.timedelta(days=i)
        count = day_counts.get(day, 0)
        history.append(
            {
                "day": str(
                    int(
                        datetime.datetime.combine(
                            day, datetime.time.min, tzinfo=datetime.timezone.utc
                        ).timestamp()
                    )
                ),
                "uses": str(count),
                "accounts": str(min(count, 1)),
            }
        )

    base_url = (config.link or "").rstrip("/")
    return jsonify(
        {
            "name": canonical,
            "url": f"{base_url}/tags/{canonical}",
            "history": history,
            "following": False,
        }
    )


@app.route("/api/v2/search", methods=["GET"])
def mastodon_search_route():
    """Mastodon-compatible search endpoint (read-only, local data only)."""
    q = (request.args.get("q") or "").strip()
    search_type = request.args.get("type", "")
    limit = min(int(request.args.get("limit", 20)), 40)

    if not q:
        return jsonify({"accounts": [], "statuses": [], "hashtags": []})

    accounts = []
    statuses = []
    hashtags = []

    q_lower = q.lower()

    # -- accounts --
    if not search_type or search_type == "accounts":
        if config.enable_activitypub and hasattr(app, "activitypub_handler"):
            from pubby.server.mastodon import actor_to_account

            username = config.activitypub_username.lower()
            name = (
                config.activitypub_name or config.author or config.title or ""
            ).lower()
            if q_lower in username or q_lower in name:
                accounts.append(actor_to_account(app.activitypub_handler))

    # -- hashtags --
    if not search_type or search_type == "hashtags":
        from .tags import normalize_tag as _normalize_tag

        all_tags = app.tag_index.get_all_tags()
        prefix = _normalize_tag(q)
        for tag_name, _ in all_tags:
            if tag_name.startswith(prefix):
                base_url = (config.link or "").rstrip("/")
                hashtags.append(
                    {
                        "name": tag_name,
                        "url": f"{base_url}/tags/{tag_name}",
                        "history": [],
                    }
                )
            if len(hashtags) >= limit:
                break

    # -- statuses --
    if not search_type or search_type == "statuses":
        if config.enable_activitypub and hasattr(app, "activitypub_handler"):
            from pubby.server.mastodon import activity_to_status, actor_to_account

            handler = app.activitypub_handler
            account = actor_to_account(handler)
            activities = handler.storage.get_activities(limit=10000, offset=0)
            for act in activities:
                obj = act.get("object", {})
                if isinstance(obj, dict):
                    content = (obj.get("content") or "").lower()
                    name = (obj.get("name") or "").lower()
                    if q_lower in content or q_lower in name:
                        statuses.append(
                            activity_to_status(act, handler, account=account)
                        )
                if len(statuses) >= limit:
                    break

    return jsonify(
        {
            "accounts": accounts[:limit],
            "statuses": statuses[:limit],
            "hashtags": hashtags[:limit],
        }
    )


@app.route("/guestbook", methods=["GET"])
def guestbook_route():
    from flask import make_response, render_template

    if not config.enable_guestbook:
        return Response("Guestbook is not enabled", status=404, mimetype="text/plain")

    # Compute mtime from interaction directories
    mentions_dir = str(app.mentions_dir) if hasattr(app, "mentions_dir") else None
    ap_interactions_dir = None
    if hasattr(app, "activitypub_storage"):
        ap_interactions_dir = str(
            config.resolved_state_dir / "activitypub" / "state" / "interactions"
        )
    replies_dir = str(app.replies_dir) if hasattr(app, "replies_dir") else None

    mtime = get_guestbook_mtime(
        mentions_dir=mentions_dir,
        ap_interactions_dir=ap_interactions_dir,
        replies_dir=replies_dir,
    )

    # Check cache validity
    last_modified = None
    etag = None
    if mtime > 0:
        last_modified = email.utils.formatdate(mtime, usegmt=True)
        etag = generate_etag(mtime)
        if check_cache_validity(mtime, etag):
            return make_304_response(last_modified, etag, {})

    webmentions = app.get_guestbook_webmentions() if config.enable_webmentions else []
    ap_interactions = (
        app.get_guestbook_ap_interactions() if config.enable_activitypub else []
    )

    guestbook_url = config.link.rstrip("/")
    reactions_tree = build_thread_tree(
        webmentions=webmentions,
        ap_interactions=ap_interactions,
        author_replies=[],
        article_url=guestbook_url,
    )
    reactions_counts = count_reactions(reactions_tree)

    response = make_response(
        render_template(
            "guestbook.html",
            config=config,
            reactions_tree=reactions_tree,
            reactions_counts=reactions_counts,
            utils=TemplateUtils(),
        )
    )

    # Set cache headers
    if mtime > 0:
        response.headers["Last-Modified"] = last_modified
        response.headers["ETag"] = etag
        response.headers["Cache-Control"] = "public, max-age=0, must-revalidate"
    else:
        response.headers["Cache-Control"] = "no-store"

    if config.language:
        response.headers["Language"] = config.language

    return response


def _get_guestbook_feed(request: Request, feed_type: Optional[str] = None):
    """
    Generate an RSS/Atom feed for guestbook entries.
    """
    if not config.enable_guestbook:
        return Response("Guestbook is not enabled", status=404, mimetype="text/plain")

    if not feed_type:
        feed_type = request.args.get("type", "atom")

    feed_type = feed_type.lower().strip()
    if feed_type not in {"rss", "atom"}:
        return Response("Invalid feed type", status=400, mimetype="text/plain")

    limit = int(request.args.get("limit", 25))
    offset = int(request.args.get("offset", 0))

    # Collect guestbook entries from both sources
    entries = []

    if config.enable_webmentions:
        for wm in app.get_guestbook_webmentions():
            entries.append(
                {
                    "id": wm.source,
                    "url": wm.source,
                    "title": wm.title or wm.excerpt or "Webmention",
                    "content": wm.content or wm.excerpt or "",
                    "published": wm.published or wm.created_at,
                    "author_name": wm.author_name,
                    "author_url": wm.author_url,
                    "type": "webmention",
                }
            )

    if config.enable_activitypub:
        for interaction in app.get_guestbook_ap_interactions():
            obj_id = getattr(interaction, "object_id", None) or getattr(
                interaction, "activity_id", ""
            )
            entries.append(
                {
                    "id": obj_id,
                    "url": obj_id,
                    "title": getattr(interaction, "source_actor_name", None)
                    or "Fediverse mention",
                    "content": getattr(interaction, "content", "") or "",
                    "published": getattr(interaction, "published", None)
                    or getattr(interaction, "created_at", None),
                    "author_name": getattr(interaction, "source_actor_name", None),
                    "author_url": getattr(interaction, "source_actor_id", None),
                    "type": "activitypub",
                }
            )

    # Sort by published date (most recent first) and apply pagination
    entries.sort(key=lambda x: x["published"] or datetime.datetime.min, reverse=True)
    entries = entries[offset : offset + limit]

    # Build the feed
    fg = FeedGenerator()
    guestbook_url = _get_absolute_url("/guestbook")
    fg.id(guestbook_url)
    fg.title(f"{config.title} - Guestbook")
    fg.link(href=guestbook_url, rel="alternate")
    fg.description(f"Guestbook entries for {config.title}")
    fg.language(config.language)

    fg.author(
        **_parse_author_info(author=config.author, author_url=config.author_url),
    )

    self_url = _get_absolute_url(f"/guestbook/feed.{feed_type}")
    fg.link(href=self_url, rel="self")

    if entries:
        updated = _to_feed_datetime(entries[0].get("published"))
        if updated:
            fg.updated(updated)

    for entry in entries:
        fe = fg.add_entry()
        entry_id = entry.get("id", "")
        if entry_id:
            fe.id(entry_id)
            fe.link(href=entry_id, rel="alternate")

        fe.title(_to_feed_text(entry.get("title", "Guestbook entry")))

        published = _to_feed_datetime(entry.get("published"))
        if published:
            fe.published(published)
            fe.updated(published)

        content = entry.get("content", "")
        if content:
            fe.content(_to_feed_text(content), type="html")

        author_name = entry.get("author_name")
        author_url = entry.get("author_url")
        if author_name or author_url:
            fe.author(**_parse_author_info(author=author_name, author_url=author_url))

    # Create response with appropriate content type
    response = (
        Response(fg.atom_str(pretty=True), mimetype="application/atom+xml")
        if feed_type == "atom"
        else Response(fg.rss_str(pretty=True), mimetype="application/rss+xml")
    )

    response.headers["Cache-Control"] = "no-store"

    if config.language:
        response.headers["Language"] = config.language

    return response


@app.route("/guestbook/feed.<type>", methods=["GET"])
def guestbook_feed_route(type: str):
    return _get_guestbook_feed(request, type)


@app.route("/guestbook/feed", methods=["GET"])
def guestbook_feed_default_route():
    return _get_guestbook_feed(request)


@app.route("/followers", methods=["GET"])
def followers_route():
    from flask import make_response, render_template

    if not config.enable_activitypub:
        return Response("ActivityPub is not enabled", status=404, mimetype="text/plain")

    # Compute mtime from followers directory
    followers_dir = config.resolved_state_dir / "activitypub" / "state" / "followers"
    mtime = get_dir_mtime(followers_dir)

    # Check cache validity
    last_modified = None
    etag = None
    if mtime > 0:
        last_modified = email.utils.formatdate(mtime, usegmt=True)
        etag = generate_etag(mtime)
        if check_cache_validity(mtime, etag):
            return make_304_response(last_modified, etag, {})

    followers = []
    if hasattr(app, "activitypub_storage"):
        try:
            raw_followers = app.activitypub_storage.get_followers()
            for f in raw_followers:
                actor_data = f.actor_data or {}
                followers.append(
                    {
                        "actor_id": f.actor_id,
                        "name": actor_data.get("name")
                        or actor_data.get("preferredUsername")
                        or f.actor_id,
                        "username": actor_data.get("preferredUsername", ""),
                        "url": actor_data.get("url") or f.actor_id,
                        "host": urlparse(actor_data.get("url") or f.actor_id).netloc,
                        "icon": (
                            actor_data.get("icon", {}).get("url")
                            if isinstance(actor_data.get("icon"), dict)
                            else actor_data.get("icon")
                        ),
                        "summary": actor_data.get("summary", ""),
                        "followed_at": f.followed_at,
                    }
                )
            followers.sort(key=lambda x: x.get("followed_at") or "", reverse=True)
        except Exception:
            logger.exception("Failed to get followers")

    response = make_response(
        render_template(
            "followers.html",
            followers=followers,
            config=config,
        )
    )

    # Set cache headers
    if mtime > 0:
        response.headers["Last-Modified"] = last_modified
        response.headers["ETag"] = etag
        response.headers["Cache-Control"] = "public, max-age=0, must-revalidate"
    else:
        response.headers["Cache-Control"] = "no-store"

    if config.language:
        response.headers["Language"] = config.language

    return response


@app.route("/unlisted", methods=["GET"])
def unlisted_route():
    """
    Render a timeline of unlisted posts.

    Unlisted posts are files in the replies/ root directory that have content
    but no reply-to or like-of metadata. They are published to the Fediverse
    but not listed on the main blog index.
    """
    from flask import make_response, render_template

    unlisted_posts = app.get_unlisted_posts()

    if not unlisted_posts:
        return Response("No unlisted posts", status=404, mimetype="text/plain")

    # Compute mtime from replies directory
    mtime = get_dir_mtime(app.replies_dir)

    # Check cache validity
    last_modified = None
    etag = None
    if mtime > 0:
        last_modified = email.utils.formatdate(mtime, usegmt=True)
        etag = generate_etag(mtime)
        if check_cache_validity(mtime, etag):
            return make_304_response(last_modified, etag, {})

    # Build thread tree from unlisted posts (no webmentions/AP interactions,
    # just the posts themselves as author replies)
    from .reactions import ReactionType, ThreadNode

    reactions_tree = []
    for post in unlisted_posts:
        node = ThreadNode(
            item=post,
            reaction_type=ReactionType.AUTHOR_REPLY,
            identity=post.get("full_url", ""),
            reply_to=None,
            published=post.get("published"),
            children=[],
        )
        reactions_tree.append(node)

    reactions_counts = count_reactions(reactions_tree)

    response = make_response(
        render_template(
            "unlisted.html",
            config=config,
            reactions_tree=reactions_tree,
            reactions_counts=reactions_counts,
            utils=TemplateUtils(),
            followers_count=_get_followers_count(),
        )
    )

    # Set cache headers
    if mtime > 0:
        response.headers["Last-Modified"] = last_modified
        response.headers["ETag"] = etag
        response.headers["Cache-Control"] = "public, max-age=0, must-revalidate"
    else:
        response.headers["Cache-Control"] = "no-store"

    if config.language:
        response.headers["Language"] = config.language

    return response


# vim:sw=4:ts=4:et:
