"""Pins + threads + comments + attachments. A pin is the x/y anchor; the
conversation is a standard-shaped thread (subject_type "pin", subject_id = pin
id) so the future bw_interaction swap is a substitution (goal §7)."""

import logging
import secrets
from datetime import datetime, timezone

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session

from app import accounts, schemas
from app.db import get_session_factory
from app.identity import optional_user
from app.models import Comment, CommentReaction, CommentRevision, Option, Pin, Screen, Thread, WalkthroughStep
from app.services import interaction
from app.services import projects as svc

log = logging.getLogger(__name__)
router = APIRouter(tags=["pins"])


def _user(user: str | None = Depends(optional_user)) -> str:
    assert user is not None
    return user


def _variant_labels(db: Session, option_id: int, chosen: dict | None) -> list[str]:
    """Turn {"brand": "hive"} into ["Full hive"] — the client's own words for
    the variation, not our keys."""
    if not chosen:
        return []
    from app.models import Option

    option = db.get(Option, option_id)
    if option is None:
        return []
    return [v.label for v in option.variants if chosen.get(v.axis) == v.key]


def _pin_out(db: Session, pin: Pin, thread: Thread, number: int,
             project_id: str) -> schemas.PinThreadOut:
    return schemas.PinThreadOut(
        pin_id=pin.id, thread_id=thread.id, x_percent=pin.x_percent,
        y_percent=pin.y_percent, w_percent=pin.w_percent, h_percent=pin.h_percent,
        anchor=pin.anchor, number=number, resolved=thread.resolved,
        created_by=pin.created_by, created_at=pin.created_at,
        variants=pin.variants,
        variant_labels=_variant_labels(db, pin.option_id, pin.variants),
        comments=[schemas.CommentOut(
            id=c.id,
            body_md="" if c.deleted_at else c.body_md,
            author_username=c.author_username,
            author_side=svc.side_of(c.author_username, project_id) or "client",
            created_at=c.created_at, edited_at=c.edited_at,
            deleted=c.deleted_at is not None,
            attachment_ids=[a.id for a in c.attachments],
            reactions=interaction.reactions_of(c),
        ) for c in thread.comments])


def _anchor_of(anchor: schemas.PinAnchor | None, area: bool) -> dict | None:
    """The stored anchor, or None. An area needs both corners named; half an
    area anchor would move one edge and leave the other behind."""
    if anchor is None:
        return None
    if area and not (anchor.s2 and anchor.fx2 is not None and anchor.oy2 is not None):
        return None
    return anchor.model_dump(exclude_none=True) if area else {
        "s": anchor.s, "fx": anchor.fx, "oy": anchor.oy}


def _option_ctx(db: Session, option_id: int, user: str):
    try:
        option, project_id = svc.option_project(db, option_id)
    except svc.DomainError as exc:
        raise HTTPException(status_code=exc.status, detail={
            "error_code": exc.code, "summary": str(exc)}) from exc
    # Not merely "can you see the project" — an option with no mockup is
    # invisible to the client in the project view and must 404 by id too.
    if not svc.option_visible_to(db, user, option):
        raise HTTPException(status_code=404, detail={
            "error_code": "NO_SUCH_OPTION", "summary": "No such option."})
    return option, project_id


def _thread_ctx(db: Session, thread_id: int, user: str):
    """Resolve a thread to (thread, pin_or_None, project_id).

    A thread is not always anchored to a pin. The Interaction Standard's
    `subject_type` is app-defined and plural on purpose — easel uses "pin" for a
    note on a spot and "screen" for a conversation about a page as a whole — so
    the comment, resolve and attachment paths are shared rather than duplicated
    per subject. Callers must cope with `pin is None`.
    """
    thread = db.get(Thread, thread_id)
    if thread is None:
        raise HTTPException(status_code=404, detail={
            "error_code": "NO_SUCH_THREAD", "summary": "No such thread."})
    pin = db.scalar(select(Pin).where(Pin.thread_id == thread_id))
    if pin is not None:
        _option, project_id = _option_ctx(db, pin.option_id, user)
        return thread, pin, project_id
    project_id = thread.project_id or ""
    if svc.side_of(user, project_id) is None:
        raise HTTPException(status_code=404, detail={
            "error_code": "NO_SUCH_THREAD", "summary": "No such thread."})
    return thread, None, project_id


def _viewer_url(project_id: str, option_id: int | None,
                pin_id: int | None = None) -> str:
    # A screen-level thread has no option to deep-link into; the board is where
    # that conversation lives.
    if option_id is None:
        return f"/p/{project_id}"
    url = f"/p/{project_id}/o/{option_id}"
    return f"{url}?pin={pin_id}" if pin_id else url


