from __future__ import annotations

import json
import re
import secrets
import base64
import binascii
from datetime import datetime, timedelta, timezone
import os
import uuid
import time
from pathlib import Path
from typing import Any

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field

from . import db
from .services.context_builder import build_system_prompt, recent_messages_for_model
from .services.memory import llm_extract, apply_extracted_local, rollback_auto_continuity_for_messages
from .services.provider import call_chat
from .services.model_access import public_models, reserve as reserve_local_generation, release as release_local_generation, finalize as finalize_local_generation, usage as local_generation_usage
from .services.story_time import advance_for_user_turn, ensure_story_clock
from .services.validators import validate_candidate
from .services.canon_search import search_canon
from .services.web_search import search_web
from .services.image_generation import generate as generate_2d_image, OUT as GENERATED_IMAGE_DIR
from .services.chat_import import parse_transcript, heuristic_analysis, deep_analysis, merge_analysis
from .services import cloud

ROOT = Path(__file__).resolve().parents[1]
STATIC = ROOT / "static"

# Persona slots are a subscription entitlement. Keep this centralized so plan
# limits can be changed without touching every endpoint/UI. None = unlimited.
PERSONA_LIMITS = {"Free": 2, "Plus": 5, "Pro": 15, "Ultra": None}

def persona_limit_for(plan_tier: str) -> int | None:
    return PERSONA_LIMITS.get(str(plan_tier or "Free"), 2)

# Lightweight .env loader for local prototype; avoids extra dependency.
env_path = ROOT / ".env"
if env_path.exists():
    for line in env_path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))

# provider module reads env at import time; .env also works when supplied by shell.

db.init_db()
app = FastAPI(title="MelumiyaChat", version="0.9.15")
app.mount("/static", StaticFiles(directory=STATIC), name="static")
app.mount("/generated-images", StaticFiles(directory=GENERATED_IMAGE_DIR), name="generated-images")

@app.middleware("http")
async def disable_client_asset_cache(request: Request, call_next):
    response = await call_next(request)
    if request.url.path in {"/", "/static/app.js", "/static/auth.js", "/static/index.html"}:
        response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
        response.headers["Pragma"] = "no-cache"
        response.headers["Expires"] = "0"
    return response


class AuthSignupRequest(BaseModel):
    email: str
    password: str
    display_name: str = ""


class AuthLoginRequest(BaseModel):
    email: str
    password: str


class AuthRefreshRequest(BaseModel):
    refresh_token: str


class BillingCheckoutRequest(BaseModel):
    tier: str
    billing_period: str = "monthly"
    referral_code: str = ""


def _bearer_token(request: Request) -> str:
    value = request.headers.get("Authorization", "")
    if not value.lower().startswith("bearer "):
        raise HTTPException(401, "Sign in to continue.")
    return value.split(" ", 1)[1].strip()


@app.middleware("http")
async def cloud_auth_guard(request: Request, call_next):
    """Require a valid customer Supabase session for private API data."""
    path = request.url.path
    if not cloud.enabled() or not path.startswith("/api/") or path == "/api/health" or path.startswith("/api/auth/"):
        return await call_next(request)
    try:
        token = _bearer_token(request)
        account = cloud.me(token)
        request.state.cloud_token = token
        request.state.cloud_account = account
    except HTTPException as exc:
        return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
    except Exception as exc:
        return JSONResponse({"detail": f"Session check failed: {exc}"}, status_code=401)
    return await call_next(request)


@app.get("/api/auth/config")
def auth_config() -> dict[str, Any]:
    return {"cloud_enabled": cloud.enabled(), "provider": "Supabase Auth", "project_ref": "dzorhvanzzscfpnczilh"}


@app.post("/api/auth/signup")
def auth_signup(req: AuthSignupRequest) -> dict[str, Any]:
    if not cloud.enabled():
        raise HTTPException(503, "Cloud authentication is not configured.")
    if len(req.password) < 8:
        raise HTTPException(400, "Use a password with at least 8 characters.")
    try:
        data = cloud.signup(req.email.strip(), req.password, req.display_name.strip())
        session = data.get("session") or {}
        access = session.get("access_token") or data.get("access_token")
        account = cloud.bootstrap_account(access) if access else None
        return {"ok": True, "user": data.get("user"), "session": session or None, "account": account,
                "needs_email_confirmation": not bool(access)}
    except Exception as exc:
        raise HTTPException(400, str(exc))


@app.post("/api/auth/login")
def auth_login(req: AuthLoginRequest) -> dict[str, Any]:
    try:
        data = cloud.login(req.email.strip(), req.password)
        token = str(data.get("access_token") or "")
        if not token:
            raise RuntimeError("Supabase did not return a session token.")
        account = cloud.bootstrap_account(token)
        return {"ok": True, "access_token": token, "refresh_token": data.get("refresh_token"),
                "expires_in": data.get("expires_in"), "user": data.get("user"), "account": account}
    except Exception as exc:
        raise HTTPException(401, str(exc))


@app.post("/api/auth/refresh")
def auth_refresh(req: AuthRefreshRequest) -> dict[str, Any]:
    try:
        data = cloud.refresh(req.refresh_token)
        token = str(data.get("access_token") or "")
        account = cloud.bootstrap_account(token) if token else None
        return {"ok": True, "access_token": token, "refresh_token": data.get("refresh_token"),
                "expires_in": data.get("expires_in"), "user": data.get("user"), "account": account}
    except Exception as exc:
        raise HTTPException(401, str(exc))


@app.get("/api/auth/me")
def auth_me(request: Request) -> dict[str, Any]:
    try:
        return cloud.me(_bearer_token(request), fresh=True)
    except Exception as exc:
        raise HTTPException(401, str(exc))




@app.post("/api/auth/logout")
def auth_logout(request: Request) -> dict[str, Any]:
    try:
        return cloud.logout(_bearer_token(request))
    except Exception:
        return {"ok": True}


@app.post("/api/images/upload")
def upload_local_image(payload: dict[str, Any], request: Request) -> dict[str, Any]:
    """Upload creator artwork to the shared character/scenario image library when online.

    Falls back to a local generated-images file only when cloud mode is disabled.
    """
    data_url = str(payload.get("data_url") or "")
    original = str(payload.get("filename") or "cover").strip()
    if not data_url.startswith("data:image/") or ";base64," not in data_url:
        raise HTTPException(400, "Choose a PNG, JPG/JPEG, or WEBP image.")
    header, encoded = data_url.split(",", 1)
    mime = header[5:].split(";", 1)[0].lower()
    ext_map = {"image/png": ".png", "image/jpeg": ".jpg", "image/jpg": ".jpg", "image/webp": ".webp"}
    ext = ext_map.get(mime)
    if not ext:
        raise HTTPException(400, "Only PNG, JPG/JPEG, and WEBP are supported.")
    try:
        raw = base64.b64decode(encoded, validate=True)
    except (binascii.Error, ValueError):
        raise HTTPException(400, "That image could not be decoded.")
    if len(raw) > 8 * 1024 * 1024:
        raise HTTPException(400, "Image is too large. Keep covers under 8 MB.")
    if cloud.enabled():
        try:
            return cloud.creator_image_action(_bearer_token(request),"upload",content_type=mime,data_base64=encoded,kind=str(payload.get("kind") or "creation"))
        except Exception as exc:
            raise HTTPException(400, f"Could not upload creator image: {exc}")
    stem = ''.join(ch for ch in Path(original).stem if ch.isalnum() or ch in ('-','_'))[:50] or 'cover'
    name = f"upload_{stem}_{uuid.uuid4().hex[:10]}{ext}"
    (GENERATED_IMAGE_DIR / name).write_bytes(raw)
    return {"ok": True, "url": f"/generated-images/{name}", "filename": name}


class ChatRequest(BaseModel):
    conversation_id: str | int
    content: str
    model_key: str | None = None
    # Legacy v0.8 fields are accepted but ignored in normal customer chat.
    provider: str | None = None
    model: str | None = None
    reroll_of: int | None = None
    continue_without_user: bool = False


class DirectorRequest(BaseModel):
    conversation_id: str | int
    command: str
    model_key: str | None = None


class SelectRequest(BaseModel):
    conversation_id: str | int
    message_id: str | int


class StateUpdate(BaseModel):
    conversation_id: str | int
    doc_type: str
    payload: dict[str, Any]


class ConversationSettings(BaseModel):
    persona_name: str | None = None
    scenario_name: str | None = None
    canon_mode: str | None = None
    timeline_mode: str | None = None
    reply_length: str | None = None
    initiative: str | None = None
    relationship_realism: str | None = None




class MemoryCreate(BaseModel):
    conversation_id: str | int
    title: str = "Memory"
    what_happened: str = ""
    content: str = ""
    who_remembers: list[str] = Field(default_factory=list)
    who_involved: list[str] = Field(default_factory=list)
    story_when: str = ""
    story_where: str = ""
    memory_type: str = "Other"
    importance: str = "Normal"
    emotional_weight: str = ""
    current_relevance: str = "active"
    source_kind: str = "User-created"
    original_context: dict[str, Any] = Field(default_factory=dict)
    stable_key: str | None = None
    source_message_id: str | int | None = None


class MemoryUpdate(BaseModel):
    title: str | None = None
    what_happened: str | None = None
    content: str | None = None
    who_remembers: list[str] | None = None
    who_involved: list[str] | None = None
    story_when: str | None = None
    story_where: str | None = None
    memory_type: str | None = None
    importance: str | None = None
    emotional_weight: str | None = None
    current_relevance: str | None = None
    source_kind: str | None = None
    original_context: dict[str, Any] | None = None


class WebUseRequest(BaseModel):
    conversation_id: str | int
    title: str
    summary: str = ""
    url: str = ""
    source_label: str = "Web"
    known_by: list[str] = Field(default_factory=list)


class WebWorldSaveRequest(BaseModel):
    conversation_id: str | int
    title: str
    content: str = ""
    category: str = "Research"
    url: str = ""
    source_label: str = "Web"
    notes: str = ""



class ProfileUpdate(BaseModel):
    display_name: str | None = None
    bio: str | None = None
    avatar_url: str | None = None
    location: str | None = None
    links: list[str] | None = None


class PersonaCreate(BaseModel):
    name: str
    age_text: str = ""
    pronouns: str = ""
    appearance: str = ""
    backstory: str = ""
    occupation: str = ""
    notes: str = ""
    image_url: str = ""
    is_default: bool = False




class ImageGenerateRequest(BaseModel):
    target: str = "character"
    prompt: str
    style: str = "Cinematic"
    aspect: str = "1:1"

class ChatImportPreviewRequest(BaseModel):
    source_platform: str = "Other / plain text"
    transcript: str


class ChatImportApplyRequest(BaseModel):
    source_platform: str = "Other / plain text"
    source_filename: str = ""
    transcript: str
    destination: str = "new"  # new | current
    conversation_id: str | int | None = None
    title: str = "Imported RP"
    persona_name: str = "Persona"
    user_speaker: str = ""
    analysis_mode: str = "quick"  # quick | deep
    provider: str = "mock"
    model: str | None = None


class CommunityPostCreate(BaseModel):
    post_type: str = "News"
    title: str
    body: str = ""
    image_url: str = ""
    cta_text: str = ""
    cta_url: str = ""
    status: str = "Draft"


class SupportTicketCreate(BaseModel):
    category: str = "Help"
    subject: str
    body: str = ""


@app.post("/api/images/generate")
def image_generate(req: ImageGenerateRequest, request: Request) -> dict[str, Any]:
    if not req.prompt.strip():
        raise HTTPException(400, "Describe what you want the image to show.")
    if cloud.enabled():
        try:
            return cloud.image_action(
                _bearer_token(request), "generate",
                prompt=req.prompt.strip(), style=req.style, aspect=req.aspect, target=req.target,
            )
        except Exception as exc:
            raise HTTPException(502, str(exc) or "Image generation failed") from exc
    return generate_2d_image(req.prompt.strip(), req.style, req.aspect, req.target)


@app.get("/")
def root() -> FileResponse:
    return FileResponse(STATIC / "index.html", headers={"Cache-Control":"no-store, no-cache, must-revalidate, max-age=0"})



@app.get("/api/health")
def health() -> dict[str, Any]:
    return {"ok": True, "version": "0.9.15", "product": "MelumiyaChat", "database": str(db.DB_PATH), "cloud_enabled": cloud.enabled()}




@app.get("/api/conversations")
def conversations(request: Request) -> list[dict[str, Any]]:
    if cloud.enabled():
        return cloud.action(_bearer_token(request), "list_rps")
    return db.rows("SELECT * FROM conversations ORDER BY updated_at DESC, id DESC")


