"""Native post/page CRUD — replaces Ghost Admin API writes.

All operations go directly to db_blog. Ghost is never called.
"""
from __future__ import annotations

import json
import logging
import re
from datetime import datetime
from typing import Any, Optional

import nh3
from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession

from models.ghost_content import Post, Tag, PostTag, PostUser
from shared.browser.app.utils import utcnow

log = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _slugify(name: str) -> str:
    s = name.strip().lower()
    s = re.sub(r"[^\w\s-]", "", s)
    s = re.sub(r"[\s_]+", "-", s)
    return s.strip("-")


def _reading_time(plaintext: str | None) -> int:
    """Estimate reading time in minutes (word count / 265, min 1)."""
    if not plaintext:
        return 0
    words = len(plaintext.split())
    return max(1, round(words / 265))


def _extract_plaintext(html: str) -> str:
    """Strip HTML tags to get plaintext."""
    text = re.sub(r"<[^>]+>", "", html)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def _sanitize_html(html: str | None) -> str | None:
    if not html:
        return html
    return nh3.clean(
        html,
        tags={
            "a", "abbr", "acronym", "b", "blockquote", "br", "code",
            "div", "em", "figcaption", "figure", "h1", "h2", "h3",
            "h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p",
            "pre", "span", "strong", "sub", "sup", "table", "tbody",
            "td", "th", "thead", "tr", "ul", "video", "source",
            "picture", "iframe", "audio",
        },
        attributes={
            "*": {"class", "id", "style"},
            "a": {"href", "title", "target"},
            "img": {"src", "alt", "title", "width", "height", "loading"},
            "video": {"src", "controls", "width", "height", "poster"},
            "audio": {"src", "controls"},
            "source": {"src", "type"},
            "iframe": {"src", "width", "height", "frameborder", "allowfullscreen"},
            "td": {"colspan", "rowspan"},
            "th": {"colspan", "rowspan"},
        },
        link_rel="noopener noreferrer",
        url_schemes={"http", "https", "mailto"},
    )


def _render_and_extract(lexical_json: str) -> tuple[str, str, int]:
    """Render HTML from Lexical JSON, extract plaintext, compute reading time.

    Returns (html, plaintext, reading_time).
    """
    from bp.blog.ghost.lexical_renderer import render_lexical

    doc = json.loads(lexical_json) if isinstance(lexical_json, str) else lexical_json
    html = render_lexical(doc)
    html = _sanitize_html(html)
    plaintext = _extract_plaintext(html or "")
    rt = _reading_time(plaintext)
    return html, plaintext, rt


async def _ensure_slug_unique(sess: AsyncSession, slug: str, exclude_post_id: int | None = None) -> str:
    """Append -2, -3, etc. if slug already taken."""
    base_slug = slug
    counter = 1
    while True:
        q = select(Post.id).where(Post.slug == slug, Post.deleted_at.is_(None))
        if exclude_post_id:
            q = q.where(Post.id != exclude_post_id)
        existing = await sess.scalar(q)
        if existing is None:
            return slug
        counter += 1
        slug = f"{base_slug}-{counter}"


async def _upsert_tags_by_name(sess: AsyncSession, tag_names: list[str]) -> list[Tag]:
    """Find or create tags by name. Returns Tag objects in order."""
    tags: list[Tag] = []
    for name in tag_names:
        name = name.strip()
        if not name:
            continue
        tag = (await sess.execute(
            select(Tag).where(Tag.name == name, Tag.deleted_at.is_(None))
        )).scalar_one_or_none()
        if tag is None:
            tag = Tag(
                name=name,
                slug=_slugify(name),
                visibility="public",
            )
            sess.add(tag)
            await sess.flush()
        tags.append(tag)
    return tags


async def _rebuild_post_tags(sess: AsyncSession, post_id: int, tags: list[Tag]) -> None:
    """Replace all post_tags for a post."""
    await sess.execute(delete(PostTag).where(PostTag.post_id == post_id))
    seen: set[int] = set()
    for idx, tag in enumerate(tags):
        if tag.id not in seen:
            seen.add(tag.id)
            sess.add(PostTag(post_id=post_id, tag_id=tag.id, sort_order=idx))
    await sess.flush()


async def _rebuild_post_users(sess: AsyncSession, post_id: int, user_ids: list[int]) -> None:
    """Replace all post_users for a post."""
    await sess.execute(delete(PostUser).where(PostUser.post_id == post_id))
    seen: set[int] = set()
    for idx, uid in enumerate(user_ids):
        if uid not in seen:
            seen.add(uid)
            sess.add(PostUser(post_id=post_id, user_id=uid, sort_order=idx))
    await sess.flush()