@router.get("/api/options/{option_id}/pins", response_model=schemas.PinListOut)
def list_pins(option_id: int, user: str = Depends(_user)) -> schemas.PinListOut:
    sf = get_session_factory()
    with sf() as db:
        _option, project_id = _option_ctx(db, option_id, user)
        rows = db.execute(
            select(Pin, Thread).join(Thread, Pin.thread_id == Thread.id)
            .where(Pin.option_id == option_id).order_by(Pin.id)).all()
        return schemas.PinListOut(pins=[
            _pin_out(db, pin, thread, i + 1, project_id)
            for i, (pin, thread) in enumerate(rows)])


@router.post("/api/options/{option_id}/pins", response_model=schemas.PinCreatedOut)
def create_pin(option_id: int, body: schemas.CreatePinIn,
               user: str = Depends(_user)) -> schemas.PinCreatedOut:
    sf = get_session_factory()
    with sf() as db:
        option, project_id = _option_ctx(db, option_id, user)
        inst = accounts.instance_obj(project_id)
        # Pin row first (needs an id for the thread's subject_id) — a random
        # placeholder subject that is rewritten in the same transaction would
        # violate UNIQUE on retries, so mint the pin id via flush.
        # THREAD FIRST. The pin's thread_id is a real foreign key, so a pin can
        # never be inserted before the thread it points at — the previous order
        # wrote `thread_id=0` as a placeholder, which Postgres rejects outright.
        # SQLite does not enforce foreign keys unless asked, which is why every
        # test passed while the feature was broken in production; the test engine
        # now turns enforcement ON (app/db.py) so this class of bug cannot hide
        # behind the dialect again.
        #
        # subject_id starts provisional because it wants the pin's id, which does
        # not exist yet, and the column is UNIQUE so it cannot simply be blank.
        provisional = f"pending-{secrets.token_hex(8)}"
        thread = Thread(project_id=project_id, subject_type="pin",
                        subject_id=provisional, title="", created_by=user)
        db.add(thread)
        db.flush()

        area = body.w_percent is not None and body.h_percent is not None
        pin = Pin(option_id=option.id, thread_id=thread.id,
                  x_percent=body.x_percent, y_percent=body.y_percent,
                  w_percent=body.w_percent if area else None,
                  h_percent=body.h_percent if area else None,
                  anchor=_anchor_of(body.anchor, area),
                  created_by=user, variants=body.variants or None)
        db.add(pin)
        db.flush()

        thread.subject_id = str(pin.id)
        db.flush()
        interaction.add_comment(
            db, thread, user, body.body_md,
            url=_viewer_url(project_id, option.id, pin.id),
            context_label=f"{inst['label']} · {option.title}")
        svc.record(db, project_id, user, "pin_created",
                   {"option_id": option.id, "pin_id": pin.id})
        rows = db.execute(
            select(Pin).where(Pin.option_id == option.id).order_by(Pin.id)).scalars().all()
        number = [p.id for p in rows].index(pin.id) + 1
        out = _pin_out(db, pin, thread, number, project_id)
        db.commit()
        return schemas.PinCreatedOut(pin=out)


@router.delete("/api/pins/{pin_id}", response_model=schemas.OkOut)
def delete_pin(pin_id: int, user: str = Depends(_user)) -> schemas.OkOut:
    """Retract a note entirely — the marker too.

    Removing a comment is soft, because a conversation is a record. Retracting
    your own note is not censorship of a conversation, it is withdrawing your
    own remark; and a marker left on the design with nothing behind it is worse
    than either outcome. Same authority as removing a comment: yours always, any
    if you are the team.
    """
    sf = get_session_factory()
    with sf() as db:
        pin = db.get(Pin, pin_id)
        if pin is None:
            raise HTTPException(status_code=404, detail={
                "error_code": "NO_SUCH_PIN", "summary": "No such note."})
        _option, project_id = _option_ctx(db, pin.option_id, user)
        if pin.created_by != user and svc.side_of(user, project_id) != "manager":
            raise HTTPException(status_code=403, detail={
                "error_code": "FORBIDDEN",
                "summary": "Only the note's author or the team can remove it."})
        svc.record(db, project_id, user, "pin_deleted",
                   {"pin_id": pin_id, "option_id": pin.option_id})
        svc.delete_pin(db, pin)
        db.commit()
    return schemas.OkOut()