@app.post("/api/conversations")
def create_conversation(payload: dict[str, Any], request: Request) -> dict[str, Any]:
    title = str(payload.get("title") or "New RP")
    persona = str(payload.get("persona_name") or "Persona")
    scenario = str(payload.get("scenario_name") or "Custom scenario")
    if cloud.enabled():
        docs = db.default_state_docs()
        scene = dict(docs.get("scene") or {})
        start_date = str(payload.get("start_date") or scene.get("start_date"))
        start_time = str(payload.get("start_time") or scene.get("start_time"))
        scene.update({"start_date": start_date, "start_time": start_time, "date": start_date, "time": start_time, "controlled_character": persona})
        docs["scene"] = scene
        return cloud.action(_bearer_token(request), "create_rp", rp={"title":title,"persona_id":payload.get("persona_id"),"persona_name":persona,"scenario_name":scenario,"canon_mode":payload.get("canon_mode") or "Selective Canon","timeline_mode":payload.get("timeline_mode") or "Current Calendar / Fixed RP Age","reply_length":payload.get("reply_length") or "Long","initiative":payload.get("initiative") or "Balanced","relationship_realism":payload.get("relationship_realism") or "Balanced"}, state_docs=docs)
    cid = db.execute("INSERT INTO conversations(title, persona_name, scenario_name) VALUES (?,?,?)", (title, persona, scenario))
    docs = db.default_state_docs()
    persona_id = payload.get("persona_id")
    if persona_id not in (None, ""):
        try:
            prow = db.row("SELECT * FROM personas WHERE id=?", (int(persona_id),))
        except Exception:
            prow = None
        if prow:
            docs["hard_canon"] = {
                **dict(docs.get("hard_canon") or {}),
                "user_persona": {
                    "name": prow.get("name") or persona,
                    "age": prow.get("age_text") or "",
                    "pronouns": prow.get("pronouns") or "",
                    "appearance": prow.get("appearance") or "",
                    "backstory": prow.get("backstory") or "",
                    "occupation": prow.get("occupation") or "",
                    "notes": prow.get("notes") or "",
                    "source": "selected-persona-snapshot",
                },
            }
    for doc_type, data in docs.items():
        if doc_type == "scene":
            data = dict(data); start_date = str(payload.get("start_date") or data.get("start_date")); start_time = str(payload.get("start_time") or data.get("start_time")); data.update({"start_date":start_date,"start_time":start_time,"date":start_date,"time":start_time,"controlled_character":persona})
        db.update_state(cid, doc_type, data)
    return db.row("SELECT * FROM conversations WHERE id=?", (cid,)) or {"id": cid}


@app.patch("/api/conversations/{conversation_id}")
def patch_conversation(conversation_id: str, settings: ConversationSettings, request: Request) -> dict[str, Any]:
    data = settings.model_dump(exclude_none=True)
    if cloud.enabled():
        return cloud.write_action(_bearer_token(request), "update_rp", id=conversation_id, patch=data)
    local_id=int(conversation_id); existing=db.row("SELECT * FROM conversations WHERE id=?",(local_id,))
    if not existing: raise HTTPException(404,"Conversation not found")
    for key,value in data.items(): db.execute(f"UPDATE conversations SET {key}=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",(value,local_id))
    return db.row("SELECT * FROM conversations WHERE id=?",(local_id,)) or {}


@app.get("/api/conversations/{conversation_id}/bundle")
def conversation_bundle(conversation_id: str, request: Request) -> dict[str, Any]:
    if cloud.enabled():
        bundle=cloud.action(_bearer_token(request), "get_rp_bundle", id=conversation_id)
        bundle.setdefault("imports",[])
        return bundle
    local_id=int(conversation_id); conv=db.row("SELECT * FROM conversations WHERE id=?",(local_id,))
    if not conv: raise HTTPException(404,"Conversation not found")
    messages=db.rows("SELECT * FROM messages WHERE conversation_id=? ORDER BY id",(local_id,)); memories=db.rows("SELECT * FROM memories WHERE conversation_id=? AND active=1 ORDER BY id DESC",(local_id,)); state=db.get_state(local_id)
    imports=db.rows("""SELECT id,source_platform,source_filename,detected_format,message_count,speaker_count,user_speaker,analysis_mode,analysis_json,created_at FROM chat_imports WHERE conversation_id=? ORDER BY id DESC""",(local_id,))
    for item in imports:
        try:item["analysis"]=json.loads(item.pop("analysis_json") or "{}")
        except Exception:item["analysis"]={}
    return {"conversation":conv,"messages":messages,"memories":memories,"state":state,"imports":imports}



def _strip_auto_state_for_message(value: Any, message_id: int) -> Any:
    if isinstance(value, list):
        return [x for item in value if (x := _strip_auto_state_for_message(item, message_id)) is not None]
    if not isinstance(value, dict):
        return value
    direct = str(value.get("source_message_id") or "") == str(message_id)
    prov = str((value.get("provenance") or {}).get("source_message_id") or "") == str(message_id) if isinstance(value.get("provenance"), dict) else False
    src = str(value.get("source") or "").lower()
    if (direct or prov) and (src.startswith("auto-rp") or direct or prov):
        return None
    out = {}
    for key, item in value.items():
        cleaned = _strip_auto_state_for_message(item, message_id)
        if cleaned is not None:
            out[key] = cleaned
    return out


@app.delete("/api/conversations/{conversation_id}/messages/{message_id}")
def delete_conversation_message(conversation_id: str, message_id: str, request: Request) -> dict[str, Any]:
    if cloud.enabled():
        try:
            return cloud.message_tools_action(_bearer_token(request), "delete_message", rp_id=conversation_id, message_id=message_id)
        except Exception as exc:
            raise HTTPException(502, str(exc) or "Could not delete message") from exc
    cid, mid = int(conversation_id), int(message_id)
    msg = db.row("SELECT * FROM messages WHERE id=? AND conversation_id=?", (mid, cid))
    if not msg:
        raise HTTPException(404, "Message not found")
    replacement = None
    if msg.get("role") == "assistant" and int(msg.get("is_selected") or 0) == 1 and msg.get("turn_group"):
        replacement = db.row("SELECT id FROM messages WHERE conversation_id=? AND role='assistant' AND turn_group=? AND id<>? ORDER BY id DESC LIMIT 1", (cid, msg.get("turn_group"), mid))
    # Preserve manual memories; only auto-created memories sourced solely from this message are archived.
    db.execute("UPDATE memories SET active=0,current_relevance='archived',updated_at=CURRENT_TIMESTAMP WHERE conversation_id=? AND source_message_id=? AND lower(source_kind)='auto-created from rp'", (cid, mid))
    for doc_type, payload in db.get_state(cid).items():
        cleaned = _strip_auto_state_for_message(payload, mid)
        db.update_state(cid, doc_type, cleaned or {})
    db.execute("DELETE FROM messages WHERE id=? AND conversation_id=?", (mid, cid))
    if replacement:
        db.execute("UPDATE messages SET is_selected=1 WHERE id=? AND conversation_id=?", (replacement["id"], cid))
    db.execute("UPDATE conversations SET updated_at=CURRENT_TIMESTAMP WHERE id=?", (cid,))
    return {"ok": True, "replacement_message_id": replacement["id"] if replacement else None}