async def _fire_ap_publish(
    sess: AsyncSession,
    post: Post,
    old_status: str | None,
    tag_objs: list[Tag],
) -> None:
    """Fire AP federation activity on status transitions."""
    if post.is_page or not post.user_id:
        return

    from bp.blog.ghost.ghost_sync import _build_ap_post_data
    from shared.services.federation_publish import try_publish
    from shared.infrastructure.urls import app_url

    post_url = app_url("blog", f"/{post.slug}/")

    if post.status == "published":
        activity_type = "Create" if old_status != "published" else "Update"
        await try_publish(
            sess,
            user_id=post.user_id,
            activity_type=activity_type,
            object_type="Note",
            object_data=_build_ap_post_data(post, post_url, tag_objs),
            source_type="Post",
            source_id=post.id,
        )
    elif old_status == "published" and post.status != "published":
        await try_publish(
            sess,
            user_id=post.user_id,
            activity_type="Delete",
            object_type="Tombstone",
            object_data={
                "id": post_url,
                "formerType": "Note",
            },
            source_type="Post",
            source_id=post.id,
        )


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

async def create_post(
    sess: AsyncSession,
    *,
    title: str,
    lexical_json: str,
    status: str = "draft",
    user_id: int,
    feature_image: str | None = None,
    custom_excerpt: str | None = None,
    feature_image_caption: str | None = None,
    tag_names: list[str] | None = None,
    is_page: bool = False,
    sx_content: str | None = None,
) -> Post:
    """Create a new post or page directly in db_blog."""
    html, plaintext, reading_time = _render_and_extract(lexical_json)
    slug = await _ensure_slug_unique(sess, _slugify(title or "untitled"))

    now = utcnow()
    post = Post(
        title=title or "Untitled",
        slug=slug,
        lexical=lexical_json if isinstance(lexical_json, str) else json.dumps(lexical_json),
        sx_content=sx_content,
        html=html,
        plaintext=plaintext,
        reading_time=reading_time,
        status=status,
        is_page=is_page,
        feature_image=feature_image,
        feature_image_caption=_sanitize_html(feature_image_caption),
        custom_excerpt=custom_excerpt,
        user_id=user_id,
        visibility="public",
        created_at=now,
        updated_at=now,
        published_at=now if status == "published" else None,
    )
    sess.add(post)
    await sess.flush()

    # Tags
    if tag_names:
        tags = await _upsert_tags_by_name(sess, tag_names)
        await _rebuild_post_tags(sess, post.id, tags)
        if tags:
            post.primary_tag_id = tags[0].id

    # Post users (author)
    await _rebuild_post_users(sess, post.id, [user_id])

    # PageConfig for pages
    if is_page:
        from shared.models.page_config import PageConfig
        existing = (await sess.execute(
            select(PageConfig).where(
                PageConfig.container_type == "page",
                PageConfig.container_id == post.id,
            )
        )).scalar_one_or_none()
        if existing is None:
            sess.add(PageConfig(
                container_type="page",
                container_id=post.id,
                features={},
            ))

    await sess.flush()

    # AP federation
    if status == "published":
        tag_objs = (await _upsert_tags_by_name(sess, tag_names)) if tag_names else []
        await _fire_ap_publish(sess, post, None, tag_objs)

    return post


async def create_page(
    sess: AsyncSession,
    *,
    title: str,
    lexical_json: str,
    status: str = "draft",
    user_id: int,
    feature_image: str | None = None,
    custom_excerpt: str | None = None,
    feature_image_caption: str | None = None,
    tag_names: list[str] | None = None,
    sx_content: str | None = None,
) -> Post:
    """Create a new page. Convenience wrapper around create_post."""
    return await create_post(
        sess,
        title=title,
        lexical_json=lexical_json,
        status=status,
        user_id=user_id,
        feature_image=feature_image,
        custom_excerpt=custom_excerpt,
        feature_image_caption=feature_image_caption,
        tag_names=tag_names,
        is_page=True,
        sx_content=sx_content,
    )