@router.post("/api/pins/{pin_id}/move", response_model=schemas.OkOut)
def move_pin(pin_id: int, body: schemas.MovePinIn,
             user: str = Depends(_user)) -> schemas.OkOut:
    sf = get_session_factory()
    with sf() as db:
        pin = db.get(Pin, pin_id)
        if pin is None:
            raise HTTPException(status_code=404, detail={
                "error_code": "NO_SUCH_PIN", "summary": "No such pin."})
        _option, project_id = _option_ctx(db, pin.option_id, user)
        if pin.created_by != user and svc.side_of(user, project_id) != "manager":
            raise HTTPException(status_code=403, detail={
                "error_code": "FORBIDDEN",
                "summary": "Only the pin's author or the team can move it."})
        pin.x_percent, pin.y_percent = body.x_percent, body.y_percent
        if body.w_percent is not None and body.h_percent is not None:
            pin.w_percent, pin.h_percent = body.w_percent, body.h_percent
        pin.anchor = _anchor_of(body.anchor, pin.w_percent is not None)
        db.commit()
    return schemas.OkOut()


@router.post("/api/threads/{thread_id}/move", response_model=schemas.ThreadMovedOut)
def move_thread(thread_id: int, body: schemas.MoveThreadIn,
                user: str = Depends(_user)) -> schemas.ThreadMovedOut:
    """Re-file a conversation (services.projects.move_thread). The author or
    the team; the target must belong to the same project — the page this
    design is on, this version, or one of its walkthrough points."""
    sf = get_session_factory()
    with sf() as db:
        thread, _pin, project_id = _thread_ctx(db, thread_id, user)
        side = svc.side_of(user, project_id)
        if side is None:
            # 404, never 403: an outsider learns nothing, not even that it exists.
            raise HTTPException(status_code=404, detail={
                "error_code": "NO_SUCH_THREAD", "summary": "No such thread."})
        if thread.created_by != user and side != "manager":
            raise HTTPException(status_code=403, detail={
                "error_code": "FORBIDDEN",
                "summary": "Only the conversation's author or the team can move it."})
        missing = HTTPException(status_code=404, detail={
            "error_code": "NO_SUCH_SUBJECT", "summary": "No such place to move it to."})
        if body.subject_type == "screen":
            screen = db.get(Screen, body.subject_id)
            if screen is None or screen.instance_id != project_id:
                raise missing
            target = svc.ensure_screen_thread(db, screen, user)
        elif body.subject_type == "option":
            option = db.get(Option, body.subject_id)
            if option is None or option.screen.instance_id != project_id:
                raise missing
            target = svc.ensure_option_thread(db, option, project_id, user)
        else:
            step = db.get(WalkthroughStep, body.subject_id)
            option = db.get(Option, step.option_id) if step is not None else None
            if step is None or option is None or option.screen.instance_id != project_id:
                raise missing
            target = svc.ensure_step_thread(db, step, project_id, user)
        target = svc.move_thread(db, thread, target, project_id, user)
        out = schemas.ThreadMovedOut(thread_id=target.id, subject_type=body.subject_type,
                                     subject_id=body.subject_id)
        db.commit()
    return out


@router.post("/api/threads/{thread_id}/comments", response_model=schemas.OkOut)
def add_comment(thread_id: int, body: schemas.AddCommentIn,
                user: str = Depends(_user)) -> schemas.OkOut:
    sf = get_session_factory()
    with sf() as db:
        thread, pin, project_id = _thread_ctx(db, thread_id, user)
        inst = accounts.instance_obj(project_id)
        from app.models import Option, Screen

        if pin is not None:
            option = db.get(Option, pin.option_id)
            label = f"{inst['label']} · {option.title}"
            url = _viewer_url(project_id, pin.option_id, pin.id)
        else:
            screen = db.get(Screen, int(thread.subject_id or 0))
            label = f"{inst['label']} · {screen.title if screen else 'Screen'}"
            url = _viewer_url(project_id, None)
        interaction.add_comment(db, thread, user, body.body_md,
                                url=url, context_label=label)
        db.commit()
    return schemas.OkOut()


@router.post("/api/threads/{thread_id}/resolve", response_model=schemas.OkOut)
def resolve_thread(thread_id: int, user: str = Depends(_user)) -> schemas.OkOut:
    return _set_resolved(thread_id, user, True)


@router.post("/api/threads/{thread_id}/reopen", response_model=schemas.OkOut)
def reopen_thread(thread_id: int, user: str = Depends(_user)) -> schemas.OkOut:
    return _set_resolved(thread_id, user, False)


def _set_resolved(thread_id: int, user: str, value: bool) -> schemas.OkOut:
    sf = get_session_factory()
    with sf() as db:
        thread, pin, project_id = _thread_ctx(db, thread_id, user)
        if svc.side_of(user, project_id) != "manager" and \
                thread.created_by != user:
            raise HTTPException(status_code=403, detail={
                "error_code": "FORBIDDEN",
                "summary": "Only the team or the thread's author can do that."})
        inst = accounts.instance_obj(project_id)
        interaction.set_resolved(
            db, thread, user, value,
            url=_viewer_url(project_id, pin.option_id if pin else None,
                            pin.id if pin else None),
            context_label=inst["label"])
        svc.record(db, project_id, user,
                   "thread_resolved" if value else "thread_reopened",
                   {"thread_id": thread.id,
                    "pin_id": pin.id if pin else None,
                    "subject_type": thread.subject_type})
        db.commit()
    return schemas.OkOut()