@app.post("/api/conversations/{conversation_id}/messages/{message_id}/clone")
def clone_conversation_from_message(conversation_id: str, message_id: str, request: Request) -> dict[str, Any]:
    if cloud.enabled():
        try:
            return cloud.message_tools_action(_bearer_token(request), "clone_from_message", rp_id=conversation_id, message_id=message_id)
        except Exception as exc:
            raise HTTPException(502, str(exc) or "Could not clone RP") from exc
    cid, mid = int(conversation_id), int(message_id)
    conv = db.row("SELECT * FROM conversations WHERE id=?", (cid,))
    target = db.row("SELECT * FROM messages WHERE id=? AND conversation_id=? AND is_selected=1", (mid, cid))
    if not conv or not target:
        raise HTTPException(404, "RP/message not found on the active branch")
    title = str(conv.get("title") or "RP")
    import re as _re
    title = _re.sub(r"\\s+—\\s+Branch(?:\\s+\\d+)?$", "", title, flags=_re.I).strip() + " — Branch"
    new_id = db.execute("""INSERT INTO conversations(title,persona_name,scenario_name,canon_mode,timeline_mode,reply_length,initiative,relationship_realism) VALUES (?,?,?,?,?,?,?,?)""",
        (title[:180], conv.get("persona_name"), conv.get("scenario_name"), conv.get("canon_mode"), conv.get("timeline_mode"), conv.get("reply_length"), conv.get("initiative"), conv.get("relationship_realism")))
    try:
        for doc_type, payload in db.get_state(cid).items():
            db.update_state(new_id, doc_type, payload)
        msgs = db.rows("SELECT * FROM messages WHERE conversation_id=? AND is_selected=1 AND id<=? ORDER BY id", (cid, mid))
        id_map: dict[int, int] = {}
        for m in msgs:
            nid = db.execute("""INSERT INTO messages(conversation_id,role,content,turn_group,is_selected,model_provider,model_name,parent_message_id,story_date,story_time,speaker_name,source_timestamp,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (new_id,m.get("role"),m.get("content"),m.get("turn_group"),1,m.get("model_provider"),m.get("model_name"),None,m.get("story_date"),m.get("story_time"),m.get("speaker_name"),m.get("source_timestamp"),m.get("created_at")))
            id_map[int(m["id"])] = int(nid)
        memories = db.rows("SELECT * FROM memories WHERE conversation_id=? AND active=1 AND (source_message_id IS NULL OR source_message_id<=?) ORDER BY id", (cid, mid))
        for m in memories:
            src = id_map.get(int(m["source_message_id"])) if m.get("source_message_id") is not None else None
            db.execute("""INSERT INTO memories(conversation_id,memory_type,title,content,importance,source_message_id,confidence,active,what_happened,who_remembers,who_involved,story_when,story_where,emotional_weight,current_relevance,source_kind,original_context,stable_key,revision_history,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (new_id,m.get("memory_type"),m.get("title"),m.get("content"),m.get("importance"),src,m.get("confidence"),m.get("active"),m.get("what_happened"),m.get("who_remembers"),m.get("who_involved"),m.get("story_when"),m.get("story_where"),m.get("emotional_weight"),m.get("current_relevance"),m.get("source_kind"),m.get("original_context"),m.get("stable_key"),m.get("revision_history"),m.get("created_at"),m.get("updated_at")))
        return {"ok": True, "rp_id": new_id, "conversation_id": new_id, "messages_copied": len(msgs)}
    except Exception:
        db.execute("DELETE FROM conversations WHERE id=?", (new_id,))
        raise



LOCAL_USER_HANDLE = "you"

def _local_user() -> dict[str, Any]:
    row = db.row("SELECT * FROM user_profiles WHERE handle=?", (LOCAL_USER_HANDLE,))
    if not row:
        raise HTTPException(500, "Local prototype user is missing")
    # Prototype reward access can expire. Real paid entitlements will later be server/Stripe authoritative.
    expires = row.get("plan_expires_at")
    if expires and str(row.get("plan_source") or "").startswith("reward:"):
        try:
            dt = datetime.fromisoformat(str(expires).replace("Z", "+00:00"))
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            if dt <= datetime.now(timezone.utc):
                previous = row.get("plan_previous_tier") or "Free"
                db.execute("UPDATE user_profiles SET plan_tier=?,plan_expires_at=NULL,plan_source='',updated_at=CURRENT_TIMESTAMP WHERE id=?", (previous,row["id"]))
                row = db.row("SELECT * FROM user_profiles WHERE id=?", (row["id"],))
        except Exception:
            pass
    return row






def _active_discount_grant(user_id: int) -> dict[str, Any] | None:
    row = db.row(
        """SELECT * FROM owner_user_grants WHERE user_id=? AND benefit_type='Discount' AND active=1
           ORDER BY CASE WHEN expires_at IS NULL OR expires_at='' THEN 0 ELSE 1 END, id DESC LIMIT 1""",
        (user_id,),
    )
    if not row:
        return None
    exp = row.get('expires_at')
    if exp:
        try:
            dt = datetime.fromisoformat(str(exp).replace('Z', '+00:00'))
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            if dt <= datetime.now(timezone.utc):
                db.execute("UPDATE owner_user_grants SET active=0,updated_at=CURRENT_TIMESTAMP WHERE id=?", (row['id'],))
                return None
        except Exception:
            pass
    return row




def _public_profile_payload(profile: dict[str, Any]) -> dict[str, Any]:
    item = dict(profile)
    try:
        item["links"] = json.loads(item.pop("links_json", "[]") or "[]")
    except Exception:
        item["links"] = []
    creations = db.rows(
        "SELECT * FROM creator_content WHERE creator_name=? AND status='Approved' AND visibility='Public' ORDER BY updated_at DESC, id DESC",
        (item["display_name"],),
    )
    item["creations"] = [_decode_content_row(x) for x in creations]
    item["creation_count"] = len(creations)
    item["favorite_count"] = db.row(
        "SELECT COUNT(*) AS n FROM favorites f JOIN creator_content c ON c.id=f.content_id WHERE c.creator_name=?",
        (item["display_name"],),
    )["n"]
    return item


@app.get("/api/home")
def home_feed(request: Request) -> dict[str, Any]:
    try:
        posts = db.rows("SELECT * FROM community_posts WHERE status='Published' ORDER BY COALESCE(published_at,created_at) DESC, id DESC")
    except Exception:
        # Home news is optional local presentation data. It must never block cloud navigation.
        posts = []
    if cloud.enabled():
        try:
            featured=cloud.action(_bearer_token(request),'list_public_content',item_type='scenario')[:6]
        except Exception:
            featured=[]
    else:
        featured = [_decode_content_row(x) for x in db.rows(
            "SELECT * FROM creator_content WHERE item_type='scenario' AND status='Approved' AND visibility='Public' ORDER BY updated_at DESC, id DESC LIMIT 6"
        )]
    return {"posts": posts, "featured_scenarios": featured}


@app.get("/api/models/available")
def customer_model_access(request: Request) -> dict[str, Any]:
    if cloud.enabled(): return cloud.model_access_action(_bearer_token(request),"status")
    p=_local_user();return {"plan_tier":p["plan_tier"],"models":public_models(p["plan_tier"]),"usage":local_generation_usage(p["id"],p["plan_tier"]),"rule":"Every AI-generated reply counts, including rerolls and Continue Scene. Resets at 00:00 UTC."}




@app.get("/api/profile/me")
def my_profile(request: Request) -> dict[str, Any]:
    if cloud.enabled():
        token=_bearer_token(request); acct=cloud.me(token)
        persona_data=cloud.action(token,"list_personas"); drafts=cloud.write_action(token,"list_my_content")
        try: billing=cloud.billing_action(token,"status")
        except Exception: billing={}
        profile=dict(acct.get("profile") or {}); ent=(billing.get("entitlement") or acct.get("entitlement") or {})
        profile.update({"id":acct.get("user",{}).get("id"),"email":acct.get("user",{}).get("email"),"handle":profile.get("handle") or "you","plan_tier":ent.get("plan_tier") or "Free","plan_expires_at":ent.get("plan_expires_at"),"plan_source":ent.get("source") or "signup","personal_discount_offer":billing.get("personal_discount_offer"),"personas":persona_data.get("items",[]),"persona_limit":persona_data.get("limit"),"persona_count":persona_data.get("used",0),"draft_creations":drafts,"creation_count":sum(1 for x in drafts if x.get("status") in {"Approved","Published"} and x.get("visibility")=="Public"),"favorite_count":0,"role":acct.get("role","User")})
        return profile
    profile = _local_user(); out = _public_profile_payload(profile); out["personas"] = db.rows("SELECT * FROM personas WHERE user_id=? ORDER BY is_default DESC, id", (profile["id"],)); out["persona_limit"] = persona_limit_for(profile["plan_tier"]); out["draft_creations"] = [_decode_content_row(x) for x in db.rows("SELECT * FROM creator_content WHERE creator_name=? ORDER BY updated_at DESC,id DESC", (profile["display_name"],))]; out["personal_discount_offer"] = _active_discount_grant(profile["id"]); return out


@app.patch("/api/profile/me")
def update_my_profile(req: ProfileUpdate, request: Request) -> dict[str, Any]:
    if cloud.enabled():
        cloud.action(_bearer_token(request),"update_profile",profile=req.model_dump(exclude_none=True)); return my_profile(request)
    profile = _local_user(); data = req.model_dump(exclude_none=True)
    if "links" in data: data["links_json"] = json.dumps(data.pop("links"))
    for key,value in data.items():
        if key in {"display_name","bio","avatar_url","location","links_json"}: db.execute(f"UPDATE user_profiles SET {key}=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (value, profile["id"]))
    return my_profile(request)


@app.get("/api/profiles/search")
def search_profiles(request: Request, q: str = "") -> list[dict[str, Any]]:
    if cloud.enabled():
        return cloud.public_action(_bearer_token(request),'search_profiles',q=q)
    query = f"%{q.strip()}%"
    rows = db.rows("SELECT * FROM user_profiles WHERE is_creator=1 AND (display_name LIKE ? OR handle LIKE ? OR bio LIKE ?) ORDER BY display_name LIMIT 50",(query, query, query))
    return [_public_profile_payload(x) for x in rows]


@app.get("/api/profiles/{creator}")
def creator_profile(creator: str, request: Request) -> dict[str, Any]:
    if cloud.enabled():
        return cloud.public_action(_bearer_token(request),'creator_profile',creator=creator)
    row = db.row("SELECT * FROM user_profiles WHERE lower(display_name)=lower(?) OR lower(handle)=lower(?)", (creator, creator.lstrip('@')))
    if not row:
        exists = db.row("SELECT creator_name FROM creator_content WHERE lower(creator_name)=lower(?) LIMIT 1", (creator,))
        if not exists: raise HTTPException(404, "Creator not found")
        return {"handle":"", "display_name":exists["creator_name"], "bio":"", "avatar_url":"", "location":"", "links":[],"creations":[_decode_content_row(x) for x in db.rows("SELECT * FROM creator_content WHERE creator_name=? AND status='Approved' AND visibility='Public' ORDER BY updated_at DESC", (exists["creator_name"],))]}
    return _public_profile_payload(row)


@app.get("/api/personas")
def personas(request: Request) -> dict[str, Any]:
    if cloud.enabled():
        d=cloud.action(_bearer_token(request),"list_personas"); used=int(d.get("used",len(d.get("items",[])))); lim=d.get("limit"); return {"items":d.get("items",[]),"plan_tier":d.get("plan_tier","Free"),"limit":lim,"count":used,"remaining":None if lim is None else max(0,int(lim)-used),"limits":PERSONA_LIMITS}
    profile=_local_user(); items=db.rows("SELECT * FROM personas WHERE user_id=? ORDER BY is_default DESC, id",(profile["id"],)); limit=persona_limit_for(profile["plan_tier"]); return {"items":items,"plan_tier":profile["plan_tier"],"limit":limit,"count":len(items),"remaining":None if limit is None else max(0,limit-len(items)),"limits":PERSONA_LIMITS}


@app.post("/api/personas")
def create_persona(req: PersonaCreate, request: Request) -> dict[str, Any]:
    if cloud.enabled():
        try:return cloud.action(_bearer_token(request),"create_persona",persona=req.model_dump())
        except Exception as exc: raise HTTPException(403,str(exc))
    profile=_local_user(); count=db.row("SELECT COUNT(*) AS n FROM personas WHERE user_id=?",(profile["id"],))["n"]; limit=persona_limit_for(profile["plan_tier"])
    if limit is not None and count>=limit: raise HTTPException(403,f"{profile['plan_tier']} plan supports up to {limit} personas. Delete one or upgrade to add more.")
    if req.is_default: db.execute("UPDATE personas SET is_default=0 WHERE user_id=?",(profile["id"],))
    pid=db.execute("INSERT INTO personas(user_id,name,age_text,pronouns,appearance,backstory,occupation,notes,image_url,is_default) VALUES (?,?,?,?,?,?,?,?,?,?)",(profile["id"],req.name.strip() or "Persona",req.age_text,req.pronouns,req.appearance,req.backstory,req.occupation,req.notes,req.image_url,int(req.is_default))); return db.row("SELECT * FROM personas WHERE id=?",(pid,)) or {"id":pid}


@app.patch("/api/personas/{persona_id}")
def update_persona(persona_id: str, req: PersonaCreate, request: Request) -> dict[str, Any]:
    if cloud.enabled(): return cloud.action(_bearer_token(request),"update_persona",id=persona_id,persona=req.model_dump())
    local_id=int(persona_id);profile=_local_user();row=db.row("SELECT * FROM personas WHERE id=? AND user_id=?",(local_id,profile["id"]));
    if not row: raise HTTPException(404,"Persona not found")
    if req.is_default: db.execute("UPDATE personas SET is_default=0 WHERE user_id=?",(profile["id"],))
    db.execute("""UPDATE personas SET name=?,age_text=?,pronouns=?,appearance=?,backstory=?,occupation=?,notes=?,image_url=?,is_default=?,updated_at=CURRENT_TIMESTAMP WHERE id=?""",(req.name.strip() or "Persona",req.age_text,req.pronouns,req.appearance,req.backstory,req.occupation,req.notes,req.image_url,int(req.is_default),local_id)); return db.row("SELECT * FROM personas WHERE id=?",(local_id,)) or {}


@app.delete("/api/personas/{persona_id}")
def delete_persona(persona_id: str, request: Request) -> dict[str, Any]:
    if cloud.enabled(): return cloud.action(_bearer_token(request),"delete_persona",id=persona_id)
    profile=_local_user(); db.execute("DELETE FROM personas WHERE id=? AND user_id=?",(int(persona_id),profile["id"])); return {"ok":True}


@app.get("/api/favorites")
def favorites(request: Request) -> list[dict[str, Any]]:
    if cloud.enabled():
        token=_bearer_token(request); refs=cloud.action(token,"list_favorites") or []; wanted={str(x.get("content_id")) for x in refs}
        if not wanted:return []
        content=(cloud.action(token,"list_public_content",item_type="scenario") or [])+(cloud.action(token,"list_public_content",item_type="character") or [])
        return [x for x in content if str(x.get("id")) in wanted]
    profile=_local_user(); rows=db.rows("""SELECT c.* FROM favorites f JOIN creator_content c ON c.id=f.content_id WHERE f.user_id=? ORDER BY f.id DESC""",(profile["id"],)); return [_decode_content_row(x) for x in rows]


@app.post("/api/favorites/{content_id}")
def add_favorite(content_id: str, request: Request) -> dict[str, Any]:
    if cloud.enabled(): cloud.action(_bearer_token(request),"favorite",content_id=content_id); return {"ok":True,"favorite":True}
    profile=_local_user(); local_id=int(content_id)
    if not db.row("SELECT id FROM creator_content WHERE id=?",(local_id,)): raise HTTPException(404,"Content not found")
    try:db.execute("INSERT INTO favorites(user_id,content_id) VALUES (?,?)",(profile["id"],local_id))
    except Exception:pass
    return {"ok":True,"favorite":True}


@app.delete("/api/favorites/{content_id}")
def remove_favorite(content_id: str, request: Request) -> dict[str, Any]:
    if cloud.enabled(): cloud.action(_bearer_token(request),"unfavorite",content_id=content_id); return {"ok":True,"favorite":False}
    profile=_local_user(); db.execute("DELETE FROM favorites WHERE user_id=? AND content_id=?",(profile["id"],int(content_id))); return {"ok":True,"favorite":False}


@app.post("/api/help/tickets")
def create_support_ticket(req: SupportTicketCreate, request: Request) -> dict[str, Any]:
    if cloud.enabled():
        return cloud.support_action(_bearer_token(request), "create", category=req.category, subject=req.subject, body=req.body)
    profile=_local_user()
    tid=db.execute("INSERT INTO support_tickets(user_id,category,subject,body) VALUES (?,?,?,?)",(profile["id"],req.category,req.subject,req.body))
    return db.row("SELECT * FROM support_tickets WHERE id=?",(tid,)) or {"id":tid}


@app.get("/api/help/tickets")
def my_support_tickets(request: Request) -> dict[str, Any]:
    if cloud.enabled():
        return cloud.support_action(_bearer_token(request), "list_mine")
    profile=_local_user()
    return {"items":db.rows("SELECT * FROM support_tickets WHERE user_id=? ORDER BY id DESC",(profile["id"],))}


@app.get("/api/help/tickets/{ticket_id}")
def my_support_ticket(ticket_id: str, request: Request) -> dict[str, Any]:
    if cloud.enabled():
        return cloud.support_action(_bearer_token(request), "get", id=ticket_id)
    profile=_local_user(); row=db.row("SELECT * FROM support_tickets WHERE id=? AND user_id=?",(ticket_id,profile["id"]))
    if not row: raise HTTPException(404,"Ticket not found")
    return {"case":row,"messages":[{"author_role":"customer","body":row.get("body") or "","created_at":row.get("created_at")}] }


@app.post("/api/help/tickets/{ticket_id}/reply")
def reply_my_support_ticket(ticket_id: str, payload: dict[str, Any], request: Request) -> dict[str, Any]:
    body=str(payload.get("body") or "").strip()
    if not body: raise HTTPException(400,"Reply cannot be empty.")
    if cloud.enabled():
        return cloud.support_action(_bearer_token(request), "reply", id=ticket_id, body=body)
    raise HTTPException(503,"Two-way support replies require cloud mode.")
















DISCOVER_TAGS = [
    "Canon", "AU", "Everyone Lives", "Original",
    "Strangers", "Friends", "Dating", "Engaged", "Married", "Exes", "Enemies", "Family", "Coworkers", "Reunion", "Slow Burn",
    "Romance", "Cozy", "Slice of Life", "Angst", "Drama", "Comedy", "Action", "Adventure", "Dark",
    "Military", "Fantasy", "Sci-Fi", "Modern", "Historical", "High School", "Workplace", "Team", "Worldbuilding",
    "SFW", "NSFW",
]


def _decode_content_row(row: dict[str, Any]) -> dict[str, Any]:
    item = dict(row)
    try:
        item["tags"] = json.loads(item.pop("tags_json", "[]") or "[]")
    except Exception:
        item["tags"] = []
    try:
        item["payload"] = json.loads(item.pop("payload_json", "{}") or "{}")
    except Exception:
        item["payload"] = {}
    return item


@app.get("/api/discover/tags")
def discover_tags() -> list[str]:
    return DISCOVER_TAGS


@app.get("/api/canon/search")
async def canon_search(name: str, franchise: str = "") -> dict[str, Any]:
    try:
        return await search_canon(name=name, franchise=franchise)
    except ValueError as exc:
        raise HTTPException(400, str(exc)) from exc
    except Exception as exc:
        raise HTTPException(502, f"Canon research could not reach its search sources: {type(exc).__name__}") from exc


@app.get("/api/web/search")
async def general_web_search(q: str, request: Request) -> dict[str, Any]:
    try:
        if cloud.enabled():
            return cloud.web_search_action(_bearer_token(request), q)
        return await search_web(q)
    except ValueError as exc:
        raise HTTPException(400, str(exc)) from exc
    except Exception as exc:
        raise HTTPException(502, f"Web research could not reach its search sources: {type(exc).__name__}") from exc



@app.post("/api/creator/character-autofill")
def creator_character_autofill(payload: dict[str, Any], request: Request) -> dict[str, Any]:
    name = str(payload.get("name") or "").strip()
    source = str(payload.get("source") or "").strip()
    if not name:
        raise HTTPException(400, "Character name is required")
    try:
        existing = payload.get("existing") if isinstance(payload.get("existing"), dict) else {}
        action_name = str(payload.get("action") or "autofill").strip()
        selected_source_url = str(payload.get("selected_source_url") or "").strip()
        return cloud.character_autofill_action(_bearer_token(request), name, source, existing, action_name, selected_source_url)
    except Exception as exc:
        raise HTTPException(502, f"Character web autofill failed: {exc}") from exc


@app.post("/api/creator/scenario-autofill")
def creator_scenario_autofill(payload: dict[str, Any], request: Request) -> dict[str, Any]:
    title = str(payload.get("title") or "").strip()
    source = str(payload.get("source") or "").strip()
    main_character = str(payload.get("main_character") or "").strip()
    if not title and not source:
        raise HTTPException(400, "Scenario title or source / franchise is required")
    try:
        return cloud.scenario_autofill_action(_bearer_token(request), title, source, main_character, payload.get("existing") if isinstance(payload.get("existing"), dict) else {})
    except Exception as exc:
        raise HTTPException(502, f"Scenario web autofill failed: {exc}") from exc


def _append_unique_named(existing: list[Any], incoming: list[Any], key: str = "name") -> list[Any]:
    out = list(existing or [])
    seen = {str((x.get(key) if isinstance(x, dict) else x) or "").strip().lower() for x in out}
    for item in incoming or []:
        value = str((item.get(key) if isinstance(item, dict) else item) or "").strip().lower()
        if value and value not in seen:
            out.append(item)
            seen.add(value)
    return out


def _merge_character_records(existing: list[Any], incoming: list[Any]) -> list[Any]:
    """Merge imported character evidence by name without overwriting user edits.

    Missing fields are filled, list-like personality fields are unioned, and existing explicit
    user values stay authoritative.
    """
    out = [dict(x) if isinstance(x, dict) else x for x in (existing or [])]
    positions = {str(x.get("name") or x.get("title") or "").strip().lower(): i for i, x in enumerate(out) if isinstance(x, dict)}
    for item in incoming or []:
        if not isinstance(item, dict):
            continue
        name = str(item.get("name") or item.get("title") or "").strip()
        if not name:
            continue
        key = name.lower()
        if key not in positions:
            positions[key] = len(out)
            out.append(dict(item))
            continue
        idx = positions[key]
        current = dict(out[idx])
        for field, value in item.items():
            if field in {"personality", "mannerisms", "personality_evidence"}:
                old = current.get(field) or []
                old = old if isinstance(old, list) else [old]
                new = value if isinstance(value, list) else ([value] if value else [])
                merged = []
                seen = set()
                for entry in old + new:
                    token = json.dumps(entry, sort_keys=True, ensure_ascii=False) if isinstance(entry, (dict, list)) else str(entry)
                    if token not in seen:
                        seen.add(token); merged.append(entry)
                current[field] = merged
            elif field not in current or current.get(field) in (None, "", [], {}):
                current[field] = value
        out[idx] = current
    return out


def _import_analysis_docs(state: dict[str, Any], analysis: dict[str, Any], import_meta: dict[str, Any]) -> dict[str, Any]:
    hard = dict(state.get("hard_canon") or {})
    relationship = dict(state.get("relationship") or {})
    knowledge = dict(state.get("knowledge") or {})
    scene = dict(state.get("scene") or {})
    physical = dict(state.get("physical") or {})
    world = dict(state.get("world_bible") or {})

    status = analysis.get("relationship_status")
    if status:
        hard["relationship_status"] = status
        relationship["phase"] = status
    notes = list(hard.get("notes") or [])
    for note in analysis.get("hard_canon_notes") or []:
        if note not in notes:
            notes.append(note)
    hard["notes"] = notes

    world["characters"] = _merge_character_records(world.get("characters") or [], analysis.get("characters") or [])
    world["locations"] = _append_unique_named(world.get("locations") or [], analysis.get("locations") or [])
    world["custom_entries"] = list(world.get("custom_entries") or []) + list(analysis.get("custom_entries") or [])
    world["timeline"] = list(world.get("timeline") or []) + list(analysis.get("timeline") or [])
    world["import_review"] = list(world.get("import_review") or []) + list(analysis.get("review") or [])
    history = list(world.get("import_history") or [])
    history.append(import_meta)
    world["import_history"] = history

    # AI enrichment may add richer candidates, but never silently writes secrets/relationship claims as hard truth.
    ai = analysis.get("ai_suggestions") if isinstance(analysis.get("ai_suggestions"), dict) else {}
    ai_chars = []
    for x in ai.get("characters") or []:
        if isinstance(x, str):
            ai_chars.append({"name": x, "role": "Imported participant", "source": "AI-assisted import", "confidence": 0.6})
        elif isinstance(x, dict) and (x.get("name") or x.get("title")):
            ai_chars.append({"name": x.get("name") or x.get("title"), "role": x.get("role") or "Imported participant", "summary": x.get("summary") or x.get("facts") or x.get("evidence") or "", "personality": x.get("personality") or x.get("traits") or [], "speech_style": x.get("speech_style") or "", "affection_style": x.get("affection_style") or "", "conflict_style": x.get("conflict_style") or "", "emotional_expression": x.get("emotional_expression") or "", "mannerisms": x.get("mannerisms") or [], "relationship_specific": x.get("relationship_specific") or "", "public_private": x.get("public_private") or "", "source": "AI-assisted import", "confidence": x.get("confidence", 0.6)})
    world["characters"] = _merge_character_records(world.get("characters") or [], ai_chars)

    ai_profiles = []
    for x in ai.get("personality_profiles") or []:
        if not isinstance(x, dict) or not (x.get("name") or x.get("title")):
            continue
        ai_profiles.append({
            "name": x.get("name") or x.get("title"),
            "personality": x.get("personality") or x.get("traits") or [],
            "speech_style": x.get("speech_style") or "",
            "affection_style": x.get("affection_style") or "",
            "conflict_style": x.get("conflict_style") or "",
            "emotional_expression": x.get("emotional_expression") or "",
            "mannerisms": x.get("mannerisms") or [],
            "relationship_specific": x.get("relationship_specific") or "",
            "public_private": x.get("public_private") or "",
            "personality_evidence": ([{"signal":"AI enrichment", "excerpt":x.get("evidence") or "", "confidence":x.get("confidence",0.6)}] if x.get("evidence") else []),
            "personality_confidence": x.get("confidence", 0.6),
            "personality_source": "AI-assisted import personality enrichment",
            "personality_status": "editable AI-assisted draft",
        })
    world["characters"] = _merge_character_records(world.get("characters") or [], ai_profiles)
    ai_locs = []
    for x in ai.get("locations") or []:
        if isinstance(x, str):
            ai_locs.append({"name": x, "source": "AI-assisted import", "confidence": 0.6})
        elif isinstance(x, dict) and (x.get("name") or x.get("title")):
            ai_locs.append({"name": x.get("name") or x.get("title"), "description": x.get("description") or x.get("evidence") or "", "source": "AI-assisted import", "confidence": x.get("confidence", 0.6)})
    world["locations"] = _append_unique_named(world.get("locations") or [], ai_locs)
    for x in ai.get("timeline") or []:
        if isinstance(x, str):
            world["timeline"].append({"event": x, "source": "AI-assisted import", "confidence": 0.6})
        elif isinstance(x, dict):
            world["timeline"].append({"event": x.get("event") or x.get("title") or x.get("evidence") or "Imported event", "date": x.get("date") or "", "participants": x.get("participants") or "", "source": "AI-assisted import", "confidence": x.get("confidence", 0.6)})

    sc = analysis.get("scene") or {}
    if sc.get("date"):
        scene["date"] = sc["date"]
        scene["start_date"] = sc["date"]
    if sc.get("time"):
        scene["time"] = sc["time"]
        scene["start_time"] = sc["time"]
    if sc.get("present"):
        scene["present"] = sc["present"]
    if sc.get("controlled_character"):
        scene["controlled_character"] = sc["controlled_character"]
    if not scene.get("location") or scene.get("location") == "Unset":
        scene["location"] = sc.get("location") or "Imported RP — review current location"
    scene["story_started"] = True
    scene["last_advance_reason"] = "Story state reconstructed from imported chat history."

    for note in analysis.get("physical_notes") or []:
        physical.setdefault("continuity_notes", [])
        if note not in physical["continuity_notes"]:
            physical["continuity_notes"].append(note)

    return {
        **state,
        "hard_canon": hard,
        "relationship": relationship,
        "knowledge": knowledge,
        "scene": scene,
        "physical": physical,
        "world_bible": world,
    }


def _apply_import_analysis(conversation_id: int, analysis: dict[str, Any], import_meta: dict[str, Any]) -> None:
    merged=_import_analysis_docs(db.get_state(conversation_id), analysis, import_meta)
    for doc_type in ("hard_canon","relationship","knowledge","scene","physical","world_bible"):
        db.update_state(conversation_id, doc_type, merged[doc_type])


@app.post("/api/import/preview")
def import_preview(req: ChatImportPreviewRequest) -> dict[str, Any]:
    if not req.transcript.strip():
        raise HTTPException(400, "Paste a transcript or choose an export file first.")
    result = parse_transcript(req.transcript, req.source_platform)
    return {k: v for k, v in result.items() if k != "messages"}


@app.post("/api/import/apply")
def import_apply(req: ChatImportApplyRequest, request: Request) -> dict[str, Any]:
    if not req.transcript.strip():
        raise HTTPException(400, "Paste a transcript or choose an export file first.")
    parsed = parse_transcript(req.transcript, req.source_platform)
    if not parsed.get("messages"):
        raise HTTPException(400, "No readable chat messages were found in that import.")

    user_speaker = (req.user_speaker or req.persona_name or "You").strip()
    base = heuristic_analysis(parsed, user_speaker, req.persona_name or user_speaker)
    deep = None
    if req.analysis_mode == "deep":
        deep = deep_analysis(parsed, req.provider, req.model, user_speaker, req.persona_name or user_speaker)
    analysis = merge_analysis(base, deep)

    if cloud.enabled():
        token=_bearer_token(request)
        if req.destination == "current":
            if not req.conversation_id:
                raise HTTPException(404,"Choose an existing RP to import into.")
            bundle=cloud.action(token,'get_rp_bundle',id=str(req.conversation_id))
            state=dict(bundle.get('state') or {})
            cid=str(req.conversation_id)
            new_rp=None
        else:
            state=db.default_state_docs()
            cid=''
            new_rp={"title":req.title.strip() or "Imported RP","persona_name":req.persona_name.strip() or user_speaker,"scenario_name":f"Imported from {req.source_platform}","canon_mode":"Selective Canon"}
        import_meta={"import_id":"__IMPORT_ID__","platform":req.source_platform,"filename":req.source_filename,"format":parsed.get("format"),"message_count":int(parsed.get("message_count") or 0),"speaker_count":len(parsed.get("speakers") or []),"analysis":analysis.get("engine"),"editable":True}
        merged=_import_analysis_docs(state,analysis,import_meta)
        msgs=[]
        for idx,m in enumerate(parsed["messages"],1):
            speaker=str(m.get("speaker") or "Imported speaker").strip(); hint=str(m.get("role_hint") or "").lower()
            role="user" if (speaker.lower()==user_speaker.lower() or hint=="user") else ("system" if hint=="system" else "assistant")
            msgs.append({"role":role,"content":str(m.get("text") or ""),"turn_group":f"import-pending-{idx}","model_provider":f"import:{req.source_platform.lower().replace(' ','-')}","model_name":speaker if role!="user" else "imported-user","speaker_name":speaker,"source_timestamp":str(m.get("timestamp") or "")})
        result=cloud.import_action(token,'apply_import',rp_id=cid or None,new_rp=new_rp,import_meta={"source_platform":req.source_platform,"source_filename":req.source_filename,"detected_format":parsed.get("format") or "","raw_text":req.transcript,"message_count":len(msgs),"speaker_count":len(parsed.get("speakers") or []),"user_speaker":user_speaker,"analysis_mode":"Deep AI scan" if req.analysis_mode=="deep" else "Quick local scan","analysis":analysis},messages=msgs,state_docs={k:v for k,v in merged.items() if k in db.default_state_docs()},memory={"memory_type":"import","title":"Imported RP history","content":f"Imported {len(msgs)} messages from {req.source_platform}. The full transcript remains source evidence; extracted Bible facts stay editable.","importance":"Hard Canon"})
        actual_id=str(result.get('import_id') or '')
        if actual_id:
            world=dict(merged.get('world_bible') or {})
            history=[]
            for item in world.get('import_history') or []:
                item=dict(item); item['import_id']=actual_id if item.get('import_id')=='__IMPORT_ID__' else item.get('import_id'); history.append(item)
            world['import_history']=history
            cloud.action(token,'save_state',rp_id=str(result['conversation_id']),doc_type='world_bible',payload=world)
        return {**result,"speakers":parsed.get("speakers") or [],"analysis":analysis,"warnings":parsed.get("warnings") or [],"deep_analysis_used":bool(deep)}

    if req.destination == "current":
        if not req.conversation_id or not db.row("SELECT id FROM conversations WHERE id=?", (req.conversation_id,)):
            raise HTTPException(404, "Choose an existing RP to import into.")
        cid = int(req.conversation_id)
    else:
        cid = db.execute("INSERT INTO conversations(title, persona_name, scenario_name) VALUES (?,?,?)",(req.title.strip() or "Imported RP", req.persona_name.strip() or user_speaker, f"Imported from {req.source_platform}"))
        for doc_type, data in db.default_state_docs().items(): db.update_state(cid, doc_type, data)
    import_id = db.execute("""INSERT INTO chat_imports(conversation_id,source_platform,source_filename,detected_format,raw_text,message_count,speaker_count,user_speaker,analysis_mode,analysis_json) VALUES (?,?,?,?,?,?,?,?,?,?)""",(cid, req.source_platform, req.source_filename, parsed.get("format") or "", req.transcript,int(parsed.get("message_count") or 0), len(parsed.get("speakers") or []), user_speaker,"Deep AI scan" if req.analysis_mode == "deep" else "Quick local scan", json.dumps(analysis, ensure_ascii=False)))
    inserted = 0
    for idx, m in enumerate(parsed["messages"], 1):
        speaker = str(m.get("speaker") or "Imported speaker").strip(); hint = str(m.get("role_hint") or "").lower(); role = "user" if (speaker.lower() == user_speaker.lower() or hint == "user") else ("system" if hint == "system" else "assistant")
        db.execute("""INSERT INTO messages(conversation_id,role,content,turn_group,is_selected,model_provider,model_name,speaker_name,source_timestamp) VALUES (?,?,?,?,1,?,?,?,?)""",(cid, role, str(m.get("text") or ""), f"import-{import_id}-{idx}", f"import:{req.source_platform.lower().replace(' ','-')}", speaker if role != "user" else "imported-user", speaker, str(m.get("timestamp") or ""))); inserted += 1
    meta = {"import_id": import_id,"platform": req.source_platform,"filename": req.source_filename,"format": parsed.get("format"),"message_count": inserted,"speaker_count": len(parsed.get("speakers") or []),"analysis": analysis.get("engine"),"editable": True}
    _apply_import_analysis(cid, analysis, meta)
    db.execute("INSERT INTO memories(conversation_id,memory_type,title,content,importance) VALUES (?,?,?,?,?)",(cid, "import", "Imported RP history", f"Imported {inserted} messages from {req.source_platform}. The full transcript remains source evidence; extracted Bible facts stay editable.", "Hard Canon"))
    db.execute("UPDATE conversations SET updated_at=CURRENT_TIMESTAMP WHERE id=?", (cid,))
    return {"ok": True,"conversation_id": cid,"import_id": import_id,"messages_imported": inserted,"speakers": parsed.get("speakers") or [],"analysis": analysis,"warnings": parsed.get("warnings") or [],"deep_analysis_used": bool(deep)}


@app.get("/api/imports/{import_id}")
def import_detail(import_id: str, request: Request) -> dict[str, Any]:
    if cloud.enabled():
        row=cloud.import_action(_bearer_token(request),'get_import',id=import_id)
        row['analysis']=row.get('analysis') or {}
        return row
    row = db.row("SELECT * FROM chat_imports WHERE id=?", (int(import_id),))
    if not row: raise HTTPException(404, "Import not found")
    try: row["analysis"] = json.loads(row.pop("analysis_json") or "{}")
    except Exception: row["analysis"] = {}
    return row


@app.post("/api/web/use-in-rp")
def use_web_result_in_rp(req: WebUseRequest, request: Request) -> dict[str, Any]:
    if cloud.enabled():
        token=_bearer_token(request); bundle=cloud.action(token,'get_rp_bundle',id=str(req.conversation_id)); conv=bundle.get('conversation') or {}; state=bundle.get('state') or {}
        if not conv: raise HTTPException(404,'RP not found')
        knowledge=dict(state.get('knowledge') or {}); facts=list(knowledge.get('facts') or []); scene=dict(state.get('scene') or {})
        known_by=[x.strip() for x in req.known_by if x and x.strip()] or [str(scene.get('controlled_character') or conv.get('persona_name') or 'User character')]
        fact_text=req.title.strip() + (f": {req.summary.strip()}" if req.summary.strip() else '')
        facts.append({'fact':fact_text,'status':'researched','known_by':known_by,'hidden_from':[],'source':{'label':req.source_label,'url':req.url},'how_learned':'web search during RP','acquired_story_date':scene.get('date'),'acquired_story_time':scene.get('time')})
        knowledge['facts']=facts; cloud.action(token,'save_state',rp_id=str(req.conversation_id),doc_type='knowledge',payload=knowledge)
        return {'ok':True,'known_by':known_by,'fact':facts[-1]}
    conv = db.row("SELECT * FROM conversations WHERE id=?", (req.conversation_id,))
    if not conv: raise HTTPException(404, "RP not found")
    state = db.get_state(req.conversation_id); knowledge = dict(state.get("knowledge") or {}); facts = list(knowledge.get("facts") or []); scene = dict(state.get("scene") or {})
    known_by = [x.strip() for x in req.known_by if x and x.strip()] or [str(scene.get("controlled_character") or conv.get("persona_name") or "User character")]
    fact_text = req.title.strip() + (f": {req.summary.strip()}" if req.summary.strip() else "")
    facts.append({"fact": fact_text,"status": "researched","known_by": known_by,"hidden_from": [],"source": {"label": req.source_label, "url": req.url},"how_learned": "web search during RP","acquired_story_date": scene.get("date"),"acquired_story_time": scene.get("time")})
    knowledge["facts"] = facts; db.update_state(req.conversation_id, "knowledge", knowledge)
    return {"ok": True, "known_by": known_by, "fact": facts[-1]}


@app.post("/api/web/save-to-world")
def save_web_result_to_world(req: WebWorldSaveRequest, request: Request) -> dict[str, Any]:
    item = {"title": req.title.strip() or "Web research","content": req.content.strip(),"category": req.category.strip() or "Research","notes": req.notes.strip(),"source": {"label": req.source_label, "url": req.url},"imported_from_web": True,"editable": True}
    if cloud.enabled():
        token=_bearer_token(request); bundle=cloud.action(token,'get_rp_bundle',id=str(req.conversation_id)); state=bundle.get('state') or {}
        if not bundle.get('conversation'): raise HTTPException(404,'RP not found')
        world=dict(state.get('world_bible') or {}); research=list(world.get('research') or []); research.append(item); world['research']=research
        cloud.action(token,'save_state',rp_id=str(req.conversation_id),doc_type='world_bible',payload=world)
        return {'ok':True,'item':item,'count':len(research)}
    conv = db.row("SELECT * FROM conversations WHERE id=?", (req.conversation_id,))
    if not conv: raise HTTPException(404, "RP not found")
    state = db.get_state(req.conversation_id); world = dict(state.get("world_bible") or {}); research = list(world.get("research") or []); research.append(item); world["research"] = research; db.update_state(req.conversation_id, "world_bible", world)
    return {"ok": True, "item": item, "count": len(research)}


@app.get("/api/discover/scenarios")
def discover_scenarios(request: Request, q: str = "", tags: str = "", rating: str = "", canon_mode: str = "") -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    if cloud.enabled():
        try:
            rows = cloud.action(_bearer_token(request), "list_public_content", item_type="scenario") or []
        except Exception:
            rows = []
    if not rows:
        rows = [_decode_content_row(x) for x in db.rows("SELECT * FROM creator_content WHERE item_type='scenario' AND status='Approved' AND visibility='Public' ORDER BY updated_at DESC, id DESC")]
    wanted={x.strip().lower() for x in tags.split(',') if x.strip()}; query=q.strip().lower(); out=[]
    for item in rows:
        item_tags={str(x).lower() for x in item.get('tags',[])}
        hay=" ".join([str(item.get('title') or ''),str(item.get('summary') or ''),str(item.get('parent_character') or '')," ".join(map(str,item.get('tags',[]))),str(item.get('creator_name') or '')]).lower()
        if query and query not in hay: continue
        if wanted and not wanted.issubset(item_tags | {str(item.get('content_rating') or '').lower()}): continue
        if rating and item.get('content_rating')!=rating: continue
        if canon_mode and item.get('canon_mode')!=canon_mode: continue
        out.append(item)
    return out


@app.get("/api/discover/characters")
def discover_characters(request: Request, q: str = "", tags: str = "") -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    if cloud.enabled():
        try: rows=cloud.action(_bearer_token(request),"list_public_content",item_type="character") or []
        except Exception: rows=[]
    if not rows:
        rows=[_decode_content_row(x) for x in db.rows("SELECT * FROM creator_content WHERE item_type='character' AND status='Approved' AND visibility IN ('Public','Unlisted') ORDER BY updated_at DESC, id DESC")]
    query=q.strip().lower(); wanted={x.strip().lower() for x in tags.split(',') if x.strip()}; out=[]
    for item in rows:
        item_tags={str(x).lower() for x in item.get('tags',[])}; hay=" ".join([str(item.get('title') or ''),str(item.get('summary') or '')," ".join(map(str,item.get('tags',[])))]).lower()
        if query and query not in hay: continue
        if wanted and not wanted.issubset(item_tags): continue
        out.append(item)
    return out


def _scenario_seed_item(value: Any, section: str, **extra: Any) -> dict[str, Any] | None:
    if isinstance(value, str):
        text=value.strip()
        if not text: return None
        key=re.sub(r'[^a-z0-9]+','-',text.lower()).strip('-')[:100] or 'item'
        return {'stable_key':f'scenario:{section}:{key}','details':text,'source':'scenario-template',**extra}
    if isinstance(value, dict):
        out=dict(value); out.setdefault('source','scenario-template')
        text=str(out.get('details') or out.get('fact') or out.get('event') or out.get('rule') or out.get('name') or out.get('title') or out.get('value') or out.get('description') or out.get('notes') or '')
        key=re.sub(r'[^a-z0-9]+','-',text.lower()).strip('-')[:100] or 'item'
        out.setdefault('stable_key',f'scenario:{section}:{key}'); out.update(extra); return out
    return None


def _scenario_world_template(scenario: dict[str, Any], sp: dict[str, Any]) -> dict[str, Any]:
    base={
        'schema_version':4,'overview':{'name':scenario.get('title',''),'description':scenario.get('summary') or sp.get('premise') or ''},
        'history_backstory':[],'background_facts':[],'characters':[],'relationship_history':[],'core_canon':[],
        'locations':[],'secrets_knowledge':[],'groups_factions':[],'important_objects':[],'world_rules':[],
        'current_state':[],'physical_continuity':[],'story_timeline':[],'custom_entries':[],'auto_review_conflicts':[],
        'research':[],'import_history':[],'import_review':[],'pets':[],'vehicles':[],'organizations':[],'timeline':[],
    }
    seed=sp.get('world_bible_seed') if isinstance(sp.get('world_bible_seed'),dict) else {}
    if isinstance(seed.get('overview'),dict): base['overview'].update(seed['overview'])
    for key in [k for k,v in base.items() if isinstance(v,list)]:
        if isinstance(seed.get(key),list): base[key]=json.loads(json.dumps(seed[key]))
    base['schema_version']=max(4,int(seed.get('schema_version') or 0))
    def add(section, values, **extra):
        if not isinstance(values,list): values=[values] if values else []
        seen={str((x.get('stable_key') if isinstance(x,dict) else x) or '').lower() for x in base[section]}
        for raw in values:
            item=_scenario_seed_item(raw,section,**extra)
            if not item: continue
            k=str(item.get('stable_key') or '').lower()
            if k and k not in seen: base[section].append(item);seen.add(k)
    add('history_backstory',sp.get('history_backstory') or sp.get('history_notes') or sp.get('history_anchors') or [])
    if sp.get('scenario_setup'): add('background_facts',[sp['scenario_setup']],kind='starting_situation')
    add('relationship_history',sp.get('relationship_setup') or sp.get('relationship_history') or [])
    add('core_canon',sp.get('hard_scenario_rules') or sp.get('world_facts') or [])
    add('locations',sp.get('locations') or [])
    add('secrets_knowledge',sp.get('secrets_knowledge') or [])
    add('groups_factions',sp.get('groups_factions') or [])
    add('important_objects',sp.get('important_objects') or [])
    add('world_rules',sp.get('world_rules') or [])
    add('current_state',sp.get('current_state') or [])
    add('physical_continuity',sp.get('physical_continuity') or [])
    add('story_timeline',sp.get('story_timeline') or sp.get('timeline_seed') or [])
    add('custom_entries',sp.get('goals_hooks') or [],kind='story_hook')
    return base


def _scenario_seed_docs(scenario: dict[str, Any], payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], str, str]:
    sp=scenario.get('payload') or {}; persona=str(payload.get('persona_name') or 'Persona'); docs=db.default_state_docs(); scene=dict(docs['scene'])
    scene['start_date']=str(payload.get('start_date') or scene.get('start_date')); scene['start_time']=str(payload.get('start_time') or scene.get('start_time')); scene['date']=scene['start_date']; scene['time']=scene['start_time']; scene['controlled_character']=persona; docs['scene']=scene
    world=_scenario_world_template(scenario,sp); docs['world_bible']=world
    docs['hard_canon']={**docs['hard_canon'],'scenario_source':{'content_id':scenario.get('id'),'title':scenario['title'],'version':scenario.get('version',1),'template_copied_at':datetime.now(timezone.utc).isoformat()},'source_canon_mode':scenario.get('canon_mode') or 'Full AU','base_character':scenario.get('parent_character'),'base_character_id':sp.get('parent_character_id'),'scenario_schema':sp.get('scenario_schema') or 'legacy','scenario_facts':json.loads(json.dumps(world.get('core_canon') or [])),'scenario_rules':json.loads(json.dumps(world.get('world_rules') or []))}
    if not cloud.enabled() and payload.get('persona_id') not in (None,''):
        try: prow=db.row("SELECT * FROM personas WHERE id=?",(int(payload.get('persona_id')),))
        except Exception: prow=None
        if prow:
            docs['hard_canon']['user_persona']={'name':prow.get('name') or persona,'age':prow.get('age_text') or '','pronouns':prow.get('pronouns') or '','appearance':prow.get('appearance') or '','backstory':prow.get('backstory') or '','occupation':prow.get('occupation') or '','notes':prow.get('notes') or '','source':'selected-persona-snapshot'}
    if sp.get('user_role'): docs['hard_canon']['user_role']=sp.get('user_role')
    if isinstance(sp.get('user_role_facts'),list): docs['hard_canon']['user_role_facts']=list(sp['user_role_facts'])
    if sp.get('tone_genre'): docs['hard_canon']['tone_genre']=json.loads(json.dumps(sp.get('tone_genre')))
    if sp.get('relationship_start'): docs['relationship']['phase']=sp['relationship_start']
    if sp.get('relationship_detail'): docs['relationship']['scenario_detail']=sp['relationship_detail']
    docs['relationship']['starting_history']=json.loads(json.dumps(world.get('relationship_history') or []))
    docs['scene']['starting_state']=json.loads(json.dumps(world.get('current_state') or []))
    if isinstance(sp.get('goals_hooks'),list): docs['scene']['threads']=list(sp['goals_hooks'])
    docs['physical']['continuity_notes']=json.loads(json.dumps(world.get('physical_continuity') or []))
    docs['knowledge']['facts']=[]
    for item in world.get('secrets_knowledge') or []:
        fact=str((item.get('fact') or item.get('details') or item.get('value') or item.get('title') or '') if isinstance(item,dict) else item).strip()
        if fact: docs['knowledge']['facts'].append({'fact':fact,'known_by':list(item.get('known_by') or []) if isinstance(item,dict) else [],'hidden_from':list(item.get('hidden_from') or []) if isinstance(item,dict) else [],'status':'confirmed','source':'scenario-template','stable_key':item.get('stable_key') if isinstance(item,dict) else None})
    starting_scene=sp.get('starting_scene') if isinstance(sp.get('starting_scene'),dict) else {}
    if starting_scene:
        if starting_scene.get('location'): scene['location']=str(starting_scene['location'])
        if starting_scene.get('present_cast'): scene['present']=list(starting_scene.get('present_cast') or [])
        if starting_scene.get('situation'): scene['scenario_situation']=str(starting_scene['situation'])
        if starting_scene.get('date_or_era'): scene['scenario_era']=str(starting_scene['date_or_era'])
        if starting_scene.get('time'): scene['scenario_time_label']=str(starting_scene['time'])
        docs['scene']=scene
    added=set()
    def add_char(item):
        name=str(item.get('name') or item.get('title') or '').strip() if isinstance(item,dict) else str(item or '').strip()
        if not name or name.lower() in added:return
        added.add(name.lower()); obj=dict(item) if isinstance(item,dict) else {'name':name};obj['name']=name;obj.setdefault('source','scenario-template');world['characters'].append(obj)
    for existing in list(world.get('characters') or []):
        name=str(existing.get('name') or existing.get('title') or '').strip() if isinstance(existing,dict) else str(existing or '').strip()
        if name: added.add(name.lower())
    if scenario.get('parent_character'):
        snap=sp.get('parent_character_snapshot') if isinstance(sp.get('parent_character_snapshot'),dict) else None
        if snap: add_char({'name':snap.get('title') or scenario['parent_character'],'source':'scenario-template-linked-character','content_id':snap.get('id') or sp.get('parent_character_id'),'version':snap.get('version') or 1,'summary':snap.get('summary') or '','tags':snap.get('tags') or [],'definition':snap.get('payload') or {},'role':'main character'})
        else:
            char=db.row("SELECT * FROM creator_content WHERE item_type='character' AND title=? ORDER BY id DESC LIMIT 1",(scenario['parent_character'],))
            if char:
                c=_decode_content_row(char);add_char({'name':scenario['parent_character'],'source':'character library','content_id':c['id'],'version':c['version'],'summary':c.get('summary'),'tags':c.get('tags',[]),'definition':c.get('payload',{}),'role':'main character'})
            else:add_char({'name':scenario['parent_character'],'role':'main character'})
    for linked in sp.get('linked_characters') or []:
        if not isinstance(linked,dict):continue
        cid=linked.get('content_id') or linked.get('id'); c=None
        if cid:
            try: raw=db.row("SELECT * FROM creator_content WHERE id=? AND item_type='character'",(int(cid),)); c=_decode_content_row(raw) if raw else None
            except Exception:c=None
        if c:add_char({'name':c['title'],'source':'scenario-template-linked-character','content_id':c['id'],'version':c['version'],'summary':c.get('summary'),'tags':c.get('tags',[]),'definition':c.get('payload',{}),'role':linked.get('role') or 'scenario character'})
        elif linked.get('name'):add_char(linked)
    for side in sp.get('key_characters') or sp.get('side_characters') or []:
        if isinstance(side,str) and side.strip():add_char({'name':side.split(' — ',1)[0].strip(),'notes':side,'role':'scenario character'})
        elif isinstance(side,dict):add_char(side)
    if sp.get('web_research') or sp.get('creation_research'):world.setdefault('research',[]).append({'title':'Scenario research provenance','content':'Research used to construct the scenario template; provenance only.','web_research':sp.get('web_research'),'creation_research':sp.get('creation_research'),'source':'scenario-template'})
    world['story_timeline'].append({'event':'RP started from scenario','scenario':scenario['title'],'version':scenario.get('version',1),'source':'scenario-template','stable_key':f"scenario:start:{scenario.get('id')}"})
    opening=str(sp.get('opening_prompt') or sp.get('opening') or '').strip(); speaker=scenario.get('parent_character') or scenario['title']
    return docs, scene, opening, speaker


@app.post("/api/discover/scenarios/{content_id}/start")
def start_public_scenario(content_id: str, payload: dict[str, Any], request: Request) -> dict[str, Any]:
    scenario: dict[str, Any] | None=None; cloud_item=False
    if cloud.enabled():
        try:
            cloud_rows=cloud.action(_bearer_token(request),"list_public_content",item_type="scenario") or []
            scenario=next((x for x in cloud_rows if str(x.get('id'))==str(content_id)),None); cloud_item=scenario is not None
        except Exception: scenario=None
    if scenario is None:
        try: raw=db.row("SELECT * FROM creator_content WHERE id=? AND item_type='scenario' AND status='Approved' AND visibility='Public'",(int(content_id),))
        except Exception: raw=None
        if raw: scenario=_decode_content_row(raw)
    if not scenario: raise HTTPException(404,'Public scenario not found')
    sp=scenario.get('payload') or {}; persona=str(payload.get('persona_name') or 'Persona'); rp_title=str(payload.get('title') or f"{scenario['title']} · {persona}"); docs,scene,opening,speaker=_scenario_seed_docs(scenario,payload)
    if cloud.enabled():
        token=_bearer_token(request)
        if cloud_item and payload.get('persona_id') not in (None,''):
            # Use the same canonical Scenario seeding service as Android. This lets
            # the server resolve current linked Character records and structured
            # Scenario/World Bible data, including older scenarios without a local snapshot.
            return cloud.start_rp_action(
                token, scenario_id=str(scenario.get('id')), persona_id=str(payload.get('persona_id')),
                title=rp_title, start_date=payload.get('start_date'), start_time=payload.get('start_time'),
            )
        # Compatibility fallback for a local-only scenario while cloud mode is on.
        rp=cloud.action(token,'create_rp',rp={'title':rp_title,'persona_id':payload.get('persona_id'),'persona_name':persona,'scenario_id':None,'scenario_name':scenario['title'],'scenario_version':scenario.get('version',1),'canon_mode':scenario.get('canon_mode') or 'Full AU','timeline_mode':str(sp.get('timeline_mode') or 'Current Calendar / Fixed RP Age'),'reply_length':str(sp.get('reply_length') or 'Long'),'initiative':str(sp.get('initiative') or 'Balanced'),'relationship_realism':str(sp.get('relationship_realism') or 'Balanced')},state_docs=docs,opening_message=opening,opening_speaker=speaker)
        return {'ok':True,'conversation_id':rp['id'],'conversation':rp}
    cid=db.execute("""INSERT INTO conversations(title,persona_name,scenario_name,canon_mode,timeline_mode,reply_length,initiative,relationship_realism) VALUES (?,?,?,?,?,?,?,?)""",(rp_title,persona,scenario['title'],scenario.get('canon_mode') or 'Full AU',str(sp.get('timeline_mode') or 'Current Calendar / Fixed RP Age'),str(sp.get('reply_length') or 'Long'),str(sp.get('initiative') or 'Balanced'),str(sp.get('relationship_realism') or 'Balanced')))
    for doc_type,doc in docs.items(): db.update_state(cid,doc_type,doc)
    if opening: db.execute("""INSERT INTO messages(conversation_id,role,content,turn_group,is_selected,model_provider,model_name,story_date,story_time,speaker_name) VALUES (?,?,?,?,1,?,?,?,?,?)""",(cid,'assistant',opening,f'scenario-start-{cid}','scenario-seed','scenario-opening',scene.get('date'),scene.get('time'),speaker))
    return {'ok':True,'conversation_id':cid,'conversation':db.row("SELECT * FROM conversations WHERE id=?",(cid,))}


@app.get("/api/creator/content")
def creator_content(request: Request, creator_name: str = "You") -> list[dict[str, Any]]:
    if cloud.enabled():
        return cloud.write_action(_bearer_token(request), "list_my_content")
    rows = db.rows("SELECT * FROM creator_content WHERE creator_name=? ORDER BY updated_at DESC, id DESC", (creator_name,))
    return [_decode_content_row(r) for r in rows]


@app.get("/api/creator/characters")
def creator_character_choices(request: Request, q: str = "", creator_name: str = "You") -> list[dict[str, Any]]:
    if cloud.enabled():
        token=_bearer_token(request)
        mine=cloud.write_action(token,"list_my_content")
        public=cloud.action(token,"list_public_content",item_type="character")
        rows=[]; seen=set(); query=q.strip().lower()
        for item in list(mine)+list(public):
            if item.get("item_type")!="character": continue
            iid=str(item.get("id"))
            if iid in seen: continue
            seen.add(iid)
            hay=" ".join([str(item.get('title') or ''),str(item.get('summary') or '')," ".join(map(str,item.get('tags') or []))]).lower()
            if query and query not in hay: continue
            rows.append(item)
        rows.sort(key=lambda x: (0 if str(x.get('creator_user_id') or '') else 1, str(x.get('updated_at') or '')), reverse=False)
        return rows
    rows = db.rows(
        """SELECT * FROM creator_content
           WHERE item_type='character' AND ((status='Approved' AND visibility IN ('Public','Unlisted')) OR creator_name=? )
           ORDER BY CASE WHEN creator_name=? THEN 0 ELSE 1 END, updated_at DESC, id DESC""",
        (creator_name, creator_name),
    )
    query=q.strip().lower(); out=[]; seen=set()
    for raw in rows:
        item=_decode_content_row(raw)
        if item['id'] in seen: continue
        seen.add(item['id'])
        hay=" ".join([str(item.get('title') or ''),str(item.get('summary') or '')," ".join(map(str,item.get('tags',[])))]).lower()
        if query and query not in hay: continue
        out.append(item)
    return out


@app.post("/api/creator/content")
def create_creator_content(payload: dict[str, Any], request: Request) -> dict[str, Any]:
    item_type=str(payload.get('item_type') or 'scenario')
    if item_type not in {'scenario','character'}: raise HTTPException(400,'item_type must be scenario or character')
    title=str(payload.get('title') or ('New Scenario' if item_type=='scenario' else 'New Character')).strip()
    if not title: raise HTTPException(400,'Title is required')
    if cloud.enabled():
        return cloud.write_action(_bearer_token(request),'create_content',item={**payload,'title':title,'item_type':item_type})
    cid=db.execute(
        """INSERT INTO creator_content(item_type,title,creator_name,parent_character,summary,visibility,content_rating,canon_mode,source_name,status,tags_json,payload_json)
           VALUES (?,?,?,?,?,?,?,?,?,'Draft',?,?)""",
        (item_type,title,str(payload.get('creator_name') or 'You'),payload.get('parent_character'),str(payload.get('summary') or ''),str(payload.get('visibility') or 'Private'),str(payload.get('content_rating') or 'SFW'),str(payload.get('canon_mode') or 'Selective Canon'),str(payload.get('source_name') or 'Original'),json.dumps(payload.get('tags') or []),json.dumps(payload.get('payload') or {})),
    )
    return _decode_content_row(db.row('SELECT * FROM creator_content WHERE id=?',(cid,)))


@app.patch("/api/creator/content/{content_id}")
def update_creator_content(content_id: str, payload: dict[str, Any], request: Request) -> dict[str, Any]:
    if cloud.enabled():
        return cloud.write_action(_bearer_token(request),'update_content',id=content_id,patch=payload)
    local_id=int(content_id)
    item=db.row('SELECT * FROM creator_content WHERE id=?',(local_id,))
    if not item: raise HTTPException(404,'Creation not found')
    if item['status'] not in {'Draft','Changes Requested','Private Test','Rejected'}:
        raise HTTPException(400,'This version is already in review or published. Create/update through versioning instead.')
    for key in {'title','parent_character','summary','visibility','content_rating','canon_mode','source_name'}:
        if key in payload: db.execute(f'UPDATE creator_content SET {key}=?,updated_at=CURRENT_TIMESTAMP WHERE id=?',(payload[key],local_id))
    if 'tags' in payload: db.execute('UPDATE creator_content SET tags_json=?,updated_at=CURRENT_TIMESTAMP WHERE id=?',(json.dumps(payload.get('tags') or []),local_id))
    if 'payload' in payload: db.execute('UPDATE creator_content SET payload_json=?,updated_at=CURRENT_TIMESTAMP WHERE id=?',(json.dumps(payload.get('payload') or {}),local_id))
    return _decode_content_row(db.row('SELECT * FROM creator_content WHERE id=?',(local_id,)))


@app.post("/api/creator/content/{content_id}/submit")
def submit_creator_content(content_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
    if cloud.enabled():
        return cloud.write_action(_bearer_token(request),'submit_content',id=content_id)
    local_id=int(content_id)
    item=db.row('SELECT * FROM creator_content WHERE id=?',(local_id,))
    if not item: raise HTTPException(404,'Creation not found')
    if item['status'] not in {'Draft','Changes Requested','Private Test','Rejected'}:
        raise HTTPException(400,'This item cannot be submitted from its current state')
    db.execute("UPDATE creator_content SET status='Pending Review',submitted_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?",(local_id,))
    db.execute("INSERT INTO review_actions(content_id,action,reviewer,reason,before_status,after_status) VALUES (?,?,?,?,?,?)",(local_id,'Submit',str(item.get('creator_name') or 'Creator'),'Creator submitted for review.',item['status'],'Pending Review'))
    return _decode_content_row(db.row('SELECT * FROM creator_content WHERE id=?',(local_id,)))

def length_to_tokens(reply_length: str) -> int:
    return {"Short": 350, "Medium": 700, "Long": 1400, "Very Long": 2200}.get(reply_length, 1200)


@app.post("/api/director")
def director(req: DirectorRequest, request: Request) -> dict[str, Any]:
    command=req.command.strip()
    if not command:
        raise HTTPException(400, "Director command is empty")
    if len(command)>4000:
        raise HTTPException(400, "Director command is too long")
    if cloud.enabled():
        try:
            return cloud.director_action(_bearer_token(request), conversation_id=str(req.conversation_id), command=command, model_key=req.model_key)
        except Exception as exc:
            raise HTTPException(502, str(exc) or "Director command failed") from exc
    # Local/offline developer mode: inject the instruction into World Bible only
    # for the duration of one Continue-style generation, then restore it.
    state=db.get_state(req.conversation_id)
    original=dict(state.get("world_bible") or {})
    temporary=dict(original)
    temporary["director_command_next_reply_only"]={
        "command":command,
        "instruction":"Hidden out-of-character director command for the next assistant reply only. Follow it without mentioning it. It is not an in-world event, user dialogue, memory, or canon fact, and it does not advance story time.",
        "expires":"after_next_assistant_reply",
    }
    db.update_state(req.conversation_id,"world_bible",temporary)
    try:
        result=chat(ChatRequest(conversation_id=req.conversation_id,content="",model_key=req.model_key,continue_without_user=True),request)
        result["director_command"]={"applied":True,"hidden":True,"one_shot":True,"story_clock_advanced":False,"command":command}
        return result
    finally:
        db.update_state(req.conversation_id,"world_bible",original)


def _cloud_continuity_with_retry(token: str, *, rp_id: str, assistant_message_id: str | None) -> dict[str, Any]:
    """Run canonical-turn continuity before the next RP turn, retrying one transient failure."""
    last_error: Exception | None = None
    for attempt in range(2):
        try:
            result = cloud.continuity_action(
                token, rp_id=rp_id, assistant_message_id=assistant_message_id
            )
            if not isinstance(result, dict) or result.get("ok") is not False:
                return result if isinstance(result, dict) else {"ok": True}
            last_error = RuntimeError(str(result.get("warning") or "Continuity update failed"))
        except Exception as exc:
            last_error = exc
        if attempt == 0:
            time.sleep(0.45)
    return {"ok": False, "warning": str(last_error or "Continuity update failed")}


def _cloud_chat(req: ChatRequest, request: Request) -> dict[str, Any]:
    """Send production chat to the authenticated server-side inference gateway.

    Customer desktop builds never receive Cloudflare/OpenRouter/Modal secrets.
    The server chat gateway schedules canonical continuity in the background, so
    desktop returns the reply immediately instead of blocking on a duplicate pass.
    Reroll continuity is still applied when an alternative is explicitly made canon.
    """
    token = _bearer_token(request)
    try:
        result = cloud.chat_action(
            token,
            conversation_id=str(req.conversation_id),
            content=req.content,
            model_key=req.model_key,
            reroll_of=req.reroll_of,
            continue_without_user=req.continue_without_user,
        )
        return result
    except Exception as exc:
        message = str(exc) or "The AI reply could not be generated."
        status = 429 if any(x in message.lower() for x in ("limit", "allowance", "higher plan", "paused")) else 502
        raise HTTPException(status, message) from exc


@app.post("/api/chat")
def chat(req: ChatRequest, request: Request) -> dict[str, Any]:
    if cloud.enabled(): return _cloud_chat(req,request)
    conv = db.row("SELECT * FROM conversations WHERE id=?", (req.conversation_id,))
    if not conv:
        raise HTTPException(404, "Conversation not found")
    if not req.content.strip() and req.reroll_of is None and not req.continue_without_user:
        raise HTTPException(400, "Message is empty")
    profile=_local_user()
    try: reservation=reserve_local_generation(profile["id"],profile["plan_tier"],req.model_key)
    except PermissionError as exc: raise HTTPException(429,str(exc)) from exc
    resolved_provider=reservation["model"].provider; resolved_model=reservation["model"].provider_model
    if os.getenv("MELUMIYACHAT_LOCAL_REAL_MODELS","0").lower() not in {"1","true","yes"}: resolved_provider="mock"; resolved_model=reservation["model"].provider_model

    state = db.get_state(req.conversation_id)
    scene = ensure_story_clock(state.get("scene", {}))
    time_reason = "Rerolls do not advance story time."

    if req.reroll_of is None and not req.continue_without_user:
        # The selected date/time is the timestamp of the FIRST sent message.
        # Later canonical user turns advance the story clock conservatively, with
        # explicit user time-skips (e.g. “three years later”) taking priority.
        scene, time_reason = advance_for_user_turn(scene, req.content.strip())
        db.update_state(req.conversation_id, "scene", scene)
        state["scene"] = scene

        turn_group = str(uuid.uuid4())
        user_id = db.execute(
            """
            INSERT INTO messages(
                conversation_id, role, content, turn_group, is_selected, story_date, story_time
            ) VALUES (?,?,?,?,1,?,?)
            """,
            (
                req.conversation_id,
                "user",
                req.content.strip(),
                turn_group,
                scene.get("date"),
                scene.get("time"),
            ),
        )
        user_text = req.content.strip()
    elif req.reroll_of is None and req.continue_without_user:
        # Generate another AI/NPC turn without inventing a user response.
        # The controlled-character agency rule remains in the system prompt.
        turn_group = str(uuid.uuid4())
        user_id = None
        user_text = ""
        time_reason = "AI continuation requested; no user turn was inserted."
    else:
        original = db.row("SELECT * FROM messages WHERE id=? AND conversation_id=?", (req.reroll_of, req.conversation_id))
        if not original or original["role"] != "assistant":
            raise HTTPException(400, "reroll_of must refer to an assistant message")
        turn_group = original.get("turn_group") or str(uuid.uuid4())
        prev_user = db.row(
            "SELECT * FROM messages WHERE conversation_id=? AND role='user' AND id < ? ORDER BY id DESC LIMIT 1",
            (req.conversation_id, req.reroll_of),
        )
        user_id = prev_user["id"] if prev_user else None
        user_text = prev_user["content"] if prev_user else "Continue the scene."

    # Pull a slightly wider candidate set. The context builder performs the final
    # relevance/priority selection using the redesigned memory importance and
    # relevance fields. Legacy labels remain supported for older RP databases.
    memories = db.rows(
        """SELECT * FROM memories
           WHERE conversation_id=? AND active=1 AND COALESCE(current_relevance,'active')!='archived'
           ORDER BY
             CASE COALESCE(current_relevance,'active') WHEN 'active' THEN 0 WHEN 'resolved' THEN 1 WHEN 'superseded' THEN 2 ELSE 3 END,
             CASE importance
               WHEN 'Critical' THEN 0 WHEN 'Hard Canon' THEN 0
               WHEN 'Important' THEN 1 WHEN 'Relationship' THEN 1
               WHEN 'Normal' THEN 2 WHEN 'Long-Term' THEN 2
               WHEN 'Minor' THEN 3 WHEN 'Short-Term' THEN 3
               ELSE 4 END,
             COALESCE(updated_at,created_at) DESC, id DESC
           LIMIT 80""",
        (req.conversation_id,),
    )
    history = db.rows(
        "SELECT * FROM messages WHERE conversation_id=? AND is_selected=1 ORDER BY id",
        (req.conversation_id,),
    )
    system_prompt = build_system_prompt(conv, state, memories, user_text)
    model_messages = [{"role": "system", "content": system_prompt}]
    model_messages.extend(recent_messages_for_model(history, 26))
    if req.continue_without_user and req.reroll_of is None:
        model_messages.append({
            "role": "user",
            "content": "Continue the scene naturally with the AI-controlled character(s) only. Do not invent dialogue, thoughts, feelings, consent, choices, or major actions for the currently user-controlled character. Let NPCs act, speak, react, or allow a natural pause if that best fits the scene.",
        })
    if req.reroll_of is not None:
        model_messages.append({
            "role": "user",
            "content": "Regenerate the immediately preceding assistant turn. Keep hard canon, current scene, and story timestamp fixed; produce a meaningfully different but character-consistent alternative.",
        })

    try:
        result = call_chat(
            resolved_provider,
            resolved_model,
            model_messages,
            temperature=0.95,
            max_tokens=length_to_tokens(conv.get("reply_length", "Long")),
        )
    except Exception as exc:
        release_local_generation(reservation["request_id"])
        raise HTTPException(502, str(exc)) from exc
    u=result.get("usage") or {}
    finalize_local_generation(reservation["request_id"],int(u.get("prompt_tokens") or 0),int(u.get("completion_tokens") or 0),float(result.get("estimated_cost_usd") or 0))

    validation = validate_candidate(result["content"], state)
    selected = 0 if req.reroll_of is not None else 1
    assistant_id = db.execute(
        """
        INSERT INTO messages(
            conversation_id, role, content, turn_group, is_selected, model_provider, model_name,
            parent_message_id, story_date, story_time
        ) VALUES (?,?,?,?,?,?,?,?,?,?)
        """,
        (
            req.conversation_id,
            "assistant",
            result["content"],
            turn_group,
            selected,
            resolved_provider,
            result.get("model") or resolved_model,
            req.reroll_of,
            scene.get("date"),
            scene.get("time"),
        ),
    )

    # For normal (not reroll) turns, extract meaningful memories AND newly
    # established World Bible/state facts. This pass deduplicates evolving
    # memories and protects user-authored canon from automatic overwrites.
    extracted = None
    if req.reroll_of is None:
        try:
            recent_memories = db.rows(
                "SELECT * FROM memories WHERE conversation_id=? AND active=1 ORDER BY COALESCE(updated_at,created_at) DESC, id DESC LIMIT 40",
                (req.conversation_id,),
            )
            extracted = llm_extract(
                resolved_provider, resolved_model, user_text, result["content"],
                state=state, conversation=conv, recent_memories=recent_memories,
            )
            extracted["applied"] = apply_extracted_local(
                db, req.conversation_id, extracted, source_message_id=assistant_id,
                user_text=user_text, assistant_text=result["content"],
            )
        except Exception as memory_exc:
            extracted = {"ok": False, "warning": str(memory_exc)}

    return {
        "message": db.row("SELECT * FROM messages WHERE id=?", (assistant_id,)),
        "validation": validation,
        "latency_ms": result.get("latency_ms"),
        "usage": result.get("usage"),
        "model_key": reservation["model"].key,
        "model_access": local_generation_usage(profile["id"],profile["plan_tier"]),
        "extracted": extracted,
        "user_message_id": user_id,
        "story_clock": {
            "date": scene.get("date"),
            "time": scene.get("time"),
            "reason": time_reason,
            "story_started": scene.get("story_started"),
        },
    }


@app.post("/api/rerolls/select")
def select_reroll(req: SelectRequest, request: Request) -> dict[str, Any]:
    if cloud.enabled():
        token = _bearer_token(request)
        cloud.write_action(token,'select_reroll',rp_id=str(req.conversation_id),message_id=str(req.message_id))
        continuity = _cloud_continuity_with_retry(
            token, rp_id=str(req.conversation_id), assistant_message_id=str(req.message_id)
        )
        return {"ok":True,"selected_message_id":req.message_id,"continuity":continuity}
    msg=db.row("SELECT * FROM messages WHERE id=? AND conversation_id=?",(req.message_id,req.conversation_id))
    if not msg or msg["role"]!="assistant": raise HTTPException(404,"Assistant message not found")
    group=msg.get("turn_group")
    if not group: raise HTTPException(400,"Message does not have a reroll group")
    abandoned = [
        x["id"] for x in db.rows(
            "SELECT id FROM messages WHERE conversation_id=? AND turn_group=? AND role='assistant' AND is_selected=1 AND id<>?",
            (req.conversation_id, group, req.message_id),
        )
    ]
    rollback = rollback_auto_continuity_for_messages(db, req.conversation_id, abandoned) if abandoned else {"memories":0,"world_entries":0,"state_entries":0,"conflicts":0}
    db.execute("UPDATE messages SET is_selected=0 WHERE conversation_id=? AND turn_group=? AND role='assistant'",(req.conversation_id,group)); db.execute("UPDATE messages SET is_selected=1 WHERE id=?",(req.message_id,))
    continuity = None
    try:
        user_msg = db.row("SELECT * FROM messages WHERE conversation_id=? AND role='user' AND id<? ORDER BY id DESC LIMIT 1", (req.conversation_id, req.message_id)) or {}
        conv = db.row("SELECT * FROM conversations WHERE id=?", (req.conversation_id,)) or {}
        state_doc = db.get_state(req.conversation_id)
        recent_memories = db.rows("SELECT * FROM memories WHERE conversation_id=? AND active=1 ORDER BY COALESCE(updated_at,created_at) DESC,id DESC LIMIT 40", (req.conversation_id,))
        extracted = llm_extract(str(msg.get("model_provider") or "mock"), msg.get("model_name"), str(user_msg.get("content") or ""), str(msg.get("content") or ""), state=state_doc, conversation=conv, recent_memories=recent_memories)
        continuity = apply_extracted_local(db, req.conversation_id, extracted, source_message_id=req.message_id, user_text=str(user_msg.get("content") or ""), assistant_text=str(msg.get("content") or ""))
    except Exception as exc:
        continuity = {"ok": False, "warning": str(exc)}
    return {"ok":True,"selected_message_id":req.message_id,"continuity":continuity,"rollback":rollback}


@app.post("/api/state")
def set_state(req: StateUpdate, request: Request) -> dict[str, Any]:
    if cloud.enabled(): cloud.action(_bearer_token(request),'save_state',rp_id=str(req.conversation_id),doc_type=req.doc_type,payload=req.payload); return {"ok":True,"doc_type":req.doc_type}
    db.update_state(req.conversation_id,req.doc_type,req.payload); return {"ok":True,"doc_type":req.doc_type}


@app.post("/api/memories")
def add_memory(req: MemoryCreate, request: Request) -> dict[str, Any]:
    what = (req.what_happened or req.content).strip()
    if not what:
        raise HTTPException(400, "What Happened is required")
    memory = {
        "memory_type": req.memory_type, "title": req.title, "content": what, "what_happened": what,
        "importance": req.importance, "who_remembers": req.who_remembers, "who_involved": req.who_involved,
        "story_when": req.story_when, "story_where": req.story_where, "emotional_weight": req.emotional_weight,
        "current_relevance": req.current_relevance, "source_kind": req.source_kind or "User-created",
        "original_context": req.original_context, "stable_key": req.stable_key, "source_message_id": req.source_message_id,
    }
    if cloud.enabled():
        return cloud.write_action(_bearer_token(request), 'add_memory', rp_id=str(req.conversation_id), memory=memory)
    import json as _json
    mid = db.execute(
        """INSERT INTO memories(conversation_id,memory_type,title,content,importance,source_message_id,active,what_happened,who_remembers,who_involved,story_when,story_where,emotional_weight,current_relevance,source_kind,original_context,stable_key,revision_history) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
        (req.conversation_id, req.memory_type, req.title, what, req.importance, req.source_message_id, 0 if req.current_relevance == "archived" else 1, what, _json.dumps(req.who_remembers, ensure_ascii=False), _json.dumps(req.who_involved, ensure_ascii=False), req.story_when, req.story_where, req.emotional_weight, req.current_relevance, req.source_kind or "User-created", _json.dumps(req.original_context, ensure_ascii=False), req.stable_key, "[]"),
    )
    return db.row("SELECT * FROM memories WHERE id=?", (mid,)) or {"id": mid}


@app.patch("/api/memories/{memory_id}")
def update_memory(memory_id: str, req: MemoryUpdate, request: Request) -> dict[str, Any]:
    patch = {k: v for k, v in req.model_dump(exclude_none=True).items()}
    if cloud.enabled():
        return cloud.write_action(_bearer_token(request), 'update_memory', id=memory_id, patch=patch)
    row = db.row("SELECT * FROM memories WHERE id=?", (int(memory_id),))
    if not row:
        raise HTTPException(404, "Memory not found")
    import json as _json
    allowed = {"title","what_happened","content","who_remembers","who_involved","story_when","story_where","memory_type","importance","emotional_weight","current_relevance","source_kind","original_context"}
    updates: dict[str, Any] = {}
    for k, v in patch.items():
        if k not in allowed:
            continue
        if k in {"who_remembers", "who_involved", "original_context"}:
            updates[k] = _json.dumps(v, ensure_ascii=False)
        else:
            updates[k] = v
    if "what_happened" in updates and "content" not in updates:
        updates["content"] = updates["what_happened"]
    if "content" in updates and "what_happened" not in updates:
        updates["what_happened"] = updates["content"]
    if "current_relevance" in updates:
        updates["active"] = 0 if updates["current_relevance"] == "archived" else 1
    updates["source_kind"] = "User-created"  # a manual edit now outranks future auto updates
    updates["updated_at"] = datetime.now(timezone.utc).isoformat()
    if updates:
        cols = ",".join(f"{k}=?" for k in updates)
        db.execute(f"UPDATE memories SET {cols} WHERE id=?", (*updates.values(), int(memory_id)))
    return db.row("SELECT * FROM memories WHERE id=?", (int(memory_id),)) or {"id": memory_id}


@app.delete("/api/memories/{memory_id}")
def delete_memory(memory_id: str, request: Request) -> dict[str, Any]:
    if cloud.enabled(): return cloud.write_action(_bearer_token(request),'delete_memory',id=memory_id)
    db.execute("UPDATE memories SET active=0,current_relevance='archived',updated_at=CURRENT_TIMESTAMP WHERE id=?",(int(memory_id),)); return {"ok":True}





















































































# ---------------- Customer billing / Stripe checkout ----------------

BILLING_TIERS = {"Plus", "Pro", "Ultra"}
BILLING_PERIODS = {"monthly", "six_months", "annual"}
BILLING_RETURN_BASE = "https://melumiyachat.pages.dev/plans/"


@app.get("/api/billing/catalog")
def billing_catalog(request: Request) -> dict[str, Any]:
    if not cloud.enabled():
        raise HTTPException(503, "Cloud billing is not configured.")
    try:
        return cloud.billing_action(_bearer_token(request), "catalog")
    except Exception as exc:
        raise HTTPException(502, str(exc))


@app.post("/api/billing/checkout")
def billing_checkout(req: BillingCheckoutRequest, request: Request) -> dict[str, Any]:
    if not cloud.enabled():
        raise HTTPException(503, "Cloud billing is not configured.")
    tier = str(req.tier or "").strip()
    period = str(req.billing_period or "monthly").strip()
    if tier not in BILLING_TIERS:
        raise HTTPException(400, "Choose Plus, Pro, or Ultra.")
    if period not in BILLING_PERIODS:
        raise HTTPException(400, "Choose monthly, six_months, or annual billing.")
    referral = str(req.referral_code or "").strip()
    try:
        return cloud.billing_action(
            _bearer_token(request),
            "create_checkout",
            tier=tier,
            billing_period=period,
            referral_code=referral,
            success_url=f"{BILLING_RETURN_BASE}?checkout=success&source=windows",
            cancel_url=f"{BILLING_RETURN_BASE}?checkout=cancelled&source=windows",
        )
    except Exception as exc:
        raise HTTPException(502, str(exc))


@app.post("/api/billing/refresh")
def billing_refresh(request: Request) -> dict[str, Any]:
    if not cloud.enabled():
        raise HTTPException(503, "Cloud billing is not configured.")
    try:
        return cloud.billing_action(_bearer_token(request), "status")
    except Exception as exc:
        raise HTTPException(502, str(exc))


# ---------------- Public promo / influencer-code validation ----------------

@app.get("/api/promos/validate")
def validate_public_promo(request: Request, code: str = "") -> dict[str, Any]:
    if cloud.enabled():
        cleaned=str(code or "").strip()
        if not cleaned: return {"valid":False,"code":"","message":"Enter a code first."}
        try:
            creator=cloud.billing_action(_bearer_token(request),"validate_creator_code",code=cleaned)
            if creator.get("valid"):
                return {"valid":True,"kind":"influencer_discount","code":creator.get("code"),"discount_percent":creator.get("discount_percent",5),"creator_name":creator.get("creator_name") or "Creator","message":f"Code applied: {float(creator.get('discount_percent') or 0):g}% off eligible subscription purchases."}
        except Exception:
            pass
        return cloud.reward_action(_bearer_token(request),"validate",code=cleaned)
    cleaned = str(code or "").strip().upper()
    if not cleaned:
        return {"valid": False, "code": "", "message": "Enter a code first."}
    row = db.row(
        """SELECT c.code,c.discount_percent,c.campaign_name,c.active,i.display_name AS creator_name
           FROM marketing_codes c JOIN marketing_influencers i ON i.id=c.influencer_id
           WHERE UPPER(c.code)=UPPER(?) LIMIT 1""",
        (cleaned,),
    )
    if row and row.get("active"):
        discount = max(0.0, min(100.0, float(row.get("discount_percent") or 0)))
        return {
            "valid": True, "kind": "influencer_discount", "code": row["code"], "discount_percent": discount,
            "creator_name": row.get("creator_name") or "Creator", "campaign_name": row.get("campaign_name") or "",
            "message": f"Code applied: {discount:g}% off eligible subscription purchases.",
        }
    reward = db.row(
        """SELECT r.*,(SELECT COUNT(*) FROM reward_redemptions x WHERE x.code_id=r.id AND x.status='Redeemed') AS redemption_count
           FROM reward_codes r WHERE UPPER(r.code)=UPPER(?) LIMIT 1""", (cleaned,)
    )
    if not reward or not reward.get("active"):
        return {"valid": False, "code": cleaned, "message": "That code is not active."}
    if reward.get("expires_at"):
        try:
            exp=datetime.fromisoformat(str(reward["expires_at"]).replace("Z","+00:00"))
            if exp.tzinfo is None: exp=exp.replace(tzinfo=timezone.utc)
            if exp <= datetime.now(timezone.utc):
                return {"valid": False, "code": cleaned, "message": "That reward code has expired."}
        except Exception:
            pass
    max_uses=int(reward.get("max_redemptions") or 0)
    if max_uses>0 and int(reward.get("redemption_count") or 0)>=max_uses:
        return {"valid": False, "code": cleaned, "message": "That reward code has already reached its redemption limit."}
    if reward["reward_type"] == "Discount":
        discount=max(0.0,min(100.0,float(reward.get("discount_percent") or 0)))
        return {"valid":True,"kind":"account_discount","reward_type":"Discount","code":reward["code"],"discount_percent":discount,
                "campaign_name":reward.get("campaign_name") or "","message":f"Reward code applied: {discount:g}% off eligible subscription purchases."}
    days=max(0,int(reward.get("duration_days") or 0))
    duration="permanent" if days==0 else f"{days} day"+("" if days==1 else "s")
    return {"valid":True,"kind":"tier_reward","reward_type":"Tier Access","code":reward["code"],"target_tier":reward.get("target_tier") or "Plus",
            "duration_days":days,"campaign_name":reward.get("campaign_name") or "","message":f"Prize code: {reward.get('target_tier') or 'Plus'} access for {duration}. Redeem it to activate."}


@app.post("/api/rewards/redeem-discount")
def redeem_discount_reward(payload: dict[str, Any], request: Request) -> dict[str, Any]:
    code=str(payload.get("code") or "").strip().upper()
    if not code:
        raise HTTPException(400,"Enter a discount code first.")
    if cloud.enabled():
        try:
            return cloud.reward_action(_bearer_token(request),"redeem_discount",code=code)
        except Exception as exc:
            raise HTTPException(502,str(exc))
    info=validate_public_promo(request, code)
    if not info.get("valid") or info.get("kind") not in {"account_discount","owner_discount"}:
        raise HTTPException(400, info.get("message") or "This is not a checkout discount code.")
    raise HTTPException(503,"Cloud billing is required to attach a checkout discount securely.")


@app.post("/api/rewards/redeem")
def redeem_reward(payload: dict[str, Any], request: Request) -> dict[str, Any]:
    code=str(payload.get("code") or "").strip().upper()
    if cloud.enabled(): return cloud.reward_action(_bearer_token(request),"redeem",code=code)
    info=validate_public_promo(request, code)
    if not info.get("valid") or info.get("kind")!="tier_reward":
        raise HTTPException(400, info.get("message") or "This is not a tier-access reward code.")
    user=_local_user()
    reward=db.row("SELECT * FROM reward_codes WHERE UPPER(code)=UPPER(?)",(code,))
    if not reward: raise HTTPException(404,"Reward code not found")
    if db.row("SELECT id FROM reward_redemptions WHERE code_id=? AND user_id=?",(reward["id"],user["id"])):
        raise HTTPException(409,"You already redeemed this reward code.")
    current_count=db.row("SELECT COUNT(*) AS n FROM reward_redemptions WHERE code_id=? AND status='Redeemed'",(reward["id"],))["n"]
    max_uses=int(reward.get("max_redemptions") or 0)
    if max_uses>0 and current_count>=max_uses: raise HTTPException(409,"That reward code has reached its redemption limit.")
    tier=reward.get("target_tier") or "Plus"; days=max(0,int(reward.get("duration_days") or 0))
    access_exp=None
    if days>0: access_exp=(datetime.now(timezone.utc)+timedelta(days=days)).isoformat()
    previous=user.get("plan_tier") or "Free"
    db.execute("UPDATE user_profiles SET plan_previous_tier=?,plan_tier=?,plan_expires_at=?,plan_source=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",
               (previous,tier,access_exp,f"reward:{reward['code']}",user["id"]))
    db.execute("INSERT INTO reward_redemptions(code_id,user_id,applied_tier,access_expires_at) VALUES (?,?,?,?)",(reward["id"],user["id"],tier,access_exp))
    return {"redeemed":True,"code":reward["code"],"plan_tier":tier,"expires_at":access_exp,
            "message":f"Reward activated: {tier}"+(f" for {days} days." if days else " with no expiry set.")}















# ---------------- Notifications ----------------

@app.get("/api/notifications")
def list_notifications(request: Request) -> dict[str, Any]:
    if cloud.enabled(): return cloud.notification_action(_bearer_token(request), "list")
    return {"items":[],"unread":0}

@app.post("/api/notifications/{notification_id}/read")
def mark_notification_read(notification_id: str, request: Request) -> dict[str, Any]:
    if cloud.enabled(): return cloud.notification_action(_bearer_token(request), "mark_read", id=notification_id)
    return {"ok":True}

@app.post("/api/notifications/read-all")
def mark_all_notifications_read(request: Request) -> dict[str, Any]:
    if cloud.enabled(): return cloud.notification_action(_bearer_token(request), "mark_all_read")
    return {"ok":True,"count":0}











