async def update_post(
    sess: AsyncSession,
    *,
    post_id: int,
    lexical_json: str,
    title: str | None = None,
    expected_updated_at: datetime | str,
    feature_image: str | None = ...,  # type: ignore[assignment]
    custom_excerpt: str | None = ...,  # type: ignore[assignment]
    feature_image_caption: str | None = ...,  # type: ignore[assignment]
    status: str | None = None,
    sx_content: str | None = ...,  # type: ignore[assignment]
) -> Post:
    """Update post content. Optimistic lock via expected_updated_at.

    Fields set to ... (sentinel) are left unchanged. None clears the field.
    Raises ValueError on optimistic lock conflict (409).
    """
    _SENTINEL = ...

    post = await sess.get(Post, post_id)
    if post is None:
        raise ValueError(f"Post {post_id} not found")

    # Optimistic lock
    if isinstance(expected_updated_at, str):
        expected_updated_at = datetime.fromisoformat(
            expected_updated_at.replace("Z", "+00:00")
        )
    if post.updated_at and abs((post.updated_at - expected_updated_at).total_seconds()) > 1:
        raise OptimisticLockError(
            f"Post was modified at {post.updated_at}, expected {expected_updated_at}"
        )

    old_status = post.status

    # Render content
    html, plaintext, reading_time = _render_and_extract(lexical_json)
    post.lexical = lexical_json if isinstance(lexical_json, str) else json.dumps(lexical_json)
    post.html = html
    post.plaintext = plaintext
    post.reading_time = reading_time

    if title is not None:
        post.title = title

    if sx_content is not _SENTINEL:
        post.sx_content = sx_content

    if feature_image is not _SENTINEL:
        post.feature_image = feature_image
    if custom_excerpt is not _SENTINEL:
        post.custom_excerpt = custom_excerpt
    if feature_image_caption is not _SENTINEL:
        post.feature_image_caption = _sanitize_html(feature_image_caption)

    if status is not None:
        post.status = status
        if status == "published" and not post.published_at:
            post.published_at = utcnow()

    post.updated_at = utcnow()
    await sess.flush()

    # AP federation on status change
    tags = list(post.tags) if hasattr(post, "tags") and post.tags else []
    await _fire_ap_publish(sess, post, old_status, tags)

    return post


_SETTINGS_FIELDS = (
    "slug", "published_at", "featured", "visibility", "email_only",
    "custom_template", "meta_title", "meta_description", "canonical_url",
    "og_image", "og_title", "og_description",
    "twitter_image", "twitter_title", "twitter_description",
    "feature_image_alt",
)


async def update_post_settings(
    sess: AsyncSession,
    *,
    post_id: int,
    expected_updated_at: datetime | str,
    tag_names: list[str] | None = None,
    **kwargs: Any,
) -> Post:
    """Update post settings (slug, tags, SEO, social, etc.).

    Optimistic lock via expected_updated_at.
    """
    post = await sess.get(Post, post_id)
    if post is None:
        raise ValueError(f"Post {post_id} not found")

    # Optimistic lock
    if isinstance(expected_updated_at, str):
        expected_updated_at = datetime.fromisoformat(
            expected_updated_at.replace("Z", "+00:00")
        )
    if post.updated_at and abs((post.updated_at - expected_updated_at).total_seconds()) > 1:
        raise OptimisticLockError(
            f"Post was modified at {post.updated_at}, expected {expected_updated_at}"
        )

    old_status = post.status

    for field in _SETTINGS_FIELDS:
        val = kwargs.get(field)
        if val is not None:
            if field == "slug":
                val = await _ensure_slug_unique(sess, val, exclude_post_id=post.id)
            if field == "featured":
                val = bool(val)
            if field == "email_only":
                val = bool(val)
            if field == "published_at":
                if isinstance(val, str):
                    val = datetime.fromisoformat(val.replace("Z", "+00:00"))
            setattr(post, field, val)

    # Tags
    if tag_names is not None:
        tags = await _upsert_tags_by_name(sess, tag_names)
        await _rebuild_post_tags(sess, post.id, tags)
        post.primary_tag_id = tags[0].id if tags else None

    post.updated_at = utcnow()
    await sess.flush()

    # AP federation if visibility/status changed
    tags_obj = list(post.tags) if hasattr(post, "tags") and post.tags else []
    await _fire_ap_publish(sess, post, old_status, tags_obj)

    return post


async def delete_post(sess: AsyncSession, post_id: int) -> None:
    """Soft-delete a post via deleted_at."""
    post = await sess.get(Post, post_id)
    if post is None:
        return

    old_status = post.status
    post.deleted_at = utcnow()
    post.status = "deleted"
    await sess.flush()

    # Fire AP Delete if was published
    if old_status == "published" and post.user_id:
        tags = list(post.tags) if hasattr(post, "tags") and post.tags else []
        await _fire_ap_publish(sess, post, old_status, tags)


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------

class OptimisticLockError(Exception):
    """Raised when optimistic lock check fails (stale updated_at)."""
    pass