@router.patch("/api/comments/{comment_id}", response_model=schemas.OkOut)
def edit_comment(comment_id: int, body: schemas.AddCommentIn,
                 user: str = Depends(_user)) -> schemas.OkOut:
    sf = get_session_factory()
    with sf() as db:
        comment = db.get(Comment, comment_id)
        if comment is None or comment.deleted_at is not None:
            raise HTTPException(status_code=404, detail={
                "error_code": "NO_SUCH_COMMENT", "summary": "No such comment."})
        _thread_ctx(db, comment.thread_id, user)
        if comment.author_username != user:
            raise HTTPException(status_code=403, detail={
                "error_code": "FORBIDDEN", "summary": "Not your comment."})
        if (body.body_md or "").strip() == (comment.body_md or "").strip():
            return schemas.OkOut()      # nothing changed; do not file a revision
        now = datetime.now(timezone.utc)
        # File the wording being replaced BEFORE overwriting it. An edit may
        # correct a mistake; it may not make the earlier version disappear.
        db.add(CommentRevision(comment_id=comment.id, body_md=comment.body_md,
                               replaced_at=now))
        comment.body_md = body.body_md
        comment.edited_at = now
        db.commit()
    return schemas.OkOut()


@router.post("/api/comments/{comment_id}/reactions", response_model=schemas.OkOut)
def toggle_reaction(comment_id: int, body: schemas.ReactIn,
                    user: str = Depends(_user)) -> schemas.OkOut:
    """Add this person's emoji to a comment, or take it back if it is already
    there. Anyone who can read the comment can react to it, their own
    included, as in Slack. Nobody is notified."""
    if body.emoji not in schemas.REACTION_EMOJI:
        raise HTTPException(status_code=422, detail={
            "error_code": "NO_SUCH_REACTION", "summary": "That reaction is not offered."})
    sf = get_session_factory()
    with sf() as db:
        comment = db.get(Comment, comment_id)
        if comment is None or comment.deleted_at is not None:
            raise HTTPException(status_code=404, detail={
                "error_code": "NO_SUCH_COMMENT", "summary": "No such comment."})
        _thread_ctx(db, comment.thread_id, user)
        mine = db.scalar(select(CommentReaction).where(
            CommentReaction.comment_id == comment_id,
            CommentReaction.username == user,
            CommentReaction.emoji == body.emoji))
        if mine is not None:
            db.delete(mine)
        else:
            db.add(CommentReaction(comment_id=comment_id, username=user, emoji=body.emoji))
        db.commit()
    return schemas.OkOut()


@router.delete("/api/comments/{comment_id}", response_model=schemas.OkOut)
def delete_comment(comment_id: int, user: str = Depends(_user)) -> schemas.OkOut:
    """Soft delete — never hard-delete a conversation (standard §4)."""
    sf = get_session_factory()
    with sf() as db:
        comment = db.get(Comment, comment_id)
        if comment is None:
            raise HTTPException(status_code=404, detail={
                "error_code": "NO_SUCH_COMMENT", "summary": "No such comment."})
        _thread, _pin, project_id = _thread_ctx(db, comment.thread_id, user)
        if comment.author_username != user and \
                svc.side_of(user, project_id) != "manager":
            raise HTTPException(status_code=403, detail={
                "error_code": "FORBIDDEN", "summary": "Not your comment."})
        comment.deleted_at = datetime.now(timezone.utc)
        db.commit()
    return schemas.OkOut()


@router.get("/api/comments/{comment_id}/history",
            response_model=schemas.CommentHistoryOut)
def comment_history(comment_id: int,
                    user: str = Depends(_user)) -> schemas.CommentHistoryOut:
    """What this comment used to say.

    Open to everyone who can read the comment, deliberately — a history only the
    author can see is not a record, it is a diary. The point is that an edit
    cannot quietly change what was agreed.
    """
    sf = get_session_factory()
    with sf() as db:
        comment = db.get(Comment, comment_id)
        if comment is None:
            raise HTTPException(status_code=404, detail={
                "error_code": "NO_SUCH_COMMENT", "summary": "No such comment."})
        _thread_ctx(db, comment.thread_id, user)
        rows = db.scalars(select(CommentRevision)
                          .where(CommentRevision.comment_id == comment_id)
                          .order_by(CommentRevision.id)).all()
        return schemas.CommentHistoryOut(revisions=[
            schemas.CommentRevisionOut(body_md=r.body_md, replaced_at=r.replaced_at)
            for r in rows])
