import os
import re
import uuid
import copy
import logging
import datetime
import json
import csv
import io
import zipfile
import threading
import concurrent.futures
from pathlib import Path
import markdown as md
import markupsafe
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, field_validator
from sqlalchemy import case, text
from sqlalchemy.orm import Session
from sse_starlette.sse import EventSourceResponse
from typing import Optional
import asyncio
from starlette.middleware.sessions import SessionMiddleware

import base64
from fastapi import UploadFile, File, Form
from app.database import get_db, init_db, SessionLocal
from app.models import (
    Domain, Package, PackageRevision, Job, BuildTask,
    Augment, RoadmapItem, BrandKit, BrandKitAsset, SiteProfile, ProjectContext, GlobalContext,
    AuraUser, AppSettings, Concept, ConceptRevision, BatchPlan,
    FtpProfile, FtpProjectBinding, DeploymentLog, SiteSnapshot,
    ChatConversation, ChatMessage, MarketplaceListing,
    LlmProvider, LlmCredential, LlmModel, LlmRoute,
)
from app.services.aura import analyze_domain, build_package
from app.services.llm import call_llm_text, generate_image, call_llm_stream
from app.services.llm import (
    call_llm_text_routed, generate_image_routed, call_llm_stream_routed,
    get_routing_dashboard_data, get_all_routes, set_route, reset_route,
    PIPELINE_STAGES, PROVIDERS, get_provider_status,
)
from app.services.blueprint import (
    get_default_blueprint, validate_blueprint, calculate_completeness,
    parse_imported_content, DEFAULT_SECTIONS, SECTION_CATEGORIES,
    VISUAL_ELEMENT_OPTIONS, CONTENT_DEPTH_PRESETS, blueprint_to_prompt_spec
)
from app.services.augments import get_augment_types, suggest_augments, generate_augment_html
from app.services.valuation import valuate_domain
from app.services.profiles import (
    build_default_profile_config, STARTER_PROFILES, slugify,
    validate_profile_config, get_sections_for_profile, DISCOVERY_FIELDS_DEFAULT,
)
from app.services.brandkit import (
    extract_text_from_file, classify_document_content, classify_image,
    build_brandkit_context, IMAGE_EXTENSIONS, DOC_EXTENSIONS,
    CLASSIFICATION_LABELS, IMAGE_CLASSIFICATIONS,
    build_brandkit_summary, compute_gap_analysis, compute_image_suggestions,
    CLASSIFICATION_TO_SECTIONS, compute_file_hash, resolve_assets_for_sections
)
from app.services.advisor import (
    build_advisor_context, build_analysis_prompt, build_plan_prompt, build_batch_prompt,
    ADVISOR_ANALYZE_STEPS, ADVISOR_PLAN_STEPS, ADVISOR_BATCH_STEPS, CONCEPT_STATUSES,
)
from app.services.ftp_deploy import (
    encrypt_password, decrypt_password, test_ftp_connection,
    deploy_via_sftp, deploy_via_ftp, collect_deploy_files, FTP_DEPLOY_STEPS,
)
from app.services.graphics import (
    generate_full_graphics_pack, regenerate_asset, get_graphics_pack_summary,
    LOGO_STYLES, SEPARATOR_STYLES, SITE_ESSENTIALS, resolve_asset_by_id,
)
from app.services.business_docs import (
    generate_document, generate_tier, calculate_maximization_score,
    DOC_REGISTRY, TIERS, get_tier_doc_types,
)

logger = logging.getLogger(__name__)

job_executor = concurrent.futures.ThreadPoolExecutor(max_workers=4, thread_name_prefix="aura-job")
batch_executor = concurrent.futures.ThreadPoolExecutor(max_workers=6, thread_name_prefix="aura-batch")

BATCH_CONCURRENCY = 4
JOB_RETENTION_DAYS = 30

batch_control = {}


class BatchState:
    def __init__(self, batch_id: str):
        self.batch_id = batch_id
        self.pause_event = threading.Event()
        self.stop_flag = threading.Event()
        self.pause_event.set()
        self.active = True


def seed_site_profiles():
    db = SessionLocal()
    try:
        existing = db.query(SiteProfile).count()
        if existing == 0:
            default_prof = SiteProfile(
                name="Default (Current Behavior)",
                slug="default",
                description="Mirrors the current hardcoded Aura behavior exactly. All 16 section types, 4 depth presets, standard discovery questionnaire.",
                is_default=1,
                config=build_default_profile_config(),
            )
            db.add(default_prof)
            for sp in STARTER_PROFILES:
                prof = SiteProfile(
                    name=sp["name"],
                    slug=sp["slug"],
                    description=sp["description"],
                    is_default=0,
                    config=sp["config"],
                )
                db.add(prof)
            db.commit()
            logger.info(f"Seeded {1 + len(STARTER_PROFILES)} site profiles")
    except Exception as e:
        db.rollback()
        logger.error(f"Failed to seed profiles: {e}")
    finally:
        db.close()


@asynccontextmanager
async def lifespan(app: FastAPI):
    try:
        init_db()
        os.makedirs("static/images", exist_ok=True)
        from sqlalchemy import inspect as sa_inspect
        from app.database import engine
        inspector = sa_inspect(engine)
        with engine.connect() as op_conn:
            if inspector.has_table("domains"):
                cols = [c["name"] for c in inspector.get_columns("domains")]
                if "default_niche" not in cols:
                    op_conn.execute(text("ALTER TABLE domains ADD COLUMN default_niche VARCHAR(255)"))
                    op_conn.commit()
        logger.info("Database initialized successfully")
        recover_orphaned_jobs()
        cleanup_old_jobs()
        seed_site_profiles()
    except Exception as e:
        logger.error(f"Failed to initialize database: {e}")
        raise
    yield
    job_executor.shutdown(wait=False)


app = FastAPI(title="Aura - Domain to Business Generator", lifespan=lifespan)

from app.routes.aura_core_api import router as aura_core_router
app.include_router(aura_core_router)

app.mount("/static", StaticFiles(directory="static"), name="static")


from starlette.middleware.base import BaseHTTPMiddleware

class AuthGateMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        path = request.url.path
        if path.startswith("/static/") or path == "/login" or path == "/healthz" or path.startswith("/api/job/") or path == "/api/jobs" or path.startswith("/api/aura-core/") or path.startswith("/api/mastermind/") or path.startswith("/_dev_preview/") or path.startswith("/_dev_admin/") or path.startswith("/superadmin/") or path.startswith("/api/superadmin/") or path.startswith("/storefront") or path.startswith("/api/storefront/") or path.startswith("/api/packages/site-audit/") or path.startswith("/api/listing-copy/") or path.startswith("/api/batch/"):
            return await call_next(request)
        if _is_login_required():
            username = request.session.get("username")
            if not username:
                if path.startswith("/api/"):
                    return JSONResponse({"error": "Authentication required"}, status_code=401)
                return RedirectResponse(url="/login", status_code=302)
        return await call_next(request)

app.add_middleware(AuthGateMiddleware)
app.add_middleware(SessionMiddleware, secret_key=os.environ.get("SESSION_SECRET", "aura-dev-secret-change-me"))


def _is_login_required() -> bool:
    db = SessionLocal()
    try:
        setting = db.query(AppSettings).filter(AppSettings.key == "login_enabled").first()
        if setting and setting.value == "true":
            return True
        user_count = db.query(AuraUser).count()
        if user_count == 0:
            return True
        return False
    finally:
        db.close()


@app.middleware("http")
async def add_cache_control(request: Request, call_next):
    try:
        response = await call_next(request)
    except Exception as exc:
        if "ClientDisconnect" in type(exc).__name__ or "ClientDisconnect" in str(type(exc)):
            logger.warning(f"Client disconnected during {request.method} {request.url.path}")
            from starlette.responses import Response
            return Response(status_code=499, content="Client disconnected")
        raise
    content_type = response.headers.get("content-type", "")
    if request.url.path.startswith("/static/") or "text/html" in content_type:
        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
        response.headers["Pragma"] = "no-cache"
        response.headers["Expires"] = "0"
    return response

templates = Jinja2Templates(directory="app/templates")
templates.env.auto_reload = True

md_converter = md.Markdown(extensions=["extra", "nl2br", "sane_lists"])


def jinja_md_filter(text):
    if not text:
        return ""
    md_converter.reset()
    return markupsafe.Markup(md_converter.convert(str(text)))


templates.env.filters["md"] = jinja_md_filter


def render_markdown(text: str) -> str:
    if not text:
        return ""
    cleaned = text.strip()
    if cleaned.startswith("```markdown"):
        cleaned = cleaned[len("```markdown"):].strip()
    if cleaned.startswith("```md"):
        cleaned = cleaned[len("```md"):].strip()
    if cleaned.startswith("```"):
        cleaned = cleaned[3:].strip()
    if cleaned.endswith("```"):
        cleaned = cleaned[:-3].strip()
    if cleaned.startswith("{"):
        try:
            parsed = json.loads(cleaned)
            for key in ["content_markdown", "body_markdown", "sales_letter_markdown", "sales_letter", "content", "markdown", "letter", "text", "body"]:
                if key in parsed and isinstance(parsed[key], str):
                    cleaned = parsed[key]
                    break
            else:
                longest_val = ""
                for v in parsed.values():
                    if isinstance(v, str) and len(v) > len(longest_val):
                        longest_val = v
                if len(longest_val) > 100:
                    cleaned = longest_val
        except (json.JSONDecodeError, AttributeError):
            pass
    md_converter.reset()
    return md_converter.convert(cleaned)


class AnalyzeDomainRequest(BaseModel):
    domain: str
    niche_hints: str = ""

    @field_validator("niche_hints")
    @classmethod
    def cap_niche_hints(cls, v):
        v = (v or "").strip()
        if len(v) > 1000:
            v = v[:1000]
        return v


class BuildPackageRequest(BaseModel):
    domain: str
    niche_name: str
    template_type: str = "hero"
    layout_style: str = "single-scroll"
    density: str = "balanced"
    discovery_answers: Optional[dict] = None
    blueprint: Optional[dict] = None
    profile_slug: Optional[str] = None


ANALYSIS_STEPS = [
    {"key": "init", "label": "Preparing analysis", "description": "Setting up domain parsing and AI context", "est_seconds": 2},
    {"key": "keywords", "label": "Decomposing domain", "description": "Extracting keywords, acronyms, cross-language meanings", "est_seconds": 3},
    {"key": "ai_analysis", "label": "AI niche discovery", "description": "GPT is analyzing business models, affiliates, and revenue potential", "est_seconds": 25},
    {"key": "scoring", "label": "Scoring & ranking", "description": "Sorting niches by viability score and market potential", "est_seconds": 2},
    {"key": "complete", "label": "Analysis complete", "description": "All niches discovered and scored", "est_seconds": 0},
]

BUILD_STEPS = [
    {"key": "init", "label": "Preparing build", "description": "Loading domain analysis and niche data", "est_seconds": 2},
    {"key": "brand", "label": "Creating brand identity", "description": "GPT is designing brand names, taglines, and color palettes", "est_seconds": 20},
    {"key": "copy", "label": "Writing website copy", "description": "Generating headlines, features, testimonials, and FAQ content", "est_seconds": 15},
    {"key": "sales", "label": "Writing sales letter", "description": "Crafting a marketplace-ready sales letter for domain listing", "est_seconds": 15},
    {"key": "image", "label": "Generating hero image", "description": "DALL-E is creating a custom hero banner image", "est_seconds": 20},
    {"key": "saving", "label": "Saving package", "description": "Writing all assets to database", "est_seconds": 3},
    {"key": "complete", "label": "Package complete", "description": "Your business-in-a-box is ready", "est_seconds": 0},
]

BRANDKIT_STEPS = [
    {"key": "init", "label": "Preparing", "description": "Setting up brand kit processing", "est_seconds": 1},
    {"key": "extract", "label": "Extracting text", "description": "Parsing uploaded documents", "est_seconds": 5},
    {"key": "classify_docs", "label": "Classifying content", "description": "AI is analyzing and categorizing your brand documents", "est_seconds": 15},
    {"key": "classify_images", "label": "Classifying images", "description": "AI is analyzing and tagging your uploaded images", "est_seconds": 20},
    {"key": "intelligence", "label": "Building intelligence", "description": "Generating brand summary, gap analysis, and image placement suggestions", "est_seconds": 10},
    {"key": "complete", "label": "Brand kit ready", "description": "All content classified and ready for package generation", "est_seconds": 0},
]

AUGMENT_SUGGEST_STEPS = [
    {"key": "init", "label": "Preparing", "description": "Loading niche data", "est_seconds": 1},
    {"key": "ai_suggest", "label": "AI suggesting widgets", "description": "Analyzing niche to recommend high-value interactive widgets", "est_seconds": 15},
    {"key": "complete", "label": "Suggestions ready", "description": "Widget suggestions generated", "est_seconds": 0},
]

AUGMENT_GENERATE_STEPS = [
    {"key": "init", "label": "Preparing", "description": "Loading brand and niche data", "est_seconds": 1},
    {"key": "ai_generate", "label": "Generating widget", "description": "AI is building a complete interactive HTML widget", "est_seconds": 45},
    {"key": "saving", "label": "Saving augment", "description": "Writing widget to database", "est_seconds": 2},
    {"key": "complete", "label": "Widget ready", "description": "Your interactive widget is ready to preview", "est_seconds": 0},
]


def create_job(job_id: str, job_type: str, domain: str, total_steps: int, steps_detail: list, retry_params: dict = None, retry_of: str = None):
    db = SessionLocal()
    try:
        now = datetime.datetime.utcnow()
        job = Job(
            job_id=job_id,
            job_type=job_type,
            domain=domain,
            status="pending",
            current_step="Queued...",
            current_step_key="init",
            steps_completed=0,
            total_steps=total_steps,
            progress_pct=0,
            steps_detail=steps_detail,
            retry_params=retry_params,
            retry_of=retry_of,
            started_at=now,
            created_at=now,
            updated_at=now,
        )
        db.add(job)
        db.commit()
        logger.info(f"Created job {job_id}: {job_type} for {domain}")
    except Exception as e:
        db.rollback()
        logger.error(f"Failed to create job {job_id}: {e}")
    finally:
        db.close()


def update_job(job_id: str, **kwargs):
    db = SessionLocal()
    try:
        job = db.query(Job).filter(Job.job_id == job_id).first()
        if not job:
            logger.warning(f"update_job: job {job_id} not found in DB")
            return

        new_step_key = kwargs.get("current_step_key")
        if new_step_key and new_step_key != job.current_step_key:
            job.step_started_at = datetime.datetime.utcnow()

        if "status" in kwargs:
            job.status = kwargs["status"]
        if "current_step" in kwargs:
            job.current_step = kwargs["current_step"]
        if "current_step_key" in kwargs:
            job.current_step_key = kwargs["current_step_key"]
        if "steps_completed" in kwargs:
            job.steps_completed = kwargs["steps_completed"]
        if "total_steps" in kwargs:
            job.total_steps = kwargs["total_steps"]
        if "result" in kwargs:
            job.result = kwargs["result"]
        if "error" in kwargs:
            job.error = kwargs["error"]
        if "steps_detail" in kwargs:
            job.steps_detail = kwargs["steps_detail"]
        if "progress_pct" in kwargs:
            job.progress_pct = kwargs["progress_pct"]

        if "progress_pct" not in kwargs:
            total = job.total_steps or 1
            job.progress_pct = min(100, round((job.steps_completed / total) * 100))

        if kwargs.get("status") == "completed":
            job.completed_at = datetime.datetime.utcnow()
            job.progress_pct = 100
        elif kwargs.get("status") == "failed":
            job.completed_at = datetime.datetime.utcnow()

        job.updated_at = datetime.datetime.utcnow()
        db.commit()
    except Exception as e:
        db.rollback()
        logger.warning(f"Failed to update job {job_id}: {e}")
    finally:
        db.close()


def get_job_dict(job_id: str) -> dict:
    db = SessionLocal()
    try:
        job = db.query(Job).filter(Job.job_id == job_id).first()
        if job:
            return job.to_dict()
        return None
    except Exception as e:
        logger.warning(f"Failed to get job {job_id}: {e}")
        return None
    finally:
        db.close()


def recover_orphaned_jobs():
    try:
        db = SessionLocal()
        orphans = db.query(Job).filter(Job.status.in_(["pending", "running"])).all()
        for job in orphans:
            if job.job_type == "batch_analyze" and job.result:
                result = job.result or {}
                domains = result.get("domains", [])
                for ds in domains:
                    if ds.get("status") == "running":
                        ds["status"] = "interrupted"
                result["mode"] = "interrupted"
                job.result = result
                job.current_step = f"Interrupted — {sum(1 for d in domains if d['status'] == 'completed')}/{len(domains)} completed. Resume available."
            else:
                job.current_step = "Interrupted by server restart"
            job.status = "failed"
            job.error = "Server restarted — job interrupted before completion"
            job.completed_at = datetime.datetime.utcnow()
            job.updated_at = datetime.datetime.utcnow()
            logger.info(f"Recovered orphaned job: {job.job_id} ({job.job_type} for {job.domain})")
        if orphans:
            db.commit()
            logger.info(f"Recovered {len(orphans)} orphaned job(s)")
    except Exception as e:
        logger.warning(f"Failed to recover orphaned jobs: {e}")
    finally:
        db.close()


def cleanup_old_jobs():
    try:
        db = SessionLocal()
        cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=JOB_RETENTION_DAYS)
        old_jobs = db.query(Job).filter(
            Job.status.in_(["completed", "failed"]),
            Job.created_at < cutoff
        ).all()
        count = len(old_jobs)
        for job in old_jobs:
            db.delete(job)
        if count > 0:
            db.commit()
            logger.info(f"Cleaned up {count} jobs older than {JOB_RETENTION_DAYS} days")
    except Exception as e:
        logger.warning(f"Failed to cleanup old jobs: {e}")
    finally:
        db.close()


def run_analysis_job(job_id: str, domain_name: str, niche_hints: str = ""):
    db = SessionLocal()
    try:
        update_job(job_id, status="running", current_step="Preparing analysis...",
                   steps_completed=0, total_steps=len(ANALYSIS_STEPS) - 1,
                   current_step_key="init")

        def progress_cb(step_text, completed, total):
            step_key = ANALYSIS_STEPS[min(completed, len(ANALYSIS_STEPS)-1)]["key"] if completed < len(ANALYSIS_STEPS) else "complete"
            update_job(job_id, current_step=step_text, steps_completed=completed,
                       total_steps=len(ANALYSIS_STEPS) - 1, current_step_key=step_key)

        analysis = analyze_domain(domain_name, progress_callback=progress_cb, niche_hints=niche_hints)

        existing = db.query(Domain).filter(Domain.domain == domain_name).first()
        if existing:
            existing.analysis = analysis
            existing.analyzed_at = datetime.datetime.utcnow()
        else:
            existing = Domain(domain=domain_name, analysis=analysis, analyzed_at=datetime.datetime.utcnow())
            db.add(existing)
        db.commit()
        db.refresh(existing)

        update_job(job_id, status="completed", current_step="Analysis complete!",
                   steps_completed=len(ANALYSIS_STEPS) - 1, total_steps=len(ANALYSIS_STEPS) - 1,
                   current_step_key="complete",
                   result={"id": existing.id, "domain": domain_name, "analysis": analysis})

    except Exception as e:
        logger.error(f"Analysis job failed for {domain_name}: {e}")
        update_job(job_id, status="failed", current_step=f"Error: {str(e)}", error=str(e))
    finally:
        db.close()


def run_batch_analysis_orchestrator(batch_id: str, domains: list, niche_hints: str = ""):
    state = batch_control.get(batch_id)
    if not state:
        state = BatchState(batch_id)
        batch_control[batch_id] = state

    batch_start_time = datetime.datetime.utcnow().isoformat()
    domain_times = []

    domain_status = []
    for d in domains:
        domain_status.append({"domain": d, "job_id": None, "status": "queued", "error": None, "started_at": None, "completed_at": None})

    total = len(domains)
    completed_count = 0
    failed_count = 0

    def save_batch_state(mode, step_msg=None):
        nonlocal completed_count, failed_count
        completed_count = sum(1 for ds in domain_status if ds["status"] == "completed")
        failed_count = sum(1 for ds in domain_status if ds["status"] == "failed")
        in_flight = sum(1 for ds in domain_status if ds["status"] == "running")
        queued = sum(1 for ds in domain_status if ds["status"] == "queued")
        done_total = completed_count + failed_count
        pct = round((done_total / max(total, 1)) * 100)

        try:
            running_ds = [ds for ds in domain_status if ds["status"] == "running" and ds.get("job_id")]
            if running_ds:
                step_db = SessionLocal()
                try:
                    job_ids = [ds["job_id"] for ds in running_ds]
                    child_jobs = step_db.query(Job).filter(Job.job_id.in_(job_ids)).all()
                    step_map = {j.job_id: j.current_step for j in child_jobs if j.current_step}
                    for ds in running_ds:
                        cs = step_map.get(ds["job_id"])
                        if cs:
                            ds["current_step"] = cs
                finally:
                    step_db.close()
        except Exception:
            pass

        now = datetime.datetime.utcnow()
        batch_start_dt = datetime.datetime.fromisoformat(batch_start_time)
        elapsed = round((now - batch_start_dt).total_seconds(), 1)
        avg_time = round(sum(domain_times) / len(domain_times), 1) if domain_times else 0
        remaining_count = total - done_total
        est_remaining = round(avg_time * remaining_count, 1) if avg_time > 0 else 0

        update_job(batch_id,
                   status="running" if mode == "running" else mode,
                   current_step=step_msg or f"{done_total}/{total} domains processed ({in_flight} active, {queued} queued)",
                   progress_pct=pct if mode != "completed" else 100,
                   result={
                       "mode": mode,
                       "domains": domain_status,
                       "total": total,
                       "completed": completed_count,
                       "failed": failed_count,
                       "in_flight": in_flight,
                       "queued": queued,
                       "niche_hints": niche_hints,
                       "concurrency": BATCH_CONCURRENCY,
                       "batch_started_at": batch_start_time,
                       "elapsed_seconds": elapsed,
                       "avg_seconds_per_domain": avg_time,
                       "est_remaining_seconds": est_remaining,
                   })

    save_batch_state("running", f"Starting batch analysis of {total} domains...")

    idx = 0
    futures = {}

    try:
        while idx < total or futures:
            if state.stop_flag.is_set():
                for ds in domain_status:
                    if ds["status"] == "queued":
                        ds["status"] = "cancelled"
                save_batch_state("stopped", f"Batch stopped. {completed_count + failed_count}/{total} processed.")
                for f in futures:
                    pass
                break

            if not state.pause_event.is_set():
                save_batch_state("paused", f"Batch paused. {completed_count + failed_count}/{total} processed, {len(futures)} in flight.")
                state.pause_event.wait(timeout=2)
                continue

            while len(futures) < BATCH_CONCURRENCY and idx < total:
                if state.stop_flag.is_set():
                    break
                if not state.pause_event.is_set():
                    break

                ds = domain_status[idx]
                domain_name = ds["domain"].strip().lower()
                if not domain_name:
                    ds["status"] = "skipped"
                    idx += 1
                    continue

                child_job_id = f"ba-{batch_id[:4]}-{str(uuid.uuid4())[:6]}"
                ds["job_id"] = child_job_id
                ds["status"] = "running"
                ds["started_at"] = datetime.datetime.utcnow().isoformat()

                create_job(child_job_id, "analyze", domain_name, len(ANALYSIS_STEPS) - 1, ANALYSIS_STEPS,
                           retry_params={"domain": domain_name, "niche_hints": niche_hints, "batch_id": batch_id})

                future = batch_executor.submit(run_analysis_job, child_job_id, domain_name, niche_hints)
                futures[future] = idx
                idx += 1

            save_batch_state("running")

            if futures:
                done_futures, _ = concurrent.futures.wait(futures.keys(), timeout=2, return_when=concurrent.futures.FIRST_COMPLETED)
                for f in done_futures:
                    f_idx = futures.pop(f)
                    ds = domain_status[f_idx]
                    ds["completed_at"] = datetime.datetime.utcnow().isoformat()
                    if ds.get("started_at"):
                        started_dt = datetime.datetime.fromisoformat(ds["started_at"])
                        completed_dt = datetime.datetime.fromisoformat(ds["completed_at"])
                        ds["elapsed_seconds"] = round((completed_dt - started_dt).total_seconds(), 1)
                        domain_times.append(ds["elapsed_seconds"])
                    child_db = SessionLocal()
                    try:
                        child_job = child_db.query(Job).filter(Job.job_id == ds["job_id"]).first()
                        if child_job:
                            ds["status"] = child_job.status
                            if child_job.error:
                                ds["error"] = child_job.error
                        else:
                            ds["status"] = "completed"
                    except Exception:
                        ds["status"] = "completed"
                    finally:
                        child_db.close()
                    save_batch_state("running")
            elif idx >= total:
                break

        if not state.stop_flag.is_set():
            save_batch_state("completed", f"Batch complete! {completed_count} succeeded, {failed_count} failed out of {total}.")

    except Exception as e:
        logger.error(f"Batch analysis orchestrator failed: {e}")
        save_batch_state("failed", f"Orchestrator error: {str(e)}")
    finally:
        state.active = False


def run_build_job(job_id: str, domain_name: str, niche_name: str, template_type: str = "hero", discovery_answers: dict = None, layout_style: str = "single-scroll", density: str = "balanced", blueprint: dict = None, profile_slug: str = "default"):
    from app.services.context_engine import log_context_event, update_project_state, get_full_context_for_domain
    db = SessionLocal()
    try:
        update_job(job_id, status="running", current_step="Starting package generation...",
                   steps_completed=0, total_steps=len(BUILD_STEPS) - 1,
                   current_step_key="init")

        try:
            update_project_state(domain_name, {
                "selected_niche": niche_name,
                "template_type": template_type,
                "depth": density,
                "discovery_answers": discovery_answers,
                "profile_slug": profile_slug,
            }, db)
            log_context_event(domain_name, "build_started", f"Building package for niche '{niche_name}', depth={density}, template={template_type}, profile={profile_slug}", db)
        except Exception as ctx_err:
            logger.warning(f"Context engine hook failed (non-fatal): {ctx_err}")

        domain_record = db.query(Domain).filter(Domain.domain == domain_name).first()
        if not domain_record or not domain_record.analysis:
            update_job(job_id, status="failed", current_step="Domain not found or not analyzed", error="Domain not found")
            return

        niche_data = None
        for niche in domain_record.analysis.get("niches", []):
            if niche.get("name", "").lower() == niche_name.lower():
                niche_data = niche
                break

        if niche_data:
            try:
                update_project_state(domain_name, {"niche_data": niche_data}, db)
            except Exception:
                pass

        bk_context = ""
        kit = db.query(BrandKit).filter(BrandKit.domain == domain_name, BrandKit.status == "ready").first()
        if kit:
            img_class = kit.image_classifications or []
            bk_context = build_brandkit_context(kit.extracted or {}, img_class, domain_name)
            logger.info(f"Brand kit loaded for {domain_name}: {len(bk_context)} chars of context")

        assembled_context = ""
        try:
            assembled_context = get_full_context_for_domain(domain_name, db)
            if assembled_context:
                logger.info(f"Context engine assembled {len(assembled_context)} chars for {domain_name}")
        except Exception as ctx_err:
            logger.warning(f"Context assembly failed (non-fatal): {ctx_err}")

        def progress_cb(step_text, completed, total):
            step_key = BUILD_STEPS[min(completed, len(BUILD_STEPS)-1)]["key"] if completed < len(BUILD_STEPS) else "complete"
            update_job(job_id, current_step=step_text, steps_completed=completed,
                       total_steps=len(BUILD_STEPS) - 1, current_step_key=step_key)

        package = build_package(
            domain_name, niche_name, niche_data,
            progress_callback=progress_cb,
            discovery_context=discovery_answers,
            template_type=template_type,
            blueprint=blueprint,
            brandkit_context=bk_context,
            assembled_context=assembled_context
        )

        hero_image_data = package.pop("hero_image_data", None)
        hero_image_url = None
        if hero_image_data:
            img_filename = f"{domain_name.replace('.', '_')}_hero.png"
            img_path = os.path.join("static", "images", img_filename)
            with open(img_path, "wb") as f:
                f.write(hero_image_data)
            hero_image_url = f"/static/images/{img_filename}"

        is_legendary = (density == "legendary") if density else False
        calculators_data = None
        reference_data = None
        luxury_tier = "legendary" if is_legendary else "standard"

        if is_legendary:
            brand_info = package.get("brand", {})
            brand_colors = {}
            opts = brand_info.get("options", [])
            rec = brand_info.get("recommended", 0)
            if opts and rec < len(opts):
                selected = opts[rec]
                brand_colors = {
                    "primary": selected.get("color_primary", brand_info.get("color_primary", "#6366f1")),
                    "secondary": selected.get("color_secondary", brand_info.get("color_secondary", "#8b5cf6")),
                    "accent": selected.get("color_accent", brand_info.get("color_accent", "#f59e0b")),
                }

            try:
                update_job(job_id, current_step="Generating interactive calculators...",
                           steps_completed=5, current_step_key="calculators")
                from app.services.calculator_generator import generate_all_calculators
                calculators_data = generate_all_calculators(niche_name, niche_data, brand_colors)
                logger.info(f"Generated {len(calculators_data.get('specs', []))} calculators for {domain_name}")
            except Exception as calc_err:
                logger.warning(f"Calculator generation failed for {domain_name} (non-fatal): {calc_err}")
                calculators_data = None

            try:
                update_job(job_id, current_step="Building niche reference library...",
                           steps_completed=6, current_step_key="reference")
                from app.services.reference_library import generate_full_reference_library
                def ref_progress(msg, step, total):
                    update_job(job_id, current_step=f"Reference library: {msg}")
                reference_data = generate_full_reference_library(niche_name, niche_data, progress_callback=ref_progress)
                entry_count = reference_data.get("metadata", {}).get("total_entries", 0) if reference_data else 0
                logger.info(f"Generated reference library for {domain_name}: {entry_count} entries")
            except Exception as ref_err:
                logger.warning(f"Reference library generation failed for {domain_name} (non-fatal): {ref_err}")
                reference_data = None

        update_job(job_id, current_step="Saving package to database...",
                   steps_completed=7 if is_legendary else 5, current_step_key="saving")

        existing_pkg = db.query(Package).filter(
            Package.domain_name == domain_name, Package.chosen_niche == niche_name
        ).first()

        if existing_pkg:
            existing_pkg.brand = package.get("brand", {})
            existing_pkg.site_copy = package.get("site_copy", {})
            existing_pkg.sales_letter = package.get("sales_letter", "")
            existing_pkg.hero_image_url = hero_image_url
            existing_pkg.template_type = template_type
            existing_pkg.layout_style = layout_style
            existing_pkg.density = density
            existing_pkg.discovery_answers = discovery_answers
            existing_pkg.calculators = calculators_data
            existing_pkg.reference_library = reference_data
            existing_pkg.luxury_tier = luxury_tier
            existing_pkg.created_at = datetime.datetime.utcnow()
            db.commit()
            db.refresh(existing_pkg)
            pkg_id = existing_pkg.id
        else:
            new_pkg = Package(
                domain_id=domain_record.id, domain_name=domain_name, chosen_niche=niche_name,
                brand=package.get("brand", {}), site_copy=package.get("site_copy", {}),
                sales_letter=package.get("sales_letter", ""), hero_image_url=hero_image_url,
                template_type=template_type, layout_style=layout_style, density=density,
                discovery_answers=discovery_answers,
                calculators=calculators_data,
                reference_library=reference_data,
                luxury_tier=luxury_tier,
            )
            db.add(new_pkg)
            db.commit()
            db.refresh(new_pkg)
            pkg_id = new_pkg.id

        try:
            kit = db.query(BrandKit).filter(BrandKit.domain == domain_name, BrandKit.status == "ready").first()
            if kit:
                site_copy = package.get("site_copy", {})
                site_section_keys = set(site_copy.keys())
                kit_assets = db.query(BrandKitAsset).filter(BrandKitAsset.brand_kit_id == kit.id).all()
                for asset in kit_assets:
                    if asset.asset_type == "image" and asset.classification:
                        mapped = CLASSIFICATION_TO_SECTIONS.get(asset.classification, [])
                        used = [s for s in mapped if s in site_section_keys]
                        asset.used_in_sections = used if used else None
                    elif asset.asset_type == "document":
                        doc_fields = {"mission_statement": ["hero", "about"], "about_content": ["about"], "testimonials": ["testimonials", "social_proof"], "team_info": ["team"], "product_services": ["features", "pricing"], "core_values": ["about", "hero"], "faq_content": ["faq"], "statistics": ["stats"], "pricing_info": ["pricing"], "competitive_advantages": ["features", "comparison"]}
                        used = []
                        extracted = kit.extracted or {}
                        for field_key, sections in doc_fields.items():
                            val = extracted.get(field_key)
                            if val and ((isinstance(val, str) and len(val) > 5) or (isinstance(val, list) and len(val) > 0)):
                                used.extend([s for s in sections if s in site_section_keys])
                        asset.used_in_sections = list(set(used)) if used else None
                db.commit()
                logger.info(f"Used-in-sections mapped for {domain_name} brand kit assets")
        except Exception as e:
            logger.error(f"Used-in-sections mapping failed for {domain_name}: {e}")

        try:
            log_context_event(domain_name, "build_completed", f"Package built successfully for '{niche_name}' (pkg_id={pkg_id})", db)
        except Exception:
            pass

        update_job(job_id, status="completed", current_step="Package complete!",
                   steps_completed=len(BUILD_STEPS) - 1, total_steps=len(BUILD_STEPS) - 1,
                   current_step_key="complete",
                   result={"id": pkg_id, "domain": domain_name, "niche": niche_name})

    except Exception as e:
        logger.error(f"Build job failed for {domain_name}: {e}")
        update_job(job_id, status="failed", current_step=f"Error: {str(e)}", error=str(e))
    finally:
        db.close()


def run_augment_suggest_job(job_id: str, domain_name: str):
    db = SessionLocal()
    try:
        update_job(job_id, status="running", current_step="Loading niche data...",
                   steps_completed=0, current_step_key="init")

        package = db.query(Package).filter(Package.domain_name == domain_name).order_by(Package.created_at.desc()).first()
        if not package:
            update_job(job_id, status="failed", current_step="Package not found", error="Package not found")
            return

        domain_record = db.query(Domain).filter(Domain.id == package.domain_id).first()
        niche_data = None
        if domain_record and domain_record.analysis:
            for niche in domain_record.analysis.get("niches", []):
                if niche.get("name", "").lower() == package.chosen_niche.lower():
                    niche_data = niche
                    break

        update_job(job_id, current_step="AI analyzing niche for widget suggestions...",
                   steps_completed=1, current_step_key="ai_suggest")

        suggestions = suggest_augments(package.chosen_niche, niche_data)

        update_job(job_id, status="completed", current_step="Suggestions ready!",
                   steps_completed=len(AUGMENT_SUGGEST_STEPS) - 1,
                   current_step_key="complete",
                   result={"suggestions": suggestions})

    except Exception as e:
        logger.error(f"Augment suggest job failed for {domain_name}: {e}")
        update_job(job_id, status="failed", current_step=f"Error: {str(e)}", error=str(e))
    finally:
        db.close()


def run_augment_generate_job(job_id: str, domain_name: str, augment_type: str, title: str, custom_instructions: str = "", suggestion_meta: dict = None):
    db = SessionLocal()
    try:
        update_job(job_id, status="running", current_step="Loading brand and niche data...",
                   steps_completed=0, current_step_key="init")

        package = db.query(Package).filter(Package.domain_name == domain_name).order_by(Package.created_at.desc()).first()
        if not package:
            update_job(job_id, status="failed", current_step="Package not found", error="Package not found")
            return

        domain_record = db.query(Domain).filter(Domain.id == package.domain_id).first()
        niche_data = None
        if domain_record and domain_record.analysis:
            for niche in domain_record.analysis.get("niches", []):
                if niche.get("name", "").lower() == package.chosen_niche.lower():
                    niche_data = niche
                    break

        if not title:
            type_info = get_augment_types().get(augment_type, {})
            title = f"{package.chosen_niche} {type_info.get('label', 'Widget')}"

        update_job(job_id, current_step=f"AI generating {title}...",
                   steps_completed=1, current_step_key="ai_generate")

        result = generate_augment_html(
            augment_type=augment_type,
            title=title,
            niche=package.chosen_niche,
            brand_data=package.brand,
            niche_data=niche_data,
            custom_instructions=custom_instructions
        )

        update_job(job_id, current_step="Saving augment to database...",
                   steps_completed=2, current_step_key="saving")

        aug_config = result["config"] or {}
        if suggestion_meta:
            aug_config["target_audience"] = suggestion_meta.get("target_audience", "")
            aug_config["benefit_level"] = suggestion_meta.get("benefit_level", 3)
            aug_config["benefit_reason"] = suggestion_meta.get("benefit_reason", "")
            aug_config["category"] = suggestion_meta.get("category", "engagement")

        augment = Augment(
            package_id=package.id,
            domain_name=domain_name,
            augment_type=augment_type,
            title=result["title"],
            description=result["description"],
            config=aug_config,
            html_content=result["html_content"],
        )
        db.add(augment)

        rev = PackageRevision(
            package_id=package.id, revision_type="augment", section_key=augment_type,
            action="generate", description=f"Generated augment: {title}",
            after_data={"type": augment_type, "title": title},
        )
        db.add(rev)
        db.commit()
        db.refresh(augment)

        update_job(job_id, status="completed", current_step="Widget ready!",
                   steps_completed=len(AUGMENT_GENERATE_STEPS) - 1,
                   current_step_key="complete",
                   result={"id": augment.id, "type": augment_type, "title": augment.title,
                           "description": augment.description, "domain": domain_name})

    except Exception as e:
        logger.error(f"Augment generate job failed for {domain_name}: {e}")
        update_job(job_id, status="failed", current_step=f"Error: {str(e)}", error=str(e))
    finally:
        db.close()


def run_auto_augment_for_domain(domain_name: str, min_augments: int = 2, max_augments: int = 4) -> dict:
    """Suggest + generate augments for a domain to bring it up to min_augments (hard cap: max_augments).
    Idempotent: re-counts inside a fresh session before generating to avoid race-condition overdraft."""
    db = SessionLocal()
    try:
        package = db.query(Package).filter(Package.domain_name == domain_name).order_by(Package.created_at.desc()).first()
        if not package:
            return {"domain": domain_name, "status": "skipped", "reason": "no_package"}

        current_count = db.query(Augment).filter(Augment.package_id == package.id).count()
        if current_count >= max_augments:
            return {"domain": domain_name, "status": "skipped", "reason": "maxed", "count": current_count}

        needed = min(min_augments - current_count, max_augments - current_count)
        if needed <= 0:
            return {"domain": domain_name, "status": "skipped", "reason": "sufficient", "count": current_count}

        domain_record = db.query(Domain).filter(Domain.id == package.domain_id).first()
        niche_data = None
        if domain_record and domain_record.analysis:
            for niche in domain_record.analysis.get("niches", []):
                if niche.get("name", "").lower() == package.chosen_niche.lower():
                    niche_data = niche
                    break

        suggestions = suggest_augments(package.chosen_niche, niche_data)

        existing_types = {a.augment_type for a in db.query(Augment).filter(Augment.package_id == package.id).all()}
        suggestions = [s for s in suggestions if s.get("type") not in existing_types]
        suggestions = sorted(suggestions, key=lambda x: x.get("benefit_level", 0), reverse=True)[:needed]

        jobs_started = []
        for s in suggestions:
            jid = str(uuid.uuid4())[:8]
            create_job(jid, "augment_generate", domain_name, len(AUGMENT_GENERATE_STEPS) - 1, AUGMENT_GENERATE_STEPS)
            job_executor.submit(run_augment_generate_job, jid, domain_name, s.get("type", "estimator"), s.get("title", ""), "", s)
            jobs_started.append(jid)

        return {"domain": domain_name, "status": "queued", "count_before": current_count, "jobs": jobs_started, "generating": len(jobs_started)}
    except Exception as e:
        logger.error(f"Auto-augment failed for {domain_name}: {e}")
        return {"domain": domain_name, "status": "error", "error": str(e)}
    finally:
        db.close()


@app.get("/healthz")
async def healthz():
    db_ok = False
    try:
        db = SessionLocal()
        db.execute(text("SELECT 1"))
        db_ok = True
        db.close()
    except Exception:
        pass
    return {
        "status": "ok" if db_ok else "degraded",
        "database": "connected" if db_ok else "unreachable",
    }


@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
    db = SessionLocal()
    try:
        user_count = db.query(AuraUser).count()
        setup_mode = user_count == 0
    finally:
        db.close()
    error = request.query_params.get("error")
    return templates.TemplateResponse("login.html", {
        "request": request,
        "setup_mode": setup_mode,
        "error": error,
        "username": "",
    })


@app.post("/login")
async def login_submit(request: Request):
    form = await request.form()
    username = form.get("username", "").strip()
    password = form.get("password", "")

    db = SessionLocal()
    try:
        user_count = db.query(AuraUser).count()
        if user_count == 0:
            password_confirm = form.get("password_confirm", "")
            if not username or not password:
                return RedirectResponse(url="/login?error=Username+and+password+required", status_code=302)
            if password != password_confirm:
                return RedirectResponse(url="/login?error=Passwords+do+not+match", status_code=302)
            if len(password) < 4:
                return RedirectResponse(url="/login?error=Password+must+be+at+least+4+characters", status_code=302)
            user = AuraUser(username=username, is_admin=True)
            user.set_password(password)
            db.add(user)
            setting = AppSettings(key="login_enabled", value="true")
            db.add(setting)
            db.commit()
            request.session["username"] = username
            request.session["is_admin"] = True
            user.last_login = datetime.datetime.utcnow()
            db.commit()
            return RedirectResponse(url="/", status_code=302)

        user = db.query(AuraUser).filter(AuraUser.username == username).first()
        if not user or not user.check_password(password):
            return RedirectResponse(url="/login?error=Invalid+username+or+password", status_code=302)

        request.session["username"] = username
        request.session["is_admin"] = user.is_admin
        user.last_login = datetime.datetime.utcnow()
        db.commit()
        return RedirectResponse(url="/", status_code=302)
    finally:
        db.close()


_DEV_PREVIEW_TOKEN = "N5G4K8fWLY9MrapEkZnw_g"


@app.get("/_dev_preview/{token}/static-storefront", response_class=HTMLResponse)
async def dev_preview_static_storefront_early(token: str, db: Session = Depends(get_db)):
    if token != _DEV_PREVIEW_TOKEN:
        raise HTTPException(status_code=403)
    from app.services.storefront_generator import generate_static_storefront
    html = generate_static_storefront(db, base_url="", contact_email="")
    return HTMLResponse(content=html)


@app.get("/_dev_preview/{token}/{domain}/admin", response_class=HTMLResponse)
async def dev_preview_admin(request: Request, token: str, domain: str, db: Session = Depends(get_db)):
    if token != _DEV_PREVIEW_TOKEN:
        raise HTTPException(status_code=403, detail="Invalid preview token")
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="No package found for this domain")
    brand = package.brand or {}
    recommended_idx = brand.get("recommended", 0)
    options = brand.get("options", [])
    chosen_brand = options[recommended_idx] if options and recommended_idx < len(options) else {"name": domain, "tagline": ""}
    augments = db.query(Augment).filter(Augment.domain_name == domain).order_by(Augment.created_at).all()
    brand_kit_assets = []
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if kit:
        brand_kit_assets = db.query(BrandKitAsset).filter(BrandKitAsset.brand_kit_id == kit.id).all()
    from app.services.graphics import flatten_pack_assets
    graphics_assets = flatten_pack_assets(package.graphics_pack or {})
    business_docs = (package.business_box or {}).get("documents", {})
    if not isinstance(business_docs, dict):
        business_docs = {}
    manifest = {"index.html": 1, "admin.html": 1}
    if package.sales_letter:
        manifest["sales.html"] = 1
    for aug in augments:
        manifest[f"tools/{_augment_slug(aug.id, aug.title)}.html"] = 1
    for dk, dv in business_docs.items():
        tier_slug = (dv.get("tier", "general")).lower().replace(" ", "-")
        manifest[f"docs/{tier_slug}/{dk}.html"] = 1
    if package.hero_image_url:
        manifest[f"images/{os.path.basename(package.hero_image_url)}"] = 1
    for ga in graphics_assets:
        fn = ga.get("filename", os.path.basename(ga.get("url", "")))
        manifest[f"images/graphics/{fn}"] = 1
    for bka in brand_kit_assets:
        cls = bka.classification or "uncategorized"
        manifest[f"assets/brand-kit/{cls}/{bka.filename}"] = 1
    style_tier = getattr(package, "style_tier", None) or "premium"
    from app.services.standalone_renderer import render_standalone_admin
    try:
        admin_html = render_standalone_admin(
            domain, chosen_brand, brand, package.site_copy or {}, package,
            augments, brand_kit_assets, business_docs, graphics_assets, manifest,
            tier=style_tier
        )
    except Exception:
        admin_html = generate_standalone_admin_html(
            domain, chosen_brand, brand, package.site_copy or {}, package,
            augments, brand_kit_assets, business_docs, graphics_assets, manifest
        )
    return HTMLResponse(admin_html)


@app.get("/_dev_preview/{token}/{domain}/standalone", response_class=HTMLResponse)
async def dev_preview_standalone(request: Request, token: str, domain: str, db: Session = Depends(get_db)):
    if token != _DEV_PREVIEW_TOKEN:
        raise HTTPException(status_code=403, detail="Invalid preview token")
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="No package found")
    brand = package.brand or {}
    recommended_idx = brand.get("recommended", 0)
    options = brand.get("options", [])
    chosen_brand = options[recommended_idx] if options and recommended_idx < len(options) else {"name": domain, "tagline": ""}
    augments = db.query(Augment).filter(Augment.domain_name == domain).order_by(Augment.created_at).all()
    style_tier = getattr(package, "style_tier", None) or "premium"
    hero_image_url = package.hero_image_url
    try:
        kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
        if kit:
            hero_assets = db.query(BrandKitAsset).filter(
                BrandKitAsset.brand_kit_id == kit.id,
                BrandKitAsset.classification.in_(["hero_banner", "lifestyle", "background_texture"])
            ).all()
            if hero_assets:
                bk_path = hero_assets[0].file_path
                if bk_path and os.path.exists(bk_path.lstrip("/")):
                    hero_image_url = bk_path
    except Exception:
        pass
    from app.services.standalone_renderer import render_standalone_site
    site_desig = package.site_designations or {}
    html = render_standalone_site(domain, chosen_brand, brand, package.site_copy or {}, hero_image_url, augments=augments, tier=style_tier, designations=site_desig, template_type=package.template_type)
    return HTMLResponse(html)


@app.get("/_dev_preview/{token}/{domain}/sales", response_class=HTMLResponse)
async def dev_preview_sales(request: Request, token: str, domain: str, db: Session = Depends(get_db)):
    if token != _DEV_PREVIEW_TOKEN:
        raise HTTPException(status_code=403, detail="Invalid preview token")
    request.session["username"] = "_dev_preview"
    return await sales_letter_view(request, domain, db)


@app.get("/_dev_preview/{token}/{domain}/{scroll_to}", response_class=HTMLResponse)
@app.get("/_dev_preview/{token}/{domain}", response_class=HTMLResponse)
async def dev_preview(request: Request, token: str, domain: str, scroll_to: str = None, db: Session = Depends(get_db)):
    if token != _DEV_PREVIEW_TOKEN:
        raise HTTPException(status_code=403, detail="Invalid preview token")
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="No package found for this domain")
    from app.services.aura import _normalize_site_copy
    from app.services.validators import validate_site_copy, validate_brand
    brand = package.brand or {}
    site_copy = _normalize_site_copy(package.site_copy or {})
    site_copy, copy_report = validate_site_copy(site_copy, auto_repair=True)
    brand, brand_report = validate_brand(brand, auto_repair=True)
    recommended_idx = brand.get("recommended", 0)
    options = brand.get("options", [])
    chosen_brand = options[recommended_idx] if options and recommended_idx < len(options) else {"name": domain, "tagline": ""}
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    section_assets = {}
    if kit:
        kit_assets = db.query(BrandKitAsset).filter(BrandKitAsset.brand_kit_id == kit.id).all()
        section_assets = resolve_assets_for_sections(kit_assets)
    gp = package.graphics_pack or {}
    from app.services.graphics import flatten_pack_assets
    gp_flat = flatten_pack_assets(gp)
    if gp_flat:
        gp_as_dicts = [{
            "id": a.get("asset_id"),
            "filename": a.get("filename", ""),
            "file_path": a.get("url", ""),
            "classification": a.get("classification", "other"),
            "asset_type": "image",
            "ai_description": a.get("display_name", ""),
            "tags": ["graphics-pack"],
        } for a in gp_flat]
        gp_section_assets = resolve_assets_for_sections(gp_as_dicts)
        for sec, assets_list in gp_section_assets.items():
            if sec not in section_assets:
                section_assets[sec] = assets_list
            else:
                existing_files = {a["filename"] for a in section_assets[sec]}
                for a in assets_list:
                    if a["filename"] not in existing_files:
                        section_assets[sec].append(a)
    from app.services.theme import generate_theme_config, generate_theme_css
    brand_tone = ""
    if kit and kit.summary:
        brand_tone = (kit.summary or {}).get("tone", "")
    theme = generate_theme_config(
        primary=brand.get("color_primary", "#4F46E5"),
        secondary=brand.get("color_secondary", "#7C3AED"),
        accent=brand.get("color_accent", "#06B6D4"),
        niche=package.chosen_niche or "",
        brand_tone=brand_tone,
        brand_data=brand,
        atmosphere=package.atmosphere,
    )
    theme_css = generate_theme_css(theme)
    augments = db.query(Augment).filter(Augment.domain_name == domain).order_by(Augment.created_at).all()
    resp = templates.TemplateResponse("site.html", {
        "request": request, "domain": domain, "brand": chosen_brand,
        "brand_data": brand, "brand_options": options, "site_copy": site_copy,
        "package": package, "hero_image_url": package.hero_image_url,
        "template_type": package.template_type or "hero",
        "layout_style": package.layout_style or "single-scroll",
        "density": package.density or "balanced",
        "section_assets": section_assets,
        "theme": theme, "theme_css": theme_css,
        "augments": augments,
        "graphics_pack": package.graphics_pack or {},
    })
    if scroll_to:
        import re
        safe_id = re.sub(r'[^a-zA-Z0-9_-]', '', scroll_to)
        body = resp.body.decode()
        scroll_script = f'<script>window.addEventListener("load",function(){{var el=document.getElementById("{safe_id}");if(el)el.scrollIntoView()}})</script>'
        body = body.replace('</body>', scroll_script + '</body>')
        return HTMLResponse(body)
    return resp

@app.get("/logout")
async def logout(request: Request):
    request.session.clear()
    return RedirectResponse(url="/login", status_code=302)


@app.get("/admin", response_class=HTMLResponse)
async def admin_page(request: Request, db: Session = Depends(get_db)):
    if not request.session.get("is_admin"):
        raise HTTPException(status_code=403, detail="Admin access required")
    users = db.query(AuraUser).order_by(AuraUser.created_at).all()
    login_setting = db.query(AppSettings).filter(AppSettings.key == "login_enabled").first()
    login_enabled = login_setting.value == "true" if login_setting else False
    return templates.TemplateResponse("admin.html", {
        "request": request,
        "users": users,
        "login_enabled": login_enabled,
        "current_user": request.session.get("username"),
        "current_page": "admin", "current_domain": "",
    })


@app.get("/_dev_admin/{token}/roadmap", response_class=HTMLResponse)
@app.get("/admin/roadmap", response_class=HTMLResponse)
async def platform_roadmap_page(request: Request, token: str = None):
    if token and token != _DEV_PREVIEW_TOKEN:
        return HTMLResponse("Unauthorized", status_code=403)
    if not token and not request.session.get("is_admin"):
        raise HTTPException(status_code=403, detail="Admin access required")
    return templates.TemplateResponse("admin_roadmap.html", {
        "request": request,
        "current_page": "roadmap", "current_domain": "",
    })

@app.get("/_dev_admin/{token}/valuation-thesis", response_class=HTMLResponse)
@app.get("/admin/valuation-thesis", response_class=HTMLResponse)
async def valuation_thesis_page(request: Request, token: str = None, db: Session = Depends(get_db)):
    if token and token != _DEV_PREVIEW_TOKEN:
        return HTMLResponse("Unauthorized", status_code=403)
    if not token and not request.session.get("is_admin"):
        raise HTTPException(status_code=403, detail="Admin access required")
    stats = _compute_mastermind_summary(db)
    stats["avg_per_domain"] = round(stats["total_portfolio_value"] / max(stats["analyzed"], 1))
    return templates.TemplateResponse("valuation_thesis.html", {
        "request": request,
        "current_page": "valuation_thesis", "current_domain": "",
        "stats": stats,
    })

@app.get("/admin/llm-strategy", response_class=HTMLResponse)
async def llm_strategy_page(request: Request):
    if not request.session.get("is_admin"):
        raise HTTPException(status_code=403, detail="Admin access required")
    return templates.TemplateResponse("llm_strategy.html", {
        "request": request,
        "current_page": "llm_strategy", "current_domain": "",
    })


@app.get("/_dev_admin/{token}/llm-routing", response_class=HTMLResponse)
@app.get("/admin/llm-routing", response_class=HTMLResponse)
async def llm_routing_page(request: Request, token: str = None):
    if token and token != _DEV_PREVIEW_TOKEN:
        return HTMLResponse("Unauthorized", status_code=403)
    routing_data = get_routing_dashboard_data()
    return templates.TemplateResponse("llm_routing.html", {
        "request": request,
        "current_page": "llm_routing",
        "current_domain": "",
        "routing_data": routing_data,
    })


@app.get("/api/llm-routing")
async def get_llm_routing():
    return get_routing_dashboard_data()


@app.post("/api/llm-routing/{stage}")
async def set_llm_route(stage: str, request: Request):
    body = await request.json()
    provider = body.get("provider")
    model = body.get("model")
    if not provider or not model:
        return {"error": "provider and model required"}
    try:
        result = set_route(stage, provider, model)
        return {"success": True, "route": result}
    except (KeyError, ValueError) as e:
        return {"error": str(e)}


@app.delete("/api/llm-routing/{stage}")
async def reset_llm_route(stage: str):
    reset_route(stage)
    return {"success": True, "route": get_all_routes().get(stage)}


def _extract_pkg_data(pkg) -> dict:
    """Extract marketplace-compatible data from a Package ORM object.

    Package model stores brand identity nested in pkg.brand JSON,
    business docs in pkg.business_box JSON. This helper correctly
    unpacks those nested structures for marketplace scoring/pricing.
    """
    brand = pkg.brand or {}
    brand_identity = {}
    if isinstance(brand, dict):
        options = brand.get("options", [])
        rec_idx = brand.get("recommended_idx", 0)
        if options and isinstance(options, list) and len(options) > rec_idx:
            brand_identity = options[rec_idx] if isinstance(options[rec_idx], dict) else {}

    business_box = pkg.business_box or {}
    business_docs = []
    if isinstance(business_box, dict):
        for tier_data in business_box.values():
            if isinstance(tier_data, list):
                business_docs.extend(tier_data)
            elif isinstance(tier_data, dict):
                business_docs.append(tier_data)

    atmosphere = pkg.atmosphere or {}

    result_extra = {}
    if pkg.result_json:
        try:
            rj = json.loads(pkg.result_json) if isinstance(pkg.result_json, str) else (pkg.result_json or {})
            for key in ("market_research", "competitor_analysis", "seo_strategy", "ad_copy", "ad_copy_suite", "email_sequences"):
                if rj.get(key):
                    mapped_key = "ad_copy" if key == "ad_copy_suite" else key
                    result_extra[mapped_key] = rj[key]
        except (json.JSONDecodeError, TypeError):
            pass

    data = {
        "domain": pkg.domain_name,
        "niche": pkg.chosen_niche,
        "brand_name": brand_identity.get("brand_name") or brand_identity.get("name"),
        "tagline": brand_identity.get("tagline"),
        "brand_colors": brand_identity.get("brand_colors") or brand_identity.get("colors"),
        "site_copy": pkg.site_copy or {},
        "hero_image_url": pkg.hero_image_url,
        "sales_letter": pkg.sales_letter,
        "theme": atmosphere.get("theme") if isinstance(atmosphere, dict) else None,
        "business_docs": business_docs,
        "graphics_pack": pkg.graphics_pack or [],
        "deployed": False,
    }
    data.update(result_extra)
    return data


@app.get("/api/marketplace/summary/{domain_name}")
async def marketplace_summary(domain_name: str, request: Request, db: Session = Depends(get_db)):
    if not request.session.get("username"):
        raise HTTPException(status_code=401, detail="Auth required")
    from app.services.marketplace import get_marketplace_summary
    pkg = db.query(Package).filter(Package.domain_name == domain_name).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")
    return get_marketplace_summary(_extract_pkg_data(pkg))


@app.get("/api/marketplace/pricing/{domain_name}")
async def marketplace_pricing(domain_name: str, tier: str = None, request: Request = None, db: Session = Depends(get_db)):
    if not request.session.get("username"):
        raise HTTPException(status_code=401, detail="Auth required")
    from app.services.marketplace import calculate_listing_price
    pkg = db.query(Package).filter(Package.domain_name == domain_name).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")
    return calculate_listing_price(_extract_pkg_data(pkg), tier)


@app.get("/api/marketplace/tiers")
async def marketplace_tiers(request: Request):
    if not request.session.get("username"):
        raise HTTPException(status_code=401, detail="Auth required")
    from app.services.marketplace import get_all_tiers
    return get_all_tiers()


@app.post("/api/marketplace/build-missing/{domain_name}")
async def marketplace_build_missing(domain_name: str, request: Request, db: Session = Depends(get_db)):
    if not request.session.get("username"):
        raise HTTPException(status_code=401, detail="Auth required")
    pkg = db.query(Package).filter(Package.domain_name == domain_name).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")
    body = {}
    try:
        body = await request.json()
    except Exception:
        pass
    components = body.get("components", [])
    job_id = str(uuid.uuid4())
    now = datetime.datetime.utcnow()
    job = Job(
        job_id=job_id, job_type="build_missing", domain=domain_name,
        status="queued", current_step="Queued...", current_step_key="init",
        steps_completed=0, total_steps=1, progress_pct=0, steps_detail=[],
        started_at=now, created_at=now, updated_at=now,
    )
    db.add(job)
    db.commit()
    threading.Thread(
        target=_run_build_missing_components,
        args=(job_id, domain_name),
        kwargs={"requested_components": components},
        daemon=True,
    ).start()
    return JSONResponse({"job_id": job_id, "status": "queued"})


@app.post("/api/marketplace/research/{domain_name}")
async def marketplace_research(domain_name: str, request: Request, db: Session = Depends(get_db)):
    if not request.session.get("username"):
        raise HTTPException(status_code=401, detail="Auth required")
    from app.services.marketplace import run_market_research
    pkg = db.query(Package).filter(Package.domain_name == domain_name).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")
    result = await run_market_research(pkg.domain_name, pkg.chosen_niche or "general")
    return result


@app.post("/api/marketplace/listing/{domain_name}")
async def marketplace_listing(domain_name: str, request: Request, db: Session = Depends(get_db)):
    if not request.session.get("username"):
        raise HTTPException(status_code=401, detail="Auth required")
    from app.services.marketplace import generate_listing_copy
    pkg = db.query(Package).filter(Package.domain_name == domain_name).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")
    data = await request.json() if request.headers.get("content-type") == "application/json" else {}
    tier = data.get("tier", "professional")
    pkg_data = _extract_pkg_data(pkg)
    result = await generate_listing_copy(pkg.domain_name, pkg.chosen_niche or "general", pkg_data, tier)
    return result


@app.get("/marketplace", response_class=HTMLResponse)
async def marketplace_page(request: Request, db: Session = Depends(get_db)):
    if not request.session.get("username"):
        return RedirectResponse("/login", status_code=302)
    from app.services.marketplace import PRICING_TIERS, PACKAGE_COMPONENTS
    packages = db.query(Package).order_by(Package.created_at.desc()).all()
    return templates.TemplateResponse("marketplace.html", {
        "request": request,
        "current_page": "marketplace",
        "tiers": PRICING_TIERS,
        "components": PACKAGE_COMPONENTS,
        "packages": packages,
    })


@app.get("/_dev_admin/{token}/marketplace", response_class=HTMLResponse)
async def dev_marketplace_page(request: Request, token: str, db: Session = Depends(get_db)):
    if token != _DEV_PREVIEW_TOKEN:
        raise HTTPException(status_code=403, detail="Invalid token")
    from app.services.marketplace import PRICING_TIERS, PACKAGE_COMPONENTS
    packages = db.query(Package).order_by(Package.created_at.desc()).all()
    return templates.TemplateResponse("marketplace.html", {
        "request": request,
        "current_page": "marketplace",
        "tiers": PRICING_TIERS,
        "components": PACKAGE_COMPONENTS,
        "packages": packages,
    })


@app.get("/admin/model-shootout", response_class=HTMLResponse)
async def model_shootout_page(request: Request):
    if not request.session.get("is_admin"):
        raise HTTPException(status_code=403, detail="Admin access required")
    return templates.TemplateResponse("model_shootout.html", {
        "request": request,
        "current_page": "model_shootout", "current_domain": "",
    })


@app.post("/api/admin/shootout/run")
async def run_shootout(request: Request, db: Session = Depends(get_db)):
    if not request.session.get("is_admin"):
        raise HTTPException(status_code=403, detail="Admin access required")
    data = await request.json()
    domain = data.get("domain", "").strip().lower()
    model_id = data.get("model_id", "")
    shootout_id = data.get("shootout_id", "")

    if not domain or not model_id:
        raise HTTPException(status_code=400, detail="Domain and model required")

    import time
    from openai import OpenAI as ShootoutOpenAI

    name_part = domain.split(".")[0] if "." in domain else domain
    tld = domain.split(".")[-1] if "." in domain else "com"

    prompt = f"""Analyze the domain name "{domain}" for business potential.

The domain name part is "{name_part}" with TLD ".{tld}".

Break down the domain into keywords and explore creative interpretations including:
- Literal meanings
- Acronyms (what could each letter stand for?)
- Cross-language meanings (Spanish, French, German, Japanese, etc.)
- Mashups and portmanteaus
- Cross-domain tie-ins
- Industry-specific interpretations

Generate 7-10 niche business ideas for this domain. For EACH niche, consider:
- Whether an affiliate program exists in that industry (list specific programs if known)
- Whether it could work as a passive/semi-passive income site
- How brandable the domain is for that niche

For each niche, provide:
- "name": short niche name (2-5 words)
- "description": 3-4 sentence description of the business concept, what it does, who it serves, and why it's viable
- "synopsis": A one-paragraph executive summary of what this business would look like
- "monetization_model": primary revenue method
- "affiliate_programs": list of specific affiliate programs or networks
- "target_audience": who would use this site
- "time_to_revenue": one of "fast", "medium", "slow"
- "valuation_band": estimated domain+business value range
- "score": 0-10 rating of viability and potential
- "requires_inventory": boolean

Return JSON in this exact format:
{{{{
  "domain": "{domain}",
  "keywords": ["keyword1", "keyword2"],
  "interpretations": ["interpretation1", "interpretation2"],
  "domain_summary": "A one-paragraph summary",
  "niches": [...]
}}}}"""

    MODEL_CONFIGS = {
        "gpt-4o": {
            "provider": "openai",
            "model": "gpt-4o",
            "base_url": os.environ.get("AI_INTEGRATIONS_OPENAI_BASE_URL"),
            "api_key": os.environ.get("AI_INTEGRATIONS_OPENAI_API_KEY"),
            "input_cost_per_m": 2.50,
            "output_cost_per_m": 10.00,
        },
        "gpt-5": {
            "provider": "openai",
            "model": "gpt-5",
            "base_url": os.environ.get("AI_INTEGRATIONS_OPENAI_BASE_URL"),
            "api_key": os.environ.get("AI_INTEGRATIONS_OPENAI_API_KEY"),
            "input_cost_per_m": 2.00,
            "output_cost_per_m": 8.00,
        },
        "gemini-2.5-flash": {
            "provider": "google",
            "model": "gemini-2.5-flash",
            "base_url": os.environ.get("AI_INTEGRATIONS_GEMINI_BASE_URL"),
            "api_key": os.environ.get("AI_INTEGRATIONS_GEMINI_API_KEY"),
            "input_cost_per_m": 0.15,
            "output_cost_per_m": 0.60,
        },
        "gemini-2.5-pro": {
            "provider": "google",
            "model": "gemini-2.5-pro",
            "base_url": os.environ.get("AI_INTEGRATIONS_GEMINI_BASE_URL"),
            "api_key": os.environ.get("AI_INTEGRATIONS_GEMINI_API_KEY"),
            "input_cost_per_m": 1.25,
            "output_cost_per_m": 10.00,
        },
        "sonar-pro": {
            "provider": "perplexity",
            "model": "sonar-pro",
            "base_url": "https://api.perplexity.ai",
            "api_key": os.environ.get("PERPLEXITY_API"),
            "input_cost_per_m": 3.00,
            "output_cost_per_m": 15.00,
        },
        "sonar-reasoning": {
            "provider": "perplexity",
            "model": "sonar-reasoning",
            "base_url": "https://api.perplexity.ai",
            "api_key": os.environ.get("PERPLEXITY_API"),
            "input_cost_per_m": 3.00,
            "output_cost_per_m": 15.00,
        },
    }

    config = MODEL_CONFIGS.get(model_id)
    if not config:
        raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")

    if not config["api_key"]:
        return {"status": "error", "model": model_id, "error": f"API key not configured for {config['provider']}"}

    start_time = time.time()
    try:
        model_client = ShootoutOpenAI(api_key=config["api_key"], base_url=config["base_url"])

        messages = [
            {"role": "system", "content": "You are a domain analysis expert specializing in identifying profitable online business niches with affiliate and monetization potential. Return ONLY valid JSON."},
            {"role": "user", "content": prompt}
        ]

        create_kwargs = {
            "model": config["model"],
            "messages": messages,
            "max_tokens": 8192,
        }
        if config["provider"] != "perplexity":
            create_kwargs["response_format"] = {"type": "json_object"}
            create_kwargs["max_completion_tokens"] = 8192
            del create_kwargs["max_tokens"]

        response = model_client.chat.completions.create(**create_kwargs)

        elapsed_ms = int((time.time() - start_time) * 1000)
        raw_content = response.choices[0].message.content or ""

        input_tokens = getattr(response.usage, 'prompt_tokens', 0) if response.usage else 0
        output_tokens = getattr(response.usage, 'completion_tokens', 0) if response.usage else 0
        total_tokens = input_tokens + output_tokens

        cost = (input_tokens / 1_000_000 * config["input_cost_per_m"]) + (output_tokens / 1_000_000 * config["output_cost_per_m"])

        citations = None
        if hasattr(response, 'citations') and response.citations:
            citations = response.citations

        parsed = None
        niche_count = 0
        avg_score = 0
        try:
            cleaned = raw_content.strip()
            if cleaned.startswith("```"):
                cleaned = cleaned.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
            start_idx = cleaned.find("{")
            end_idx = cleaned.rfind("}") + 1
            if start_idx >= 0 and end_idx > start_idx:
                parsed = json.loads(cleaned[start_idx:end_idx])
                niches = parsed.get("niches", [])
                niche_count = len(niches)
                scores = [n.get("score", 0) for n in niches if isinstance(n.get("score"), (int, float))]
                avg_score = round(sum(scores) / len(scores), 2) if scores else 0
        except Exception:
            pass

        from app.models import ModelShootoutResult
        result_record = ModelShootoutResult(
            shootout_id=shootout_id,
            domain_name=domain,
            model_name=model_id,
            provider=config["provider"],
            raw_response=raw_content[:50000],
            parsed_result=parsed,
            niche_count=niche_count,
            avg_niche_score=avg_score,
            response_time_ms=elapsed_ms,
            input_tokens=input_tokens,
            output_tokens=output_tokens,
            total_tokens=total_tokens,
            cost_estimate=round(cost, 6),
            citations=citations,
            status="success" if parsed else "parse_error",
        )
        db.add(result_record)
        db.commit()

        return {
            "status": "success" if parsed else "parse_error",
            "model": model_id,
            "provider": config["provider"],
            "response_time_ms": elapsed_ms,
            "input_tokens": input_tokens,
            "output_tokens": output_tokens,
            "total_tokens": total_tokens,
            "cost_estimate": round(cost, 6),
            "niche_count": niche_count,
            "avg_niche_score": avg_score,
            "citations": citations,
            "parsed_result": parsed,
            "raw_response": raw_content[:5000] if not parsed else None,
        }

    except Exception as e:
        elapsed_ms = int((time.time() - start_time) * 1000)
        logger.error(f"Shootout error for {model_id}: {e}")

        from app.models import ModelShootoutResult
        result_record = ModelShootoutResult(
            shootout_id=shootout_id,
            domain_name=domain,
            model_name=model_id,
            provider=config["provider"],
            error_message=str(e)[:2000],
            response_time_ms=elapsed_ms,
            status="error",
        )
        db.add(result_record)
        db.commit()

        return {
            "status": "error",
            "model": model_id,
            "provider": config["provider"],
            "error": str(e)[:500],
            "response_time_ms": elapsed_ms,
        }


@app.get("/api/admin/shootout/history")
async def shootout_history(request: Request, db: Session = Depends(get_db)):
    if not request.session.get("is_admin"):
        raise HTTPException(status_code=403, detail="Admin access required")
    from app.models import ModelShootoutResult
    recent = db.query(ModelShootoutResult).order_by(ModelShootoutResult.created_at.desc()).limit(100).all()
    grouped = {}
    for r in recent:
        if r.shootout_id not in grouped:
            grouped[r.shootout_id] = {"domain": r.domain_name, "created_at": r.created_at.isoformat() + "Z" if r.created_at else None, "results": []}
        grouped[r.shootout_id]["results"].append({
            "model": r.model_name, "provider": r.provider, "status": r.status,
            "niche_count": r.niche_count, "avg_score": r.avg_niche_score,
            "response_time_ms": r.response_time_ms, "tokens": r.total_tokens,
            "cost": r.cost_estimate, "citations": len(r.citations) if r.citations else 0,
        })
    return {"shootouts": list(grouped.values())[:20]}


@app.post("/api/admin/toggle-login")
async def toggle_login(request: Request, db: Session = Depends(get_db)):
    if not request.session.get("is_admin"):
        raise HTTPException(status_code=403, detail="Admin access required")
    setting = db.query(AppSettings).filter(AppSettings.key == "login_enabled").first()
    if not setting:
        setting = AppSettings(key="login_enabled", value="true")
        db.add(setting)
    else:
        setting.value = "false" if setting.value == "true" else "true"
    db.commit()
    return {"login_enabled": setting.value == "true"}


@app.post("/api/admin/users")
async def create_user(request: Request, db: Session = Depends(get_db)):
    if not request.session.get("is_admin"):
        raise HTTPException(status_code=403, detail="Admin access required")
    data = await request.json()
    username = data.get("username", "").strip()
    password = data.get("password", "")
    is_admin = data.get("is_admin", False)
    if not username or not password:
        raise HTTPException(status_code=400, detail="Username and password required")
    if len(password) < 4:
        raise HTTPException(status_code=400, detail="Password must be at least 4 characters")
    existing = db.query(AuraUser).filter(AuraUser.username == username).first()
    if existing:
        raise HTTPException(status_code=400, detail="Username already exists")
    user = AuraUser(username=username, is_admin=is_admin)
    user.set_password(password)
    db.add(user)
    db.commit()
    return {"id": user.id, "username": user.username, "is_admin": user.is_admin}


@app.delete("/api/admin/users/{user_id}")
async def delete_user(user_id: int, request: Request, db: Session = Depends(get_db)):
    if not request.session.get("is_admin"):
        raise HTTPException(status_code=403, detail="Admin access required")
    user = db.query(AuraUser).filter(AuraUser.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    if user.username == request.session.get("username"):
        raise HTTPException(status_code=400, detail="Cannot delete your own account")
    db.delete(user)
    db.commit()
    return {"deleted": True}


async def _render_dashboard(request: Request, db, template: str = "dashboard.html"):
    domains = db.query(Domain).order_by(Domain.created_at.desc()).all()
    deployed_domains = set()
    try:
        deploy_logs = db.query(DeploymentLog).filter(DeploymentLog.status == "completed").all()
        deployed_domains = {dl.domain for dl in deploy_logs}
    except Exception:
        pass
    bk_hero_map = {}
    try:
        bk_assets = db.query(BrandKitAsset.file_path, BrandKit.domain).join(
            BrandKit, BrandKitAsset.brand_kit_id == BrandKit.id
        ).filter(
            BrandKitAsset.asset_type == "image",
            BrandKitAsset.classification.in_(["hero_banner", "lifestyle", "background_texture"]),
            BrandKit.status == "ready",
        ).all()
        for asset_path, bk_domain in bk_assets:
            if bk_domain not in bk_hero_map:
                bk_hero_map[bk_domain] = "/" + asset_path
    except Exception:
        pass
    portfolio_data = []
    content_type_map = {
        "estimator": "calculator", "roi_calculator": "calculator",
        "assessment": "lms", "checklist": "lms", "comparison": "gallery", "configurator": "app",
    }
    for d in domains:
        if not d.analysis:
            continue
        pkg = d.packages[0] if d.packages else None
        niches = d.analysis.get("niches", []) if d.analysis else []
        top_niche = niches[0] if niches else None
        augment_types_set = set()
        if pkg and pkg.augments:
            for aug in pkg.augments:
                augment_types_set.add(aug.augment_type)
        content_icons = set()
        for at in augment_types_set:
            content_icons.add(content_type_map.get(at, "app"))
        site_copy = pkg.site_copy if pkg else None
        section_count = 0
        section_types = set()
        if site_copy and isinstance(site_copy, dict):
            sections_list = site_copy.get("sections", [])
            if sections_list and isinstance(sections_list, list):
                for sec in sections_list:
                    section_count += 1
                    st = sec.get("type", "")
                    if st:
                        section_types.add(st)
            else:
                KNOWN_SECTIONS = {"hero","features","how_it_works","problem","solution","about",
                                  "stats","testimonials","pricing","comparison","gallery",
                                  "team","resources","faq","contact","cta","cta_final","footer"}
                for key in site_copy:
                    if key in KNOWN_SECTIONS:
                        section_count += 1
                        section_types.add(key)
        brand_color = "#6366F1"
        if pkg and pkg.brand and isinstance(pkg.brand, dict):
            brand_opts = pkg.brand.get("options", [])
            rec_idx = pkg.brand.get("recommended_idx", 0)
            if brand_opts and len(brand_opts) > rec_idx:
                brand_color = brand_opts[rec_idx].get("color_primary", "#6366F1")
        has_analysis = bool(d.analysis)
        has_niche = bool(top_niche)
        has_package = pkg is not None
        has_brand = bool(pkg and pkg.brand)
        has_hero = bool(pkg and pkg.hero_image_url)
        has_sales = bool(pkg and pkg.sales_letter)
        has_augments = bool(pkg and pkg.augments and len(pkg.augments) > 0)
        is_deployed = d.domain in deployed_domains
        completion_pct = 0
        if has_analysis: completion_pct += 10
        if has_niche: completion_pct += 10
        if has_package: completion_pct += 20
        if has_brand: completion_pct += 15
        if has_hero: completion_pct += 10
        if has_sales: completion_pct += 15
        if has_augments: completion_pct += 10
        if is_deployed: completion_pct += 10
        domain_only_value = 0
        best_developed_value = 0
        best_monthly_net = 0
        try:
            val_result = valuate_domain(d.domain, d.analysis)
            domain_only_value = val_result.get("domain_only_value", 0)
            best_developed_value = val_result.get("best_developed_value", 0)
            best_monthly_net = val_result.get("best_monthly_net", 0)
        except Exception:
            pass
        decision_badges = []
        if completion_pct >= 85 and (is_deployed or (has_sales and has_hero)):
            decision_badges.append("flip-ready")
        if best_developed_value >= 5000 and completion_pct < 50:
            decision_badges.append("quick-win")
        if best_monthly_net >= 500:
            decision_badges.append("high-roi")
        if completion_pct < 40 or not has_package:
            decision_badges.append("needs-work")
        portfolio_data.append({
            "domain": d.domain,
            "first_letter": d.domain[0].upper() if d.domain else "?",
            "analyzed_at": d.analyzed_at,
            "niche_count": len(niches),
            "top_niche": top_niche.get("name", "") if top_niche else "",
            "top_score": top_niche.get("score", 0) if top_niche else 0,
            "monetization": top_niche.get("monetization_model", "") if top_niche else "",
            "revenue_speed": top_niche.get("time_to_revenue", "") if top_niche else "",
            "summary": (d.analysis.get("domain_summary", "") or "")[:120] if d.analysis else "",
            "has_package": has_package, "has_brand": has_brand, "has_hero": has_hero,
            "has_sales": has_sales, "has_augments": has_augments,
            "augment_count": len(pkg.augments) if pkg and pkg.augments else 0,
            "augment_types": list(augment_types_set), "content_icons": list(content_icons),
            "is_deployed": is_deployed, "chosen_niche": pkg.chosen_niche if pkg else "",
            "updated_at": pkg.updated_at if pkg else d.analyzed_at,
            "brand_color": brand_color,
            "hero_url": bk_hero_map.get(d.domain) or (pkg.hero_image_url if pkg else None),
            "section_count": section_count, "section_types": list(section_types),
            "completion_pct": completion_pct,
            "domain_only_value": round(domain_only_value),
            "best_developed_value": round(best_developed_value),
            "best_monthly_net": round(best_monthly_net),
            "decision_badges": decision_badges,
        })
    pf_total = len(portfolio_data)
    pf_with_pkg = sum(1 for p in portfolio_data if p["has_package"])
    pf_deployed = sum(1 for p in portfolio_data if p["is_deployed"])
    pf_with_sales = sum(1 for p in portfolio_data if p["has_sales"])
    pf_total_value = sum(p["best_developed_value"] for p in portfolio_data)
    pf_total_monthly = sum(p["best_monthly_net"] for p in portfolio_data)
    pf_avg_completion = round(sum(p["completion_pct"] for p in portfolio_data) / max(pf_total, 1))
    pf_avg_score = round(sum(p["top_score"] for p in portfolio_data) / max(pf_total, 1), 1)
    pf_high_value = sum(1 for p in portfolio_data if p["best_developed_value"] >= 5000)
    pf_flip_ready = sum(1 for p in portfolio_data if "flip-ready" in p["decision_badges"])
    portfolio_aggregates = {
        "total_domains": pf_total, "with_packages": pf_with_pkg, "deployed": pf_deployed,
        "with_sales": pf_with_sales, "total_portfolio_value": pf_total_value,
        "total_monthly_net": pf_total_monthly, "avg_completion": pf_avg_completion,
        "avg_score": pf_avg_score, "high_value_count": pf_high_value,
        "flip_ready_count": pf_flip_ready,
        "deploy_rate": round(pf_deployed / max(pf_total, 1) * 100),
        "package_rate": round(pf_with_pkg / max(pf_total, 1) * 100),
    }
    fav_setting = db.query(AppSettings).filter(AppSettings.key == "favorite_niches").first()
    favorite_niches = json.loads(fav_setting.value) if fav_setting and fav_setting.value else []
    return templates.TemplateResponse(template, {
        "request": request, "domains": domains,
        "portfolio_data": portfolio_data, "portfolio_aggregates": portfolio_aggregates,
        "favorite_niches": favorite_niches, "current_page": "dashboard", "current_domain": "",
    })


@app.get("/_dev_admin/{token}/dashboard", response_class=HTMLResponse)
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db), token: str = None):
    domains = db.query(Domain).order_by(Domain.created_at.desc()).all()
    deployed_domains = set()
    try:
        deploy_logs = db.query(DeploymentLog).filter(DeploymentLog.status == "completed").all()
        deployed_domains = {dl.domain for dl in deploy_logs}
    except Exception:
        pass

    bk_hero_map = {}
    try:
        bk_assets = db.query(BrandKitAsset.file_path, BrandKit.domain).join(
            BrandKit, BrandKitAsset.brand_kit_id == BrandKit.id
        ).filter(
            BrandKitAsset.asset_type == "image",
            BrandKitAsset.classification.in_(["hero_banner", "lifestyle", "background_texture"]),
            BrandKit.status == "ready",
        ).all()
        for asset_path, bk_domain in bk_assets:
            if bk_domain not in bk_hero_map:
                bk_hero_map[bk_domain] = "/" + asset_path
    except Exception:
        pass

    portfolio_data = []
    content_type_map = {
        "estimator": "calculator",
        "roi_calculator": "calculator",
        "assessment": "lms",
        "checklist": "lms",
        "comparison": "gallery",
        "configurator": "app",
    }

    for d in domains:
        if not d.analysis:
            continue
        pkg = d.packages[0] if d.packages else None
        niches = d.analysis.get("niches", []) if d.analysis else []
        top_niche = niches[0] if niches else None

        augment_types_set = set()
        if pkg and pkg.augments:
            for aug in pkg.augments:
                augment_types_set.add(aug.augment_type)

        content_icons = set()
        for at in augment_types_set:
            content_icons.add(content_type_map.get(at, "app"))

        site_copy = pkg.site_copy if pkg else None
        section_count = 0
        section_types = set()
        if site_copy and isinstance(site_copy, dict):
            sections_list = site_copy.get("sections", [])
            if sections_list and isinstance(sections_list, list):
                for sec in sections_list:
                    section_count += 1
                    st = sec.get("type", "")
                    if st:
                        section_types.add(st)
            else:
                KNOWN_SECTIONS = {"hero", "features", "how_it_works", "problem", "solution", "about",
                                  "stats", "testimonials", "pricing", "comparison", "gallery",
                                  "team", "resources", "faq", "contact", "cta", "cta_final", "footer"}
                for key in site_copy:
                    if key in KNOWN_SECTIONS:
                        section_count += 1
                        section_types.add(key)

        brand_color = "#6366F1"
        if pkg and pkg.brand and isinstance(pkg.brand, dict):
            brand_opts = pkg.brand.get("options", [])
            rec_idx = pkg.brand.get("recommended_idx", 0)
            if brand_opts and len(brand_opts) > rec_idx:
                brand_color = brand_opts[rec_idx].get("color_primary", "#6366F1")

        has_analysis = bool(d.analysis)
        has_niche = bool(top_niche)
        has_package = pkg is not None
        has_brand = bool(pkg and pkg.brand)
        has_hero = bool(pkg and pkg.hero_image_url)
        has_sales = bool(pkg and pkg.sales_letter)
        has_augments = bool(pkg and pkg.augments and len(pkg.augments) > 0)
        is_deployed = d.domain in deployed_domains

        completion_pct = 0
        if has_analysis:
            completion_pct += 10
        if has_niche:
            completion_pct += 10
        if has_package:
            completion_pct += 20
        if has_brand:
            completion_pct += 15
        if has_hero:
            completion_pct += 10
        if has_sales:
            completion_pct += 15
        if has_augments:
            completion_pct += 10
        if is_deployed:
            completion_pct += 10

        domain_only_value = 0
        best_developed_value = 0
        best_monthly_net = 0
        try:
            val_result = valuate_domain(d.domain, d.analysis)
            domain_only_value = val_result.get("domain_only_value", 0)
            best_developed_value = val_result.get("best_developed_value", 0)
            best_monthly_net = val_result.get("best_monthly_net", 0)
        except Exception:
            pass

        decision_badges = []
        if completion_pct >= 85 and (is_deployed or (has_sales and has_hero)):
            decision_badges.append("flip-ready")
        if best_developed_value >= 5000 and completion_pct < 50:
            decision_badges.append("quick-win")
        if best_monthly_net >= 500:
            decision_badges.append("high-roi")
        if completion_pct < 40 or not has_package:
            decision_badges.append("needs-work")

        portfolio_data.append({
            "domain": d.domain,
            "first_letter": d.domain[0].upper() if d.domain else "?",
            "analyzed_at": d.analyzed_at,
            "niche_count": len(niches),
            "top_niche": top_niche.get("name", "") if top_niche else "",
            "top_score": top_niche.get("score", 0) if top_niche else 0,
            "monetization": top_niche.get("monetization_model", "") if top_niche else "",
            "revenue_speed": top_niche.get("time_to_revenue", "") if top_niche else "",
            "summary": (d.analysis.get("domain_summary", "") or "")[:120] if d.analysis else "",
            "has_package": has_package,
            "has_brand": has_brand,
            "has_hero": has_hero,
            "has_sales": has_sales,
            "has_augments": has_augments,
            "augment_count": len(pkg.augments) if pkg and pkg.augments else 0,
            "augment_types": list(augment_types_set),
            "content_icons": list(content_icons),
            "is_deployed": is_deployed,
            "chosen_niche": pkg.chosen_niche if pkg else "",
            "updated_at": pkg.updated_at if pkg else d.analyzed_at,
            "brand_color": brand_color,
            "hero_url": bk_hero_map.get(d.domain) or (pkg.hero_image_url if pkg else None),
            "section_count": section_count,
            "section_types": list(section_types),
            "completion_pct": completion_pct,
            "domain_only_value": round(domain_only_value),
            "best_developed_value": round(best_developed_value),
            "best_monthly_net": round(best_monthly_net),
            "decision_badges": decision_badges,
        })

    pf_total = len(portfolio_data)
    pf_with_pkg = sum(1 for p in portfolio_data if p["has_package"])
    pf_deployed = sum(1 for p in portfolio_data if p["is_deployed"])
    pf_with_sales = sum(1 for p in portfolio_data if p["has_sales"])
    pf_total_value = sum(p["best_developed_value"] for p in portfolio_data)
    pf_total_monthly = sum(p["best_monthly_net"] for p in portfolio_data)
    pf_avg_completion = round(sum(p["completion_pct"] for p in portfolio_data) / max(pf_total, 1))
    pf_avg_score = round(sum(p["top_score"] for p in portfolio_data) / max(pf_total, 1), 1)
    pf_high_value = sum(1 for p in portfolio_data if p["best_developed_value"] >= 5000)
    pf_flip_ready = sum(1 for p in portfolio_data if "flip-ready" in p["decision_badges"])

    portfolio_aggregates = {
        "total_domains": pf_total,
        "with_packages": pf_with_pkg,
        "deployed": pf_deployed,
        "with_sales": pf_with_sales,
        "total_portfolio_value": pf_total_value,
        "total_monthly_net": pf_total_monthly,
        "avg_completion": pf_avg_completion,
        "avg_score": pf_avg_score,
        "high_value_count": pf_high_value,
        "flip_ready_count": pf_flip_ready,
        "deploy_rate": round(pf_deployed / max(pf_total, 1) * 100),
        "package_rate": round(pf_with_pkg / max(pf_total, 1) * 100),
    }

    fav_setting = db.query(AppSettings).filter(AppSettings.key == "favorite_niches").first()
    favorite_niches = json.loads(fav_setting.value) if fav_setting and fav_setting.value else []

    return templates.TemplateResponse("dashboard.html", {
        "request": request,
        "domains": domains,
        "portfolio_data": portfolio_data,
        "portfolio_aggregates": portfolio_aggregates,
        "favorite_niches": favorite_niches,
        "current_page": "dashboard",
        "current_domain": "",
    })


@app.get("/_dev_admin/{token}/dashboard-sidebar", response_class=HTMLResponse)
@app.get("/dashboard-sidebar", response_class=HTMLResponse)
async def dashboard_sidebar(request: Request, db: Session = Depends(get_db), token: str = None):
    return await _render_dashboard(request, db, template="dashboard_sidebar.html")


@app.post("/api/domain/{domain}/default-niche")
async def api_set_default_niche(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    niche = data.get("niche")
    dom = db.query(Domain).filter(Domain.domain == domain).first()
    if not dom:
        raise HTTPException(404, "Domain not found")
    dom.default_niche = niche
    db.commit()
    return {"status": "ok", "default_niche": dom.default_niche}


@app.get("/api/favorite-niches")
async def api_get_favorite_niches(db: Session = Depends(get_db)):
    setting = db.query(AppSettings).filter(AppSettings.key == "favorite_niches").first()
    favorites = json.loads(setting.value) if setting and setting.value else []
    return {"favorites": favorites}


@app.post("/api/favorite-niches/toggle")
async def api_toggle_favorite_niche(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    niche = data.get("niche", "").strip()
    if not niche:
        raise HTTPException(400, "Niche required")
    setting = db.query(AppSettings).filter(AppSettings.key == "favorite_niches").first()
    favorites = json.loads(setting.value) if setting and setting.value else []
    if niche in favorites:
        favorites.remove(niche)
    elif len(favorites) < 10:
        favorites.append(niche)
    else:
        raise HTTPException(400, "Max 10 favorites")
    if setting:
        setting.value = json.dumps(favorites)
    else:
        db.add(AppSettings(key="favorite_niches", value=json.dumps(favorites)))
    db.commit()
    return {"favorites": favorites}


@app.post("/api/analyze-domain")
async def api_analyze_domain(req: AnalyzeDomainRequest):
    domain_name = req.domain.strip().lower()
    if not domain_name:
        raise HTTPException(status_code=400, detail="Domain name is required")

    niche_hints = (req.niche_hints or "").strip()

    job_id = str(uuid.uuid4())[:8]
    retry_params = {"domain": domain_name, "niche_hints": niche_hints}
    create_job(job_id, "analyze", domain_name, len(ANALYSIS_STEPS) - 1, ANALYSIS_STEPS, retry_params=retry_params)

    job_executor.submit(run_analysis_job, job_id, domain_name, niche_hints)

    return {"job_id": job_id, "domain": domain_name, "status": "started"}


@app.post("/api/batch-analyze")
async def api_batch_analyze(request: Request):
    db_guard = SessionLocal()
    try:
        active_batch = db_guard.query(Job).filter(
            Job.job_type == "batch_analyze",
            Job.status.in_(["running", "pending"])
        ).first()
        if active_batch:
            return {"error": f"A batch is already running ({active_batch.job_id}). Wait for it to finish, or stop it first.", "active_batch_id": active_batch.job_id}
    finally:
        db_guard.close()

    body = await request.json()
    raw_domains = body.get("domains", [])
    niche_hints = (body.get("niche_hints", "") or "").strip()
    skip_existing = body.get("skip_existing", True)

    if isinstance(raw_domains, str):
        raw_domains = [d.strip() for d in raw_domains.replace(",", "\n").split("\n") if d.strip()]

    domains = []
    seen = set()
    for d in raw_domains:
        d = d.strip().lower()
        if d and d not in seen:
            seen.add(d)
            domains.append(d)

    if not domains:
        raise HTTPException(status_code=400, detail="No valid domains provided")
    if len(domains) > 500:
        raise HTTPException(status_code=400, detail="Maximum 500 domains per batch")

    skipped = []
    if skip_existing:
        db_check = SessionLocal()
        try:
            existing_domains = db_check.query(Domain.domain).filter(
                Domain.domain.in_(domains),
                Domain.analysis.isnot(None)
            ).all()
            existing_set = {row[0] for row in existing_domains}
            if existing_set:
                skipped = [d for d in domains if d in existing_set]
                domains = [d for d in domains if d not in existing_set]
                logger.info(f"Batch dedup: skipping {len(skipped)} already-analyzed domains")
        except Exception as e:
            logger.warning(f"Batch dedup check failed (proceeding anyway): {e}")
        finally:
            db_check.close()

    if not domains and skipped:
        return {
            "batch_id": None,
            "total": 0,
            "skipped": len(skipped),
            "skipped_domains": skipped,
            "status": "all_existing",
            "message": f"All {len(skipped)} domains already have analysis. Use 'Re-analyze All' to force re-processing."
        }

    batch_id = f"batch-{str(uuid.uuid4())[:8]}"
    create_job(batch_id, "batch_analyze", f"{len(domains)} domains", len(domains), [],
               retry_params={"domains": domains, "niche_hints": niche_hints})

    state = BatchState(batch_id)
    batch_control[batch_id] = state

    job_executor.submit(run_batch_analysis_orchestrator, batch_id, domains, niche_hints)

    return {
        "batch_id": batch_id,
        "total": len(domains),
        "skipped": len(skipped),
        "skipped_domains": skipped[:20],
        "status": "started"
    }


@app.get("/api/batch/active")
async def api_active_batches():
    db = SessionLocal()
    try:
        active = db.query(Job).filter(
            Job.job_type.in_(["batch_analyze", "batch_build"]),
            Job.status.in_(["running", "pending", "paused"])
        ).order_by(Job.created_at.desc()).limit(6).all()
        return {"batches": [j.to_dict() for j in active]}
    finally:
        db.close()


@app.post("/api/batch-build")
async def api_batch_build(request: Request):
    db_guard = SessionLocal()
    try:
        active_batch = db_guard.query(Job).filter(
            Job.job_type == "batch_build",
            Job.status.in_(["running", "pending"])
        ).first()
        if active_batch:
            return {"error": f"A batch build is already running ({active_batch.job_id}). Wait for it to finish, or stop it first.", "active_batch_id": active_batch.job_id}
    finally:
        db_guard.close()

    body = await request.json()
    domain_ids = body.get("domain_ids", [])
    domain_names = body.get("domains", [])   # JS sends domain name strings
    filters = body.get("filters", {})
    config = body.get("config", {})

    # Top-level convenience params the UI sends
    density = body.get("density", "legendary")
    skip_existing = body.get("skip_existing", False)
    config.setdefault("legendary", density == "legendary")
    config.setdefault("skip_existing_packages", skip_existing)

    # Resolve domain names → IDs if provided
    if domain_names and not domain_ids:
        _db_r = SessionLocal()
        try:
            rows = _db_r.query(Domain.id, Domain.domain).filter(Domain.domain.in_(domain_names)).all()
            domain_ids = [r.id for r in rows]
        finally:
            _db_r.close()

    if not domain_ids and not filters:
        raise HTTPException(status_code=400, detail="Provide domain_ids, domains, or filters")

    if filters and not domain_ids:
        db_f = SessionLocal()
        try:
            q = db_f.query(Domain).filter(Domain.analysis.isnot(None))
            niche_filter = filters.get("niche", "").strip().lower()
            min_value = filters.get("min_value", 0)
            max_count = filters.get("max_count", 50)
            skip_with_packages = filters.get("skip_with_packages", True)

            domains_raw = q.all()
            scored = []
            for d in domains_raw:
                niches = d.analysis.get("niches", []) if d.analysis else []
                if not niches:
                    continue
                best = max(niches, key=lambda n: n.get("viability_score", 0))
                score = best.get("viability_score", 0)
                if niche_filter and niche_filter not in best.get("name", "").lower():
                    continue
                if score < min_value:
                    continue
                if skip_with_packages:
                    has_pkg = db_f.query(Package).filter(Package.domain_name == d.domain).first()
                    if has_pkg:
                        continue
                scored.append((d.id, score))

            scored.sort(key=lambda x: x[1], reverse=True)
            domain_ids = [s[0] for s in scored[:max_count]]
        finally:
            db_f.close()

    if not domain_ids:
        return {"error": "No domains match the given filters", "total": 0}

    if len(domain_ids) > 200:
        raise HTTPException(status_code=400, detail="Maximum 200 domains per batch build")

    batch_id = f"bb-{str(uuid.uuid4())[:8]}"
    create_job(batch_id, "batch_build", f"{len(domain_ids)} packages", len(domain_ids), [],
               retry_params={"domain_ids": domain_ids, "config": config})

    state = BatchState(batch_id)
    batch_control[batch_id] = state

    from app.services.batch_package_builder import run_batch_build_orchestrator
    job_executor.submit(run_batch_build_orchestrator, batch_id, domain_ids, config)

    return {
        "batch_id": batch_id,
        "job_id": batch_id,       # alias — JS uses data.job_id
        "total": len(domain_ids),
        "config": config,
        "status": "started"
    }


@app.get("/api/batch/{batch_id}")
async def api_get_batch(batch_id: str, db: Session = Depends(get_db)):
    job = db.query(Job).filter(Job.job_id == batch_id).first()
    if not job:
        raise HTTPException(status_code=404, detail="Batch not found")
    return job.to_dict()


@app.post("/api/batch/{batch_id}/pause")
async def api_pause_batch(batch_id: str):
    state = batch_control.get(batch_id)
    if not state:
        db = SessionLocal()
        try:
            job = db.query(Job).filter(Job.job_id == batch_id).first()
            if not job:
                raise HTTPException(status_code=404, detail="Batch not found")
            if job.status in ("completed", "failed", "stopped"):
                raise HTTPException(status_code=400, detail=f"Batch already {job.status}")
        finally:
            db.close()
        raise HTTPException(status_code=400, detail="Batch orchestrator not active in this process")

    state.pause_event.clear()
    return {"batch_id": batch_id, "action": "paused"}


@app.post("/api/batch/{batch_id}/resume")
async def api_resume_batch(batch_id: str):
    state = batch_control.get(batch_id)

    if state and state.active:
        state.pause_event.set()
        return {"batch_id": batch_id, "action": "resumed"}

    db = SessionLocal()
    try:
        job = db.query(Job).filter(Job.job_id == batch_id).first()
        if not job:
            raise HTTPException(status_code=404, detail="Batch not found")

        if job.status == "completed":
            raise HTTPException(status_code=400, detail="Batch already completed")

        result = job.result or {}
        domain_list = result.get("domains", [])
        niche_hints = result.get("niche_hints", "")

        remaining = [ds["domain"] for ds in domain_list if ds["status"] in ("queued", "cancelled")]
        if not remaining:
            raise HTTPException(status_code=400, detail="No domains left to process")

        new_state = BatchState(batch_id)
        batch_control[batch_id] = new_state

        job_executor.submit(run_batch_analysis_orchestrator, batch_id, remaining, niche_hints)
        return {"batch_id": batch_id, "action": "resumed", "remaining": len(remaining)}
    finally:
        db.close()


@app.post("/api/batch/{batch_id}/stop")
async def api_stop_batch(batch_id: str):
    state = batch_control.get(batch_id)
    if state:
        state.stop_flag.set()
        state.pause_event.set()
        return {"batch_id": batch_id, "action": "stopped"}

    db = SessionLocal()
    try:
        job = db.query(Job).filter(Job.job_id == batch_id).first()
        if not job:
            raise HTTPException(status_code=404, detail="Batch not found")
        if job.status not in ("completed", "failed", "stopped"):
            update_job(batch_id, status="stopped", current_step="Batch stopped by user")
        return {"batch_id": batch_id, "action": "stopped"}
    finally:
        db.close()


@app.get("/api/jobs")
async def api_list_jobs(status: str = None, job_type: str = None, domain: str = None,
                        limit: int = 50, db: Session = Depends(get_db)):
    q = db.query(Job)
    if status:
        if status == "active":
            q = q.filter(Job.status.in_(["pending", "running", "paused"]))
        else:
            q = q.filter(Job.status == status)
    if job_type:
        q = q.filter(Job.job_type == job_type)
    if domain:
        q = q.filter(Job.domain.ilike(f"%{domain}%"))
    jobs = q.order_by(Job.created_at.desc()).limit(min(limit, 200)).all()
    return {"jobs": [j.to_dict() for j in jobs]}


# ─── Package Enhancement Endpoints ────────────────────────────────

@app.post("/api/package/{pkg_id}/generate-calculators")
async def api_generate_calculators(pkg_id: int, db: Session = Depends(get_db)):
    pkg = db.query(Package).filter(Package.id == pkg_id).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")

    domain_rec = db.query(Domain).filter(Domain.domain == pkg.domain_name).first()
    niche_data = None
    if domain_rec and domain_rec.analysis:
        for n in domain_rec.analysis.get("niches", []):
            if n.get("name", "").lower() == pkg.chosen_niche.lower():
                niche_data = n
                break

    brand_info = pkg.brand or {}
    brand_colors = {}
    opts = brand_info.get("options", [])
    rec = brand_info.get("recommended", 0)
    if opts and rec < len(opts):
        selected = opts[rec]
        brand_colors = {
            "primary": selected.get("color_primary", "#6366f1"),
            "secondary": selected.get("color_secondary", "#8b5cf6"),
            "accent": selected.get("color_accent", "#f59e0b"),
        }

    from app.services.calculator_generator import generate_all_calculators
    result = generate_all_calculators(pkg.chosen_niche, niche_data, brand_colors)
    pkg.calculators = result
    db.commit()
    return {"status": "ok", "calculator_count": len(result.get("specs", []))}


@app.post("/api/package/{pkg_id}/generate-reference-library")
async def api_generate_reference_library(pkg_id: int, db: Session = Depends(get_db)):
    pkg = db.query(Package).filter(Package.id == pkg_id).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")

    domain_rec = db.query(Domain).filter(Domain.domain == pkg.domain_name).first()
    niche_data = None
    if domain_rec and domain_rec.analysis:
        for n in domain_rec.analysis.get("niches", []):
            if n.get("name", "").lower() == pkg.chosen_niche.lower():
                niche_data = n
                break

    from app.services.reference_library import generate_full_reference_library
    result = generate_full_reference_library(pkg.chosen_niche, niche_data)
    pkg.reference_library = result
    db.commit()
    entry_count = result.get("metadata", {}).get("total_entries", 0) if result else 0
    return {"status": "ok", "entry_count": entry_count, "sections": len(result.get("sections", {}))}


@app.get("/api/package/{pkg_id}/calculators")
async def api_get_calculators(pkg_id: int, db: Session = Depends(get_db)):
    pkg = db.query(Package).filter(Package.id == pkg_id).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")
    return {"calculators": pkg.calculators, "has_calculators": pkg.calculators is not None}


@app.get("/api/package/{pkg_id}/reference-library")
async def api_get_reference_library(pkg_id: int, db: Session = Depends(get_db)):
    pkg = db.query(Package).filter(Package.id == pkg_id).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")
    return {"reference_library": pkg.reference_library, "has_library": pkg.reference_library is not None}


@app.get("/api/domains/buildable")
async def api_buildable_domains(db: Session = Depends(get_db)):
    domains = db.query(Domain).filter(Domain.analysis.isnot(None)).all()
    result = []
    for d in domains:
        niches = d.analysis.get("niches", []) if d.analysis else []
        if not niches:
            continue
        best = max(niches, key=lambda n: n.get("viability_score", 0))
        has_pkg = db.query(Package).filter(Package.domain_name == d.domain).first()
        result.append({
            "id": d.id,
            "domain": d.domain,
            "best_niche": best.get("name", ""),
            "viability_score": best.get("viability_score", 0),
            "niche_count": len(niches),
            "has_package": has_pkg is not None,
            "package_id": has_pkg.id if has_pkg else None,
            "luxury_tier": has_pkg.luxury_tier if has_pkg else None,
        })
    result.sort(key=lambda x: x["viability_score"], reverse=True)
    return {"domains": result, "total": len(result)}


# ─── Mastermind Dashboard ───────────────────────────────────────────

@app.get("/mastermind", response_class=HTMLResponse)
async def mastermind_page(request: Request, db: Session = Depends(get_db)):
    summary = _compute_mastermind_summary(db)
    listings = db.query(MarketplaceListing).order_by(MarketplaceListing.updated_at.desc()).limit(100).all()
    active_jobs = db.query(Job).filter(Job.status.in_(["pending", "running", "paused"])).order_by(Job.created_at.desc()).limit(50).all()
    recent_jobs = db.query(Job).filter(Job.status.in_(["completed", "failed", "stopped"])).order_by(Job.completed_at.desc()).limit(20).all()
    batch_jobs = db.query(Job).filter(Job.job_type.in_(["batch_analyze", "batch_build"])).order_by(Job.created_at.desc()).limit(10).all()
    return templates.TemplateResponse("mastermind.html", {
        "request": request,
        "summary": summary,
        "listings": [l.to_dict() for l in listings],
        "active_jobs": [j.to_dict() for j in active_jobs],
        "recent_jobs": [j.to_dict() for j in recent_jobs],
        "batch_jobs": [j.to_dict() for j in batch_jobs],
        "current_page": "mastermind",
    })

@app.get("/_dev_admin/{token}/mastermind", response_class=HTMLResponse)
async def dev_mastermind_page(token: str, request: Request, db: Session = Depends(get_db)):
    if token != "N5G4K8fWLY9MrapEkZnw_g":
        raise HTTPException(status_code=403, detail="Invalid token")
    summary = _compute_mastermind_summary(db)
    listings = db.query(MarketplaceListing).order_by(MarketplaceListing.updated_at.desc()).limit(100).all()
    active_jobs = db.query(Job).filter(Job.status.in_(["pending", "running", "paused"])).order_by(Job.created_at.desc()).limit(50).all()
    recent_jobs = db.query(Job).filter(Job.status.in_(["completed", "failed", "stopped"])).order_by(Job.completed_at.desc()).limit(20).all()
    batch_jobs = db.query(Job).filter(Job.job_type.in_(["batch_analyze", "batch_build"])).order_by(Job.created_at.desc()).limit(10).all()
    return templates.TemplateResponse("mastermind.html", {
        "request": request,
        "summary": summary,
        "listings": [l.to_dict() for l in listings],
        "active_jobs": [j.to_dict() for j in active_jobs],
        "recent_jobs": [j.to_dict() for j in recent_jobs],
        "batch_jobs": [j.to_dict() for j in batch_jobs],
        "current_page": "mastermind",
    })


def _compute_mastermind_summary(db):
    from app.services.valuation import valuate_domain
    domains = db.query(Domain).all()
    total_domains = len(domains)
    analyzed = sum(1 for d in domains if d.analysis)
    all_packages = db.query(Package).all()
    total_packages = len(all_packages)
    deployed_set = set()
    try:
        deploys = db.query(DeploymentLog).filter(DeploymentLog.status == "completed").all()
        deployed_set = {dl.domain for dl in deploys}
    except Exception:
        pass
    total_deployed = len(deployed_set)
    with_sales = sum(1 for p in all_packages if p.sales_letter)
    with_hero = sum(1 for p in all_packages if p.hero_image_url)
    with_graphics = sum(1 for p in all_packages if p.graphics_pack)
    with_business_box = sum(1 for p in all_packages if p.business_box)
    with_brand = sum(1 for p in all_packages if p.brand)
    with_calculators = sum(1 for p in all_packages if p.calculators)
    with_reference = sum(1 for p in all_packages if p.reference_library)

    total_value = 0
    total_monthly = 0
    for d in domains:
        if d.analysis:
            try:
                val = valuate_domain(d.domain, d.analysis)
                total_value += val.get("best_developed_value", 0)
                total_monthly += val.get("best_monthly_net", 0)
            except Exception:
                pass

    total_augments = db.query(Augment).count()

    listings = db.query(MarketplaceListing).filter(MarketplaceListing.listing_status == "active").all()
    total_listed = len(listings)
    total_asking = sum(l.asking_price or 0 for l in listings)
    total_bids = sum(l.bid_count or 0 for l in listings)
    platforms = {}
    for l in listings:
        platforms[l.platform] = platforms.get(l.platform, 0) + 1

    active_jobs_count = db.query(Job).filter(Job.status.in_(["pending", "running", "paused"])).count()

    return {
        "total_domains": total_domains,
        "analyzed": analyzed,
        "total_packages": total_packages,
        "total_deployed": total_deployed,
        "with_sales": with_sales,
        "with_hero": with_hero,
        "with_graphics": with_graphics,
        "with_business_box": with_business_box,
        "with_brand": with_brand,
        "with_calculators": with_calculators,
        "with_reference": with_reference,
        "total_augments": total_augments,
        "total_portfolio_value": round(total_value),
        "total_monthly_revenue": round(total_monthly),
        "total_listed": total_listed,
        "total_asking_price": round(total_asking),
        "total_bids": total_bids,
        "platform_breakdown": platforms,
        "active_jobs": active_jobs_count,
        "completion_rates": {
            "analysis": round(analyzed / max(total_domains, 1) * 100),
            "packages": round(total_packages / max(analyzed, 1) * 100),
            "deployed": round(total_deployed / max(total_packages, 1) * 100),
            "sales": round(with_sales / max(total_packages, 1) * 100),
            "calculators": round(with_calculators / max(total_packages, 1) * 100),
            "reference": round(with_reference / max(total_packages, 1) * 100),
        },
    }


@app.get("/api/mastermind/summary")
async def api_mastermind_summary(db: Session = Depends(get_db)):
    return _compute_mastermind_summary(db)


@app.get("/api/mastermind/portfolio-grid")
async def api_portfolio_grid(db: Session = Depends(get_db)):
    from app.services.valuation import valuate_domain
    domains = db.query(Domain).order_by(Domain.domain).all()
    packages_by_domain = {}
    for pkg in db.query(Package).order_by(Package.created_at.desc()).all():
        if pkg.domain_name not in packages_by_domain:
            packages_by_domain[pkg.domain_name] = pkg

    deployed_set = set()
    try:
        deploys = db.query(DeploymentLog).filter(DeploymentLog.status == "completed").all()
        deployed_set = {dl.domain for dl in deploys}
    except Exception:
        pass

    grid = []
    for d in domains:
        best_pkg = packages_by_domain.get(d.domain)
        val_data = {}
        niche = ""
        score = 0
        if d.analysis:
            try:
                val_data = valuate_domain(d.domain, d.analysis)
            except Exception:
                pass
            niches = d.analysis.get("niches", [])
            if niches:
                niche = niches[0].get("name", "")
                score = niches[0].get("score", 0)

        has_brand = bool(best_pkg and best_pkg.brand)
        has_copy = bool(best_pkg and best_pkg.site_copy)
        has_hero = bool(best_pkg and best_pkg.hero_image_url)
        has_sales = bool(best_pkg and best_pkg.sales_letter)
        has_calcs = bool(best_pkg and best_pkg.calculators)
        has_ref = bool(best_pkg and best_pkg.reference_library)
        has_gfx = bool(best_pkg and best_pkg.graphics_pack)
        has_bib = bool(best_pkg and best_pkg.business_box)
        is_deployed = d.domain in deployed_set

        asset_count = sum([has_brand, has_copy, has_hero, has_sales, has_calcs, has_ref, has_gfx, has_bib])
        total_assets = 8
        completeness = round(asset_count / total_assets * 100)

        grid.append({
            "domain": d.domain,
            "analyzed": bool(d.analysis),
            "niche": niche,
            "score": score,
            "pkg_id": best_pkg.id if best_pkg else None,
            "pkg_niche": best_pkg.chosen_niche if best_pkg else None,
            "value": val_data.get("best_developed_value", 0),
            "monthly": val_data.get("best_monthly_net", 0),
            "brand": has_brand,
            "copy": has_copy,
            "hero": has_hero,
            "sales": has_sales,
            "calcs": has_calcs,
            "ref": has_ref,
            "gfx": has_gfx,
            "bib": has_bib,
            "deployed": is_deployed,
            "completeness": completeness,
            "asset_count": asset_count,
            "quality_score": best_pkg.quality_score if best_pkg else None,
        })
    return {"grid": grid, "total": len(grid)}


@app.post("/api/mastermind/batch-results")
async def api_mastermind_batch_results(request: Request, db: Session = Depends(get_db)):
    body = await request.json()
    domain_names = body.get("domains", [])
    if not domain_names:
        return {"results": []}

    results = []
    for dn in domain_names:
        dn_clean = dn.strip().lower()
        domain_obj = db.query(Domain).filter(Domain.domain == dn_clean).first()
        analysis = (domain_obj.analysis or {}) if domain_obj else {}

        pkg = db.query(Package).filter(Package.domain_name == dn_clean).order_by(Package.created_at.desc()).first()

        niches_raw = analysis.get("niche_opportunities") or analysis.get("niches") or []
        niches = []
        chosen_niche = None
        if isinstance(niches_raw, list):
            for n in niches_raw:
                if isinstance(n, dict):
                    niches.append(n.get("name") or n.get("niche") or str(n))
                else:
                    niches.append(str(n))
            if niches:
                chosen_niche = niches[0]
        elif isinstance(niches_raw, str):
            chosen_niche = niches_raw

        valuation = analysis.get("valuation") or analysis.get("domain_valuation") or {}
        est_value = None
        monthly_rev = None
        if isinstance(valuation, dict):
            est_value = valuation.get("estimated_value") or valuation.get("value")
            monthly_rev = valuation.get("monthly_revenue") or valuation.get("revenue_potential")
        elif isinstance(valuation, (int, float)):
            est_value = valuation

        results.append({
            "domain": dn_clean,
            "niche": chosen_niche or (pkg.chosen_niche if pkg else None),
            "niches": niches[:6],
            "estimated_value": est_value,
            "monthly_revenue": monthly_rev,
            "has_package": pkg is not None,
            "has_site_copy": bool(pkg and pkg.site_copy),
            "has_sales_letter": bool(pkg and pkg.sales_letter),
            "analyzed": domain_obj is not None,
        })

    return {"results": results}


# ─── Marketplace Listings ───────────────────────────────────────────

@app.get("/api/marketplace-listings")
async def api_list_marketplace(domain: str = None, platform: str = None,
                                status: str = None, db: Session = Depends(get_db)):
    q = db.query(MarketplaceListing)
    if domain:
        q = q.filter(MarketplaceListing.domain.ilike(f"%{domain}%"))
    if platform:
        q = q.filter(MarketplaceListing.platform == platform)
    if status:
        q = q.filter(MarketplaceListing.listing_status == status)
    listings = q.order_by(MarketplaceListing.updated_at.desc()).limit(200).all()
    return {"listings": [l.to_dict() for l in listings]}


@app.post("/api/marketplace-listings")
async def api_create_marketplace_listing(request: Request, db: Session = Depends(get_db)):
    body = await request.json()
    domain = (body.get("domain", "") or "").strip().lower()
    platform = (body.get("platform", "") or "").strip().lower()
    if not domain or not platform:
        raise HTTPException(status_code=400, detail="domain and platform required")

    listing = MarketplaceListing(
        domain=domain,
        platform=platform,
        listing_url=body.get("listing_url", ""),
        listing_status=body.get("listing_status", "active"),
        asking_price=body.get("asking_price"),
        current_bid=body.get("current_bid"),
        bid_count=body.get("bid_count", 0),
        views=body.get("views", 0),
        watchers=body.get("watchers", 0),
        source=body.get("source", "manual"),
        notes=body.get("notes", ""),
        extra_data=body.get("extra_data"),
    )
    if body.get("listed_at"):
        try:
            listing.listed_at = datetime.datetime.fromisoformat(body["listed_at"].replace("Z", "+00:00"))
        except Exception:
            pass
    if body.get("expires_at"):
        try:
            listing.expires_at = datetime.datetime.fromisoformat(body["expires_at"].replace("Z", "+00:00"))
        except Exception:
            pass

    db.add(listing)
    db.commit()
    db.refresh(listing)
    return listing.to_dict()


@app.put("/api/marketplace-listings/{listing_id}")
async def api_update_marketplace_listing(listing_id: int, request: Request, db: Session = Depends(get_db)):
    listing = db.query(MarketplaceListing).filter(MarketplaceListing.id == listing_id).first()
    if not listing:
        raise HTTPException(status_code=404, detail="Listing not found")
    body = await request.json()
    for field in ["listing_url", "listing_status", "asking_price", "current_bid", "bid_count",
                  "views", "watchers", "source", "notes", "extra_data", "platform"]:
        if field in body:
            setattr(listing, field, body[field])
    db.commit()
    db.refresh(listing)
    return listing.to_dict()


@app.delete("/api/marketplace-listings/{listing_id}")
async def api_delete_marketplace_listing(listing_id: int, db: Session = Depends(get_db)):
    listing = db.query(MarketplaceListing).filter(MarketplaceListing.id == listing_id).first()
    if not listing:
        raise HTTPException(status_code=404, detail="Listing not found")
    db.delete(listing)
    db.commit()
    return {"deleted": True}


@app.post("/api/marketplace-listings/import-csv")
async def api_import_marketplace_csv(request: Request, db: Session = Depends(get_db)):
    import csv
    import io
    body = await request.json()
    csv_text = body.get("csv", "")
    if not csv_text:
        raise HTTPException(status_code=400, detail="csv field required")

    reader = csv.DictReader(io.StringIO(csv_text))
    imported = 0
    errors = []
    for i, row in enumerate(reader):
        try:
            domain = (row.get("domain", "") or "").strip().lower()
            platform = (row.get("platform", "") or "").strip().lower() or "manual"
            if not domain:
                errors.append(f"Row {i+1}: missing domain")
                continue
            listing = MarketplaceListing(
                domain=domain,
                platform=platform,
                listing_url=row.get("listing_url", "") or row.get("url", ""),
                listing_status=row.get("status", "active"),
                asking_price=float(row["asking_price"]) if row.get("asking_price") else None,
                current_bid=float(row["current_bid"]) if row.get("current_bid") else None,
                bid_count=int(row.get("bid_count", 0) or 0),
                views=int(row.get("views", 0) or 0),
                watchers=int(row.get("watchers", 0) or 0),
                source="csv_import",
                notes=row.get("notes", ""),
            )
            db.add(listing)
            imported += 1
        except Exception as e:
            errors.append(f"Row {i+1}: {str(e)}")
    db.commit()
    return {"imported": imported, "errors": errors}


# ─── Business Bundle Export ──────────────────────────────────────────

@app.get("/api/export/{domain}/bundle")
async def api_export_business_bundle(domain: str, db: Session = Depends(get_db)):
    import zipfile
    import io
    import json as json_module
    domain = domain.strip().lower()
    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")

    buf = io.BytesIO()
    safe = domain.replace(".", "_")

    with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
        brand = pkg.brand or {}
        brand_opts = brand.get("options", [])
        rec_idx = brand.get("recommended_idx", 0)
        brand_data = brand_opts[rec_idx] if brand_opts and len(brand_opts) > rec_idx else {}
        brand_data["brand_name"] = brand.get("brand_name", brand_data.get("name", domain))
        brand_data["tagline"] = brand.get("tagline", brand_data.get("tagline", ""))

        brand_md = f"# Brand Identity: {brand_data['brand_name']}\n\n"
        brand_md += f"**Tagline:** {brand_data['tagline']}\n\n"
        brand_md += f"**Domain:** {domain}\n\n"
        brand_md += f"**Niche:** {pkg.chosen_niche}\n\n"
        if brand_data.get("color_primary"):
            brand_md += f"**Primary Color:** {brand_data['color_primary']}\n"
        if brand_data.get("color_secondary"):
            brand_md += f"**Secondary Color:** {brand_data['color_secondary']}\n"
        if brand_data.get("font"):
            brand_md += f"**Font:** {brand_data['font']}\n"
        if brand_data.get("tone"):
            brand_md += f"**Tone:** {brand_data['tone']}\n"
        if brand_data.get("personality"):
            brand_md += f"\n## Brand Personality\n{brand_data['personality']}\n"
        if brand_data.get("value_proposition"):
            brand_md += f"\n## Value Proposition\n{brand_data['value_proposition']}\n"
        zf.writestr(f"{safe}/01_brand_identity/brand_identity.md", brand_md)
        zf.writestr(f"{safe}/01_brand_identity/brand_data.json", json_module.dumps(brand, indent=2, default=str))

        if pkg.site_copy:
            site_md = f"# Site Copy: {domain}\n\n"
            sections = pkg.site_copy.get("sections", [])
            if sections and isinstance(sections, list):
                for sec in sections:
                    sec_type = sec.get("type", "section")
                    site_md += f"\n## {sec_type.replace('_', ' ').title()}\n\n"
                    for k, v in sec.items():
                        if k == "type":
                            continue
                        if isinstance(v, str):
                            site_md += f"**{k}:** {v}\n\n"
                        elif isinstance(v, list):
                            site_md += f"**{k}:**\n"
                            for item in v:
                                if isinstance(item, dict):
                                    site_md += f"- {json_module.dumps(item, default=str)}\n"
                                else:
                                    site_md += f"- {item}\n"
                            site_md += "\n"
            zf.writestr(f"{safe}/02_site_copy/site_copy.md", site_md)
            zf.writestr(f"{safe}/02_site_copy/site_copy.json", json_module.dumps(pkg.site_copy, indent=2, default=str))

        if pkg.sales_letter:
            zf.writestr(f"{safe}/03_sales_letter/sales_letter.md", f"# Sales Letter: {domain}\n\n{pkg.sales_letter}")

        if pkg.hero_image_url:
            hero_path = None
            if pkg.hero_image_url.startswith("/static/"):
                hero_path = os.path.join("static", pkg.hero_image_url[len("/static/"):])
            elif pkg.hero_image_url.startswith("static/"):
                hero_path = pkg.hero_image_url
            if hero_path and os.path.isfile(hero_path):
                ext = os.path.splitext(hero_path)[1] or ".png"
                zf.write(hero_path, f"{safe}/05_graphics/hero{ext}")

        if pkg.graphics_pack:
            from app.services.graphics import flatten_pack_assets
            for asset in flatten_pack_assets(pkg.graphics_pack):
                url = asset.get("url", "")
                if not url:
                    continue
                file_path = None
                if url.startswith("/static/"):
                    file_path = os.path.join("static", url[len("/static/"):])
                elif url.startswith("static/"):
                    file_path = url
                if file_path and os.path.isfile(file_path):
                    fname = os.path.basename(file_path)
                    zf.write(file_path, f"{safe}/05_graphics/{fname}")

        if pkg.business_box:
            bb = pkg.business_box
            all_docs = bb.get("documents", {})
            if not all_docs:
                all_docs = bb.get("tiers", {})
                if all_docs and isinstance(all_docs, dict):
                    flat = {}
                    for tier_name, tier_data in all_docs.items():
                        if isinstance(tier_data, dict):
                            for dk, dv in tier_data.items():
                                flat[dk] = dv if isinstance(dv, dict) else {"content": str(dv), "tier": tier_name, "title": dk}
                    all_docs = flat

            for doc_key, doc_data in (all_docs.items() if isinstance(all_docs, dict) else []):
                if isinstance(doc_data, dict):
                    content = doc_data.get("content", "")
                    title = doc_data.get("title", doc_key.replace("_", " ").title())
                    tier = doc_data.get("tier", "general")
                    safe_tier = tier.replace(" ", "_").replace("/", "_")
                    if content:
                        header = f"# {title}\n\n"
                        header += f"**Tier:** {tier}\n"
                        header += f"**Domain:** {domain}\n"
                        if doc_data.get("generated_at"):
                            header += f"**Generated:** {doc_data['generated_at']}\n"
                        header += f"\n---\n\n"
                        zf.writestr(f"{safe}/06_business_docs/{safe_tier}/{doc_key}.md", header + content)
                elif isinstance(doc_data, str) and doc_data:
                    zf.writestr(f"{safe}/06_business_docs/general/{doc_key}.md", doc_data)

        if pkg.atmosphere:
            zf.writestr(f"{safe}/04_config/atmosphere.json", json_module.dumps(pkg.atmosphere, indent=2, default=str))
        if pkg.discovery_answers:
            zf.writestr(f"{safe}/04_config/discovery_answers.json", json_module.dumps(pkg.discovery_answers, indent=2, default=str))

        bk_count = 0
        try:
            kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
            if kit:
                bk_assets = db.query(BrandKitAsset).filter(BrandKitAsset.brand_kit_id == kit.id).all()
                for asset in bk_assets:
                    disk_path = asset.file_path.lstrip("/")
                    if os.path.isfile(disk_path):
                        cls = asset.classification or "uncategorized"
                        zf.write(disk_path, f"{safe}/07_brand_kit/{cls}/{asset.filename}")
                        bk_count += 1
        except Exception:
            pass

        aug_count = 0
        try:
            augments = db.query(Augment).filter(Augment.domain_name == domain).order_by(Augment.created_at).all()
            for aug in augments:
                if aug.html_content:
                    aug_html = generate_standalone_augment_html(domain, brand_opts[rec_idx] if brand_opts and len(brand_opts) > rec_idx else {"name": domain, "tagline": ""}, brand, aug, all_augments=augments)
                    slug = _augment_slug(aug.id, aug.title)
                    zf.writestr(f"{safe}/08_augments/{slug}.html", aug_html)
                    aug_count += 1
        except Exception:
            pass

        site_html = None
        try:
            chosen = brand_opts[rec_idx] if brand_opts and len(brand_opts) > rec_idx else {"name": domain, "tagline": ""}
            site_html = generate_standalone_site_html(domain, chosen, brand, pkg.site_copy or {}, pkg.hero_image_url, augments=augments if 'augments' in dir() else [])
            zf.writestr(f"{safe}/09_site/index.html", site_html)
        except Exception:
            pass

        bb_docs = (pkg.business_box or {}).get("documents", {})
        bb_count = len(bb_docs) if isinstance(bb_docs, dict) else 0
        bb_tiers_list = {}
        for _dk, _dv in (bb_docs.items() if isinstance(bb_docs, dict) else []):
            if isinstance(_dv, dict):
                _t = _dv.get("tier", "general")
                bb_tiers_list.setdefault(_t, []).append(_dv.get("title", _dk))

        site_copy_keys = len(pkg.site_copy or {})
        gfx_count = 0
        if pkg.graphics_pack:
            try:
                from app.services.graphics import flatten_pack_assets as _fpa
                gfx_count = len(_fpa(pkg.graphics_pack))
            except Exception:
                pass

        readme = f"# {brand_data['brand_name']} — Business Package\n\n"
        readme += f"**Domain:** {domain}\n"
        readme += f"**Niche:** {pkg.chosen_niche}\n"
        readme += f"**Generated:** {pkg.created_at.strftime('%Y-%m-%d') if pkg.created_at else 'N/A'}\n\n"
        readme += "## Package Contents\n\n"
        readme += "```\n"
        readme += f"{safe}/\n"
        readme += f"├── 01_brand_identity/    # Brand colors, fonts, personality\n"
        readme += f"├── 02_site_copy/         # {site_copy_keys} website content keys\n"
        readme += f"├── 03_sales_letter/      # Marketplace sales letter ({len(pkg.sales_letter or '')} chars)\n"
        readme += f"├── 04_config/            # Theme & discovery config\n"
        readme += f"├── 05_graphics/          # {gfx_count} graphics + hero image\n"
        readme += f"├── 06_business_docs/     # {bb_count} Force Multiplier documents\n"
        readme += f"├── 07_brand_kit/         # {bk_count} brand kit assets\n"
        readme += f"├── 08_augments/          # {aug_count} interactive tools\n"
        readme += f"└── 09_site/              # Complete standalone site\n"
        readme += "```\n\n"

        if bb_tiers_list:
            readme += "## Business Documents by Tier\n\n"
            for tier_name, doc_titles in sorted(bb_tiers_list.items()):
                readme += f"### {tier_name.replace('_', ' ').title()} ({len(doc_titles)})\n"
                for dt in doc_titles:
                    readme += f"- {dt}\n"
                readme += "\n"

        readme += "---\n\nGenerated by **Aura** — Domain to Business Generator\n"
        zf.writestr(f"{safe}/README.md", readme)

    buf.seek(0)
    from starlette.responses import StreamingResponse
    return StreamingResponse(
        buf,
        media_type="application/zip",
        headers={"Content-Disposition": f'attachment; filename="{safe}_business_package.zip"'}
    )


@app.post("/api/build-package")
async def api_build_package(req: BuildPackageRequest, db: Session = Depends(get_db)):
    domain_name = req.domain.strip().lower()
    niche_name = req.niche_name.strip()
    if not domain_name or not niche_name:
        raise HTTPException(status_code=400, detail="Domain and niche name are required")

    domain_record = db.query(Domain).filter(Domain.domain == domain_name).first()
    if not domain_record or not domain_record.analysis:
        raise HTTPException(status_code=404, detail="Domain not found or not analyzed yet")

    job_id = str(uuid.uuid4())[:8]

    template_type = req.template_type or "hero"
    layout_style = req.layout_style or "single-scroll"
    density = req.density or "balanced"
    discovery_answers = req.discovery_answers
    blueprint = req.blueprint
    profile_slug = req.profile_slug

    if not blueprint:
        if profile_slug and profile_slug != "default":
            profile = db.query(SiteProfile).filter(SiteProfile.slug == profile_slug).first()
            if profile and profile.config:
                from app.services.profiles import get_sections_for_profile
                depth = density if density in ("minimal", "standard", "comprehensive", "legendary") else "comprehensive"
                sections, multiplier = get_sections_for_profile(profile.config, depth)
                blueprint = {
                    "depth": depth,
                    "depth_label": profile.config.get("depth_presets", {}).get(depth, {}).get("label", depth.title()),
                    "content_multiplier": multiplier,
                    "sections": sections,
                    "global_settings": {
                        "tone": profile.config.get("prompt_overrides", {}).get("global_tone", "professional"),
                        "formality": "business-casual",
                        "target_word_count": "high",
                        "include_cta_in_every_section": True,
                        "seo_optimized": True,
                    },
                    "visual_settings": profile.config.get("visual_defaults", {}),
                }
            else:
                blueprint = get_default_blueprint("comprehensive")
        else:
            blueprint = get_default_blueprint("comprehensive")

    retry_params = {
        "domain": domain_name,
        "niche_name": niche_name,
        "template_type": template_type,
        "layout_style": layout_style,
        "density": density,
        "discovery_answers": discovery_answers,
        "blueprint": blueprint,
        "profile_slug": profile_slug or "default",
    }
    create_job(job_id, "build", domain_name, len(BUILD_STEPS) - 1, BUILD_STEPS, retry_params=retry_params)

    job_executor.submit(
        run_build_job, job_id, domain_name, niche_name, template_type,
        discovery_answers, layout_style, density, blueprint, profile_slug or "default"
    )

    return {"job_id": job_id, "domain": domain_name, "niche": niche_name, "template_type": template_type, "status": "started"}


def run_brandkit_job(job_id: str, domain_name: str, niche: str, brand_kit_id: int, force: bool = False):
    db = SessionLocal()
    try:
        update_job(job_id, status="running", current_step="Processing brand kit...",
                   steps_completed=0, total_steps=len(BRANDKIT_STEPS) - 1,
                   current_step_key="init")

        kit = db.query(BrandKit).filter(BrandKit.id == brand_kit_id).first()
        if not kit:
            update_job(job_id, status="failed", error="Brand kit not found")
            return

        assets = db.query(BrandKitAsset).filter(BrandKitAsset.brand_kit_id == brand_kit_id).all()
        doc_assets = [a for a in assets if a.asset_type == "document"]
        img_assets = [a for a in assets if a.asset_type == "image"]

        if not force:
            unclassified_docs = [a for a in doc_assets if a.classified_at is None]
            unclassified_imgs = [a for a in img_assets if a.classified_at is None]
            skipped_docs = len(doc_assets) - len(unclassified_docs)
            skipped_imgs = len(img_assets) - len(unclassified_imgs)
            if skipped_docs > 0 or skipped_imgs > 0:
                logger.info(f"Skipping {skipped_docs} doc(s) and {skipped_imgs} image(s) already classified (use force=True to override)")
        else:
            unclassified_docs = doc_assets
            unclassified_imgs = img_assets

        update_job(job_id, current_step=f"Extracting text from {len(unclassified_docs)} document(s)...",
                   steps_completed=1, current_step_key="extract")

        all_text = []
        for doc in doc_assets:
            try:
                text = extract_text_from_file(doc.file_path, doc.filename)
                if text:
                    all_text.append(text)
            except Exception as e:
                logger.error(f"Failed to extract text from {doc.filename}: {e}")

        raw_text = "\n\n---\n\n".join(all_text)
        kit.raw_text = raw_text
        db.commit()

        needs_doc_classify = bool(unclassified_docs) or force
        update_job(job_id, current_step="AI is analyzing your brand content..." if needs_doc_classify else "Documents already classified, skipping...",
                   steps_completed=2, current_step_key="classify_docs")

        extracted = kit.extracted or {}
        import datetime as _dt
        if needs_doc_classify and raw_text.strip():
            try:
                result = classify_document_content(raw_text, domain_name, niche)
                if isinstance(result, str):
                    result = json.loads(result)
                extracted = result
                for doc in unclassified_docs:
                    doc.classified_at = _dt.datetime.utcnow()
            except Exception as e:
                logger.error(f"Doc classification failed: {e}")
                extracted = {"error": str(e)}
                for doc in unclassified_docs:
                    doc.classified_at = _dt.datetime.utcnow()
        elif needs_doc_classify and not raw_text.strip():
            for doc in unclassified_docs:
                doc.classified_at = _dt.datetime.utcnow()
            logger.info(f"Marking {len(unclassified_docs)} doc(s) as classified (empty/unreadable content)")

        kit.extracted = extracted
        db.commit()

        update_job(job_id, current_step=f"Classifying {len(unclassified_imgs)} image(s)..." + (f" (skipped {len(img_assets) - len(unclassified_imgs)} already done)" if len(unclassified_imgs) < len(img_assets) else ""),
                   steps_completed=3, current_step_key="classify_images")

        image_classifications = []
        for img in img_assets:
            if img.classified_at is not None and not force:
                image_classifications.append({
                    "filename": img.filename, "file_path": img.file_path,
                    "asset_id": img.id, "classification": img.classification or "other",
                    "tags": img.tags or [], "description": img.ai_description or "",
                    "suggested_sections": img.suggested_sections or [],
                })
                continue

        classify_list = unclassified_imgs
        for i, img in enumerate(classify_list):
            try:
                update_job(job_id, current_step=f"Classifying image {i+1}/{len(classify_list)}: {img.filename}...")
                result = classify_image(img.file_path, img.filename, domain_name, niche)
                img.classification = result.get("classification", "other")
                img.tags = result.get("tags", [])
                img.ai_description = result.get("description", "")
                img.suggested_sections = result.get("suggested_sections", [])
                img.used_in_sections = CLASSIFICATION_TO_SECTIONS.get(img.classification, [])
                img.classified_at = _dt.datetime.utcnow()
                db.commit()

                result["filename"] = img.filename
                result["file_path"] = img.file_path
                result["asset_id"] = img.id
                image_classifications.append(result)
            except Exception as e:
                logger.error(f"Image classification failed for {img.filename}: {e}")
                image_classifications.append({
                    "filename": img.filename, "classification": "other",
                    "description": f"Classification failed: {e}", "tags": []
                })

        kit.image_classifications = image_classifications
        db.commit()

        update_job(job_id, current_step="Building brand intelligence...",
                   steps_completed=4, current_step_key="intelligence")

        try:
            summary = build_brandkit_summary(extracted, image_classifications, domain_name)
            kit.summary = summary
            logger.info(f"Brand kit summary generated for {domain_name}")
        except Exception as e:
            logger.error(f"Brand kit summary failed for {domain_name}: {e}")
            kit.summary = {"tone": "Analysis unavailable", "keywords": [], "visual_motifs": [], "brand_personality": "Summary generation failed", "content_strength": "unknown", "asset_count": {"documents": len(doc_assets), "images": len(img_assets)}}

        try:
            gap = compute_gap_analysis(extracted, assets)
            kit.gap_analysis = gap
            logger.info(f"Gap analysis computed for {domain_name}: {gap.get('grade', '?')} ({gap.get('completeness_pct', 0)}%)")
        except Exception as e:
            logger.error(f"Gap analysis failed for {domain_name}: {e}")
            kit.gap_analysis = {"completeness_pct": 0, "grade": "?", "grade_label": "Error", "missing_required": [], "present_required": [], "missing_recommended": [], "present_recommended": [], "missing_nice_to_have": [], "present_nice_to_have": [], "total_items": 0, "present_count": 0}

        try:
            img_suggestions = compute_image_suggestions(assets)
            kit.image_suggestions = img_suggestions
            for suggestion in img_suggestions:
                asset_id = suggestion.get("asset_id")
                if asset_id:
                    asset = db.query(BrandKitAsset).filter(BrandKitAsset.id == asset_id).first()
                    if asset:
                        asset.suggested_sections = suggestion.get("suggested_sections", [])
            logger.info(f"Image suggestions computed for {domain_name}: {len(img_suggestions)} suggestions")
        except Exception as e:
            logger.error(f"Image suggestions failed for {domain_name}: {e}")
            kit.image_suggestions = []

        kit.status = "ready"
        db.commit()

        update_job(job_id, status="completed",
                   current_step="Brand kit ready!",
                   steps_completed=len(BRANDKIT_STEPS) - 1,
                   current_step_key="complete")

    except Exception as e:
        logger.exception(f"Brand kit processing failed: {e}")
        try:
            kit = db.query(BrandKit).filter(BrandKit.id == brand_kit_id).first()
            if kit:
                kit.status = "failed"
                kit.processing_error = str(e)
                db.commit()
        except:
            pass
        update_job(job_id, status="failed", error=str(e),
                   current_step=f"Error: {str(e)[:200]}")
    finally:
        db.close()


@app.post("/api/brandkit/{domain}/upload")
async def api_upload_brandkit(domain: str, files: list[UploadFile] = File(...),
                              niche: str = Form(""), db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    if not files:
        raise HTTPException(status_code=400, detail="No files uploaded")

    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if kit:
        kit.status = "uploading"
    else:
        kit = BrandKit(domain=domain, status="uploading")
        db.add(kit)
    db.flush()

    upload_dir = os.path.join("static", "uploads", "brandkit", domain.replace(".", "_"))
    os.makedirs(upload_dir, exist_ok=True)

    saved_count = {"docs": 0, "images": 0, "skipped": 0}
    MAX_FILE_SIZE = 25 * 1024 * 1024
    MAX_TOTAL_FILES = 30

    if len(files) > MAX_TOTAL_FILES:
        raise HTTPException(status_code=400, detail=f"Too many files. Maximum {MAX_TOTAL_FILES} files per upload.")

    import hashlib
    duplicates = []

    for file in files:
        ext = os.path.splitext(file.filename or "")[1].lower()
        if ext in DOC_EXTENSIONS:
            asset_type = "document"
        elif ext in IMAGE_EXTENSIONS:
            asset_type = "image"
        else:
            saved_count["skipped"] += 1
            continue

        content = await file.read()
        if len(content) > MAX_FILE_SIZE:
            saved_count["skipped"] += 1
            continue
        if len(content) == 0:
            saved_count["skipped"] += 1
            continue

        file_hash = hashlib.sha256(content).hexdigest()

        existing = db.query(BrandKitAsset).filter(
            BrandKitAsset.brand_kit_id == kit.id,
            BrandKitAsset.file_hash == file_hash
        ).first()
        if existing:
            saved_count["skipped"] += 1
            duplicates.append(file.filename or "unknown")
            continue

        base_name = os.path.basename(file.filename or "upload")
        import re as _re
        safe_name = _re.sub(r'[^a-zA-Z0-9._-]', '_', base_name)
        if not safe_name or safe_name.startswith('.'):
            safe_name = f"upload_{saved_count['docs'] + saved_count['images']}{ext}"
        file_path = os.path.join(upload_dir, safe_name)
        if os.path.exists(file_path):
            name_part, ext_part = os.path.splitext(safe_name)
            counter = 1
            while os.path.exists(file_path):
                safe_name = f"{name_part}_{counter}{ext_part}"
                file_path = os.path.join(upload_dir, safe_name)
                counter += 1

        with open(file_path, "wb") as f:
            f.write(content)

        if asset_type == "document":
            saved_count["docs"] += 1
        else:
            saved_count["images"] += 1

        asset = BrandKitAsset(
            brand_kit_id=kit.id,
            asset_type=asset_type,
            filename=safe_name,
            file_path=file_path,
            file_size=len(content),
            file_hash=file_hash,
        )
        db.add(asset)

    kit.status = "uploaded"
    db.commit()

    result = {
        "brand_kit_id": kit.id,
        "domain": domain,
        "uploaded": saved_count,
        "status": "uploaded",
    }
    if duplicates:
        result["duplicates_skipped"] = duplicates
        result["duplicate_message"] = f"{len(duplicates)} file(s) already in your Brand Kit — skipped to save AI credits"
    return result


@app.post("/api/brandkit/{domain}/process")
async def api_process_brandkit(domain: str, niche: str = "", force: bool = False, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if not kit:
        raise HTTPException(status_code=404, detail="No brand kit found. Upload files first.")

    job_id = str(uuid.uuid4())[:8]
    create_job(job_id, "brandkit", domain, len(BRANDKIT_STEPS) - 1, BRANDKIT_STEPS)

    kit.status = "processing"
    db.commit()

    job_executor.submit(run_brandkit_job, job_id, domain, niche, kit.id, force)

    return {"job_id": job_id, "domain": domain, "brand_kit_id": kit.id, "status": "processing", "force_reprocess": force}


@app.get("/api/brandkit/{domain}")
async def api_get_brandkit(domain: str, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if not kit:
        return {"exists": False, "domain": domain}

    assets = db.query(BrandKitAsset).filter(BrandKitAsset.brand_kit_id == kit.id).all()

    extracted_texts = kit.extracted or {}
    doc_previews = {}
    for fname, text in extracted_texts.items():
        if isinstance(text, str):
            doc_previews[fname] = text[:300] + ("..." if len(text) > 300 else "")

    extracted_clean = {}
    raw_extracted = kit.extracted or {}
    for k, v in raw_extracted.items():
        if k.startswith('_') or k == 'error':
            continue
        if v is None:
            continue
        if isinstance(v, str):
            extracted_clean[k] = v[:2000]
        elif isinstance(v, list):
            extracted_clean[k] = v[:20]
        else:
            extracted_clean[k] = v

    return {
        "exists": True,
        "domain": domain,
        "status": kit.status,
        "brand_kit_id": kit.id,
        "extracted": extracted_clean,
        "extracted_previews": doc_previews,
        "image_classifications": kit.image_classifications,
        "summary": kit.summary,
        "gap_analysis": kit.gap_analysis,
        "image_suggestions": kit.image_suggestions,
        "created_at": kit.created_at.isoformat() if kit.created_at else None,
        "assets": [{
            "id": a.id,
            "type": a.asset_type,
            "filename": a.filename,
            "file_url": "/" + a.file_path if not a.file_path.startswith("/") else a.file_path,
            "file_size": a.file_size,
            "classification": a.classification,
            "classification_label": CLASSIFICATION_LABELS.get(a.classification, a.classification or ""),
            "tags": a.tags or [],
            "ai_description": a.ai_description,
            "sort_order": a.sort_order or 0,
            "suggested_sections": a.suggested_sections or [],
            "used_in_sections": a.used_in_sections or [],
            "file_hash": a.file_hash,
            "is_default_logo": getattr(a, 'is_default_logo', False) or False,
            "classified_at": a.classified_at.isoformat() if a.classified_at else None,
            "created_at": a.created_at.isoformat() if a.created_at else None,
        } for a in assets],
        "section_assets": resolve_assets_for_sections(assets),
    }


@app.delete("/api/brandkit/{domain}/asset/{asset_id}")
async def api_delete_brandkit_asset(domain: str, asset_id: int, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if not kit:
        raise HTTPException(status_code=404, detail="No brand kit found for this domain")

    asset = db.query(BrandKitAsset).filter(
        BrandKitAsset.id == asset_id,
        BrandKitAsset.brand_kit_id == kit.id
    ).first()
    if not asset:
        raise HTTPException(status_code=404, detail="Asset not found")

    if os.path.exists(asset.file_path):
        try:
            os.remove(asset.file_path)
        except Exception:
            pass

    db.delete(asset)
    db.commit()

    remaining = db.query(BrandKitAsset).filter(BrandKitAsset.brand_kit_id == kit.id).count()
    if remaining == 0:
        db.delete(kit)
        db.commit()
        return {"deleted": True, "asset_id": asset_id, "kit_deleted": True}

    return {"deleted": True, "asset_id": asset_id, "remaining_assets": remaining}


@app.patch("/api/brandkit/{domain}/asset/{asset_id}")
async def api_update_brandkit_asset(domain: str, asset_id: int, request: Request, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if not kit:
        raise HTTPException(status_code=404, detail="No brand kit found")

    asset = db.query(BrandKitAsset).filter(
        BrandKitAsset.id == asset_id,
        BrandKitAsset.brand_kit_id == kit.id
    ).first()
    if not asset:
        raise HTTPException(status_code=404, detail="Asset not found")

    data = await request.json()

    if "classification" in data:
        new_cls = data["classification"]
        valid_cls = list(CLASSIFICATION_LABELS.keys())
        if new_cls not in valid_cls:
            raise HTTPException(status_code=400, detail=f"Invalid classification. Valid: {valid_cls}")
        asset.classification = new_cls
        asset.classified_at = datetime.datetime.utcnow()

    if "is_default_logo" in data:
        if data["is_default_logo"]:
            db.query(BrandKitAsset).filter(
                BrandKitAsset.brand_kit_id == kit.id,
                BrandKitAsset.is_default_logo == True
            ).update({"is_default_logo": False})
            asset.is_default_logo = True
            asset.classification = "logo"
        else:
            asset.is_default_logo = False

    if "ai_description" in data:
        asset.ai_description = data["ai_description"]

    if "tags" in data:
        asset.tags = data["tags"]

    if "sort_order" in data:
        asset.sort_order = data["sort_order"]

    db.commit()
    db.refresh(asset)

    return {
        "updated": True,
        "id": asset.id,
        "classification": asset.classification,
        "classification_label": CLASSIFICATION_LABELS.get(asset.classification, asset.classification or ""),
        "is_default_logo": getattr(asset, 'is_default_logo', False) or False,
        "ai_description": asset.ai_description,
        "tags": asset.tags,
        "sort_order": asset.sort_order,
    }


@app.get("/api/brandkit/{domain}/asset/{asset_id}/download")
async def api_download_brandkit_asset(domain: str, asset_id: int, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if not kit:
        raise HTTPException(status_code=404, detail="No brand kit found")

    asset = db.query(BrandKitAsset).filter(
        BrandKitAsset.id == asset_id,
        BrandKitAsset.brand_kit_id == kit.id
    ).first()
    if not asset:
        raise HTTPException(status_code=404, detail="Asset not found")

    if not os.path.exists(asset.file_path):
        raise HTTPException(status_code=404, detail="File not found on disk")

    from starlette.responses import FileResponse
    return FileResponse(
        asset.file_path,
        filename=asset.filename,
        media_type="application/octet-stream"
    )


@app.get("/api/brandkit/{domain}/asset/{asset_id}/text")
async def api_get_brandkit_asset_text(domain: str, asset_id: int, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if not kit:
        raise HTTPException(status_code=404, detail="No brand kit found")

    asset = db.query(BrandKitAsset).filter(
        BrandKitAsset.id == asset_id,
        BrandKitAsset.brand_kit_id == kit.id,
        BrandKitAsset.asset_type == "document"
    ).first()
    if not asset:
        raise HTTPException(status_code=404, detail="Document not found")

    extracted = kit.extracted or {}
    text = extracted.get(asset.filename, "")
    if not text:
        for key, val in extracted.items():
            if isinstance(val, str) and key.lower() == asset.filename.lower():
                text = val
                break

    return {"id": asset.id, "filename": asset.filename, "text": text}


@app.put("/api/brandkit/{domain}/asset/{asset_id}/text")
async def api_update_brandkit_asset_text(domain: str, asset_id: int, request: Request, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if not kit:
        raise HTTPException(status_code=404, detail="No brand kit found")

    asset = db.query(BrandKitAsset).filter(
        BrandKitAsset.id == asset_id,
        BrandKitAsset.brand_kit_id == kit.id,
        BrandKitAsset.asset_type == "document"
    ).first()
    if not asset:
        raise HTTPException(status_code=404, detail="Document not found")

    data = await request.json()
    new_text = data.get("text", "")

    extracted = dict(kit.extracted or {})
    extracted[asset.filename] = new_text
    kit.extracted = extracted
    from sqlalchemy.orm.attributes import flag_modified
    flag_modified(kit, "extracted")
    db.commit()

    return {"updated": True, "id": asset.id, "filename": asset.filename, "text_length": len(new_text)}


@app.post("/api/brandkit/{domain}/asset/{asset_id}/replace")
async def api_replace_brandkit_asset(domain: str, asset_id: int, db: Session = Depends(get_db), file: UploadFile = File(...)):
    domain = domain.strip().lower()
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if not kit:
        raise HTTPException(status_code=404, detail="No brand kit found")

    asset = db.query(BrandKitAsset).filter(
        BrandKitAsset.id == asset_id,
        BrandKitAsset.brand_kit_id == kit.id
    ).first()
    if not asset:
        raise HTTPException(status_code=404, detail="Asset not found")

    if os.path.exists(asset.file_path):
        try:
            backup_path = asset.file_path + ".bak"
            os.rename(asset.file_path, backup_path)
        except Exception:
            pass

    content = await file.read()
    os.makedirs(os.path.dirname(asset.file_path), exist_ok=True)
    with open(asset.file_path, "wb") as f:
        f.write(content)

    import hashlib
    asset.file_size = len(content)
    asset.file_hash = hashlib.sha256(content).hexdigest()[:16]
    asset.filename = file.filename or asset.filename
    db.commit()

    return {
        "replaced": True,
        "id": asset.id,
        "filename": asset.filename,
        "file_size": asset.file_size,
        "file_url": "/" + asset.file_path if not asset.file_path.startswith("/") else asset.file_path,
    }


@app.post("/api/brandkit/backfill-hashes")
async def api_backfill_hashes(db: Session = Depends(get_db)):
    assets = db.query(BrandKitAsset).filter(
        (BrandKitAsset.file_hash == None) | (BrandKitAsset.file_hash == "")
    ).all()

    updated = 0
    errors = 0
    for asset in assets:
        if os.path.exists(asset.file_path):
            h = compute_file_hash(asset.file_path)
            if h:
                asset.file_hash = h
                updated += 1
            else:
                errors += 1
        else:
            errors += 1

    already_classified = db.query(BrandKitAsset).filter(
        BrandKitAsset.classified_at == None,
        BrandKitAsset.classification != None,
        BrandKitAsset.classification != ""
    ).all()
    classified_backfill = 0
    for asset in already_classified:
        import datetime as _dt
        asset.classified_at = asset.created_at or _dt.datetime.utcnow()
        if not asset.used_in_sections and asset.classification:
            asset.used_in_sections = CLASSIFICATION_TO_SECTIONS.get(asset.classification, [])
        classified_backfill += 1

    db.commit()
    return {
        "hashes_updated": updated,
        "hash_errors": errors,
        "classified_at_backfilled": classified_backfill,
        "total_checked": len(assets),
    }


@app.delete("/api/brandkit/{domain}")
async def api_delete_brandkit(domain: str, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if not kit:
        raise HTTPException(status_code=404, detail="No brand kit found")

    for asset in kit.assets:
        if os.path.exists(asset.file_path):
            try:
                os.remove(asset.file_path)
            except:
                pass

    db.delete(kit)
    db.commit()
    return {"deleted": True, "domain": domain}


GRAPHICS_PACK_STEPS = [
    {"key": "init", "label": "Preparing graphics generation..."},
    {"key": "logo", "label": "Generating logo..."},
    {"key": "icons", "label": "Generating section icons..."},
    {"key": "separator", "label": "Generating separator..."},
    {"key": "complete", "label": "Graphics pack complete!"},
]


def run_graphics_pack_job(job_id: str, domain_name: str, logo_style: str = "icon_text",
                          separator_style: str = "auto", model: str = "gemini-2.5-flash-image"):
    db = SessionLocal()
    try:
        update_job(job_id, status="running", current_step="Loading package data...",
                   steps_completed=0, total_steps=len(GRAPHICS_PACK_STEPS) - 1,
                   current_step_key="init")

        pkg = db.query(Package).filter(Package.domain_name == domain_name).order_by(Package.created_at.desc()).first()
        if not pkg:
            update_job(job_id, status="failed", current_step="Package not found", error="Package not found")
            return

        brand = pkg.brand or {}
        site_copy = pkg.site_copy or {}
        niche = pkg.chosen_niche or ""

        brand_data = {}
        brand_opts = brand.get("options", [])
        rec_idx = brand.get("recommended_idx", 0)
        if brand_opts and len(brand_opts) > rec_idx:
            brand_data = brand_opts[rec_idx]
        brand_data["name"] = brand.get("brand_name", brand_data.get("name", domain_name))
        brand_data["tagline"] = brand.get("tagline", brand_data.get("tagline", ""))

        atmo = pkg.atmosphere or {}
        mood = atmo.get("animation_style", "professional")
        if mood == "auto":
            mood = "professional"

        ICON_ELIGIBLE = {"features", "how_it_works", "problem", "solution", "about",
                         "stats", "testimonials", "pricing", "comparison", "gallery",
                         "team", "resources", "faq", "contact", "cta"}
        sections = site_copy.get("sections", [])
        icon_section_types = []
        if sections and isinstance(sections, list):
            for sec in sections:
                if sec.get("type", "") in ICON_ELIGIBLE:
                    icon_section_types.append(sec.get("type", ""))
        else:
            for key in site_copy:
                if key in ICON_ELIGIBLE:
                    icon_section_types.append(key)

        from app.services.graphics import SITE_ESSENTIALS
        manifest = []
        manifest.append({"key": "logo", "name": f"Logo ({logo_style.replace('_', ' ').title()})", "type": "logo", "status": "queued"})
        for st in icon_section_types:
            manifest.append({"key": f"icon_{st}", "name": f"Icon: {st.replace('_', ' ').title()}", "type": "icon", "status": "queued"})
        manifest.append({"key": "separator", "name": f"Separator ({separator_style.title()})", "type": "separator", "status": "queued"})
        for ess_key, ess_meta in SITE_ESSENTIALS.items():
            manifest.append({"key": f"essential_{ess_key}", "name": ess_meta["name"], "type": "essential", "status": "queued"})

        asset_results = {}
        total_assets = len(manifest)

        update_job(job_id, current_step=f"Generating {total_assets} assets...",
                   progress_pct=2, current_step_key="init",
                   result={"domain": domain_name, "manifest": manifest, "completed_assets": asset_results, "total": total_assets, "done": 0})

        def progress_cb(pct, msg):
            nonlocal asset_results
            step_key = "icons"
            if "logo" in msg.lower():
                step_key = "logo"
            elif "separator" in msg.lower():
                step_key = "separator"
            elif "essential" in msg.lower() or "favicon" in msg.lower() or "nav_logo" in msg.lower() or "footer" in msg.lower() or "og_image" in msg.lower() or "cta_accent" in msg.lower() or "email" in msg.lower():
                step_key = "icons"
            elif pct >= 95:
                step_key = "complete"

            asset_key = None
            asset_url = None
            asset_status = "done"
            msg_lower = msg.lower()
            if "failed" in msg_lower or "error" in msg_lower:
                asset_status = "failed"

            if "logo" in msg_lower and "generated" in msg_lower:
                asset_key = "logo"
            elif "logo" in msg_lower and "failed" in msg_lower:
                asset_key = "logo"
                asset_status = "failed"
            elif "separator" in msg_lower and "generated" in msg_lower:
                asset_key = "separator"
            elif "separator" in msg_lower and "failed" in msg_lower:
                asset_key = "separator"
                asset_status = "failed"
            elif "icon for" in msg_lower:
                for st in icon_section_types:
                    if f"'{st}'" in msg_lower:
                        asset_key = f"icon_{st}"
                        break
            elif "essential" in msg_lower or any(e in msg_lower for e in ["favicon", "nav_logo", "footer_logo", "og_image", "cta_accent", "email_header"]):
                for ess_key, ess_meta in SITE_ESSENTIALS.items():
                    if ess_meta["name"].lower() in msg_lower or ess_key in msg_lower:
                        asset_key = f"essential_{ess_key}"
                        break

            if asset_key:
                asset_results[asset_key] = {"status": asset_status, "msg": msg}
                done_count = sum(1 for v in asset_results.values() if v["status"] in ("done", "failed"))
                update_job(job_id, current_step=msg, progress_pct=pct, current_step_key=step_key,
                           result={"domain": domain_name, "manifest": manifest, "completed_assets": asset_results, "total": total_assets, "done": done_count})
            else:
                update_job(job_id, current_step=msg, progress_pct=pct, current_step_key=step_key)

        pack = generate_full_graphics_pack(
            brand_data=brand_data,
            niche=niche,
            mood=mood,
            site_copy=site_copy,
            domain=domain_name,
            logo_style=logo_style,
            separator_style=separator_style,
            model=model,
            progress_callback=progress_cb,
            existing_pack=pkg.graphics_pack,
        )

        pkg.graphics_pack = pack
        pkg.updated_at = datetime.datetime.utcnow()
        db.commit()

        final_assets = {}
        if pack.get("logo") and pack["logo"].get("url"):
            final_assets["logo"] = {"status": "done", "url": pack["logo"]["url"], "asset_id": pack["logo"].get("asset_id", "")}
        for sec_type, icon_data in (pack.get("icons") or {}).items():
            if icon_data and icon_data.get("url"):
                final_assets[f"icon_{sec_type}"] = {"status": "done", "url": icon_data["url"], "asset_id": icon_data.get("asset_id", "")}
        if pack.get("separator") and pack["separator"].get("url"):
            final_assets["separator"] = {"status": "done", "url": pack["separator"]["url"], "asset_id": pack["separator"].get("asset_id", "")}
        for ess_type, ess_data in (pack.get("essentials") or {}).items():
            if ess_data and ess_data.get("url"):
                final_assets[f"essential_{ess_type}"] = {"status": "done", "url": ess_data["url"], "asset_id": ess_data.get("asset_id", "")}

        for err in pack.get("errors", []):
            err_asset = err.get("asset", "")
            if err_asset and err_asset not in final_assets:
                final_assets[err_asset] = {"status": "failed", "error": err.get("error", "")}

        update_job(job_id, status="completed", current_step="Graphics pack complete!",
                   steps_completed=len(GRAPHICS_PACK_STEPS) - 1,
                   total_steps=len(GRAPHICS_PACK_STEPS) - 1,
                   current_step_key="complete",
                   result={"domain": domain_name, "stats": pack.get("stats", {}), "manifest": manifest, "completed_assets": final_assets, "total": total_assets, "done": len(final_assets)})

    except Exception as e:
        logger.error(f"Graphics pack job failed for {domain_name}: {e}")
        update_job(job_id, status="failed", current_step=f"Error: {str(e)}", error=str(e))
    finally:
        db.close()


@app.post("/api/graphics/{domain}/generate")
async def api_generate_graphics_pack(domain: str, request: Request, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}

    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found for this domain")

    from app.services.graphics import DEFAULT_MODEL as GFX_DEFAULT_MODEL, MODELS as GFX_MODELS
    logo_style = body.get("logo_style", "icon_text")
    separator_style = body.get("separator_style", "auto")
    model = body.get("model", GFX_DEFAULT_MODEL)

    if model not in GFX_MODELS:
        model = GFX_DEFAULT_MODEL

    job_id = str(uuid.uuid4())[:8]
    create_job(job_id, "graphics_pack", domain, len(GRAPHICS_PACK_STEPS) - 1, GRAPHICS_PACK_STEPS)
    job_executor.submit(run_graphics_pack_job, job_id, domain, logo_style, separator_style, model)

    return {"job_id": job_id, "domain": domain, "status": "started"}


@app.get("/api/graphics/{domain}")
async def api_get_graphics_pack(domain: str, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")

    return {
        "domain": domain,
        "graphics_pack": pkg.graphics_pack,
        "summary": get_graphics_pack_summary(pkg.graphics_pack),
    }


@app.get("/api/graphics/{domain}/download-zip")
async def api_download_graphics_zip(domain: str, db: Session = Depends(get_db)):
    import zipfile
    import io
    domain = domain.strip().lower()
    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg or not pkg.graphics_pack:
        raise HTTPException(status_code=404, detail="No graphics pack found")

    from app.services.graphics import flatten_pack_assets
    assets = flatten_pack_assets(pkg.graphics_pack)
    if not assets:
        raise HTTPException(status_code=404, detail="No assets in graphics pack")

    buf = io.BytesIO()
    with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
        for asset in assets:
            url = asset.get("url", "")
            if not url:
                continue
            file_path = None
            if url.startswith("/static/"):
                file_path = os.path.join("static", url[len("/static/"):])
            elif url.startswith("static/"):
                file_path = url
            if file_path and os.path.isfile(file_path):
                asset_type = asset.get("type", "asset")
                asset_id = asset.get("asset_id", "")
                style = asset.get("style", asset.get("style_name", ""))
                sec = asset.get("section_type", "")
                ext = os.path.splitext(file_path)[1] or ".png"
                if asset_type == "logo":
                    fname = f"logo_{style}{ext}"
                elif asset_type == "icon":
                    fname = f"icon_{sec}{ext}"
                elif asset_type == "separator":
                    fname = f"separator_{style}{ext}"
                elif asset_type == "essential":
                    ess_type = asset.get("essential_type", asset_id)
                    fname = f"{ess_type}{ext}"
                else:
                    fname = f"{asset_id}{ext}"
                zf.write(file_path, f"graphics_pack/{fname}")

    buf.seek(0)
    safe_domain = domain.replace(".", "_")
    from starlette.responses import StreamingResponse
    return StreamingResponse(
        buf,
        media_type="application/zip",
        headers={"Content-Disposition": f'attachment; filename="{safe_domain}_graphics_pack.zip"'}
    )


@app.post("/api/graphics/{domain}/regenerate")
async def api_regenerate_graphics_asset(domain: str, request: Request, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    body = await request.json()

    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")

    asset_type = body.get("asset_type", "")
    if asset_type not in ("logo", "icon", "separator", "essential"):
        raise HTTPException(status_code=400, detail="asset_type must be logo, icon, separator, or essential")

    brand = pkg.brand or {}
    brand_data = {}
    brand_opts = brand.get("options", [])
    rec_idx = brand.get("recommended_idx", 0)
    if brand_opts and len(brand_opts) > rec_idx:
        brand_data = brand_opts[rec_idx]
    brand_data["name"] = brand.get("brand_name", brand_data.get("name", domain))
    brand_data["tagline"] = brand.get("tagline", brand_data.get("tagline", ""))

    niche = pkg.chosen_niche or ""
    atmo = pkg.atmosphere or {}
    mood = atmo.get("animation_style", "professional")
    if mood == "auto":
        mood = "professional"

    from app.services.graphics import DEFAULT_MODEL, MODELS
    model = body.get("model", DEFAULT_MODEL)
    if model not in MODELS:
        model = DEFAULT_MODEL

    versions = min(max(int(body.get("versions", 1)), 1), 5)
    influence_url = body.get("influence_url", "")
    reference_prompt = body.get("reference_prompt", "")

    try:
        from app.services.graphics import _archive_asset_to_history

        all_results = []
        working_pack = copy.deepcopy(pkg.graphics_pack or {})
        for v_idx in range(versions):
            extra_kwargs = {}
            if influence_url:
                extra_kwargs["influence_url"] = influence_url
            if reference_prompt:
                extra_kwargs["reference_prompt"] = reference_prompt

            result = regenerate_asset(
                asset_type=asset_type,
                brand_data=brand_data,
                niche=niche,
                mood=mood,
                domain=domain,
                model=model,
                existing_pack=working_pack,
                style=body.get("style", "icon_text"),
                separator_style=body.get("separator_style", "auto"),
                section_type=body.get("section_type", "features"),
                section_title=body.get("section_title", ""),
                icon_hint=body.get("icon_hint", ""),
                essential_type=body.get("essential_type", "favicon"),
                **extra_kwargs,
            )
            all_results.append(result)

            if asset_type == "logo":
                working_pack["logo"] = result
            elif asset_type == "icon":
                working_pack.setdefault("icons", {})[body.get("section_type", "features")] = result
            elif asset_type == "separator":
                working_pack["separator"] = result
            elif asset_type == "essential":
                working_pack.setdefault("essentials", {})[body.get("essential_type", "favicon")] = result

        gp = pkg.graphics_pack or {}
        if "history" not in gp:
            gp["history"] = []

        def _store_result(res, is_active):
            if asset_type == "logo":
                if is_active and gp.get("logo"):
                    _archive_asset_to_history(gp, gp["logo"])
                if is_active:
                    gp["logo"] = res
                else:
                    _archive_asset_to_history(gp, res)
            elif asset_type == "icon":
                if "icons" not in gp:
                    gp["icons"] = {}
                sec_key = body.get("section_type", "features")
                if is_active and gp["icons"].get(sec_key):
                    _archive_asset_to_history(gp, gp["icons"][sec_key])
                if is_active:
                    gp["icons"][sec_key] = res
                else:
                    _archive_asset_to_history(gp, res)
            elif asset_type == "separator":
                if is_active and gp.get("separator"):
                    _archive_asset_to_history(gp, gp["separator"])
                if is_active:
                    gp["separator"] = res
                else:
                    _archive_asset_to_history(gp, res)
            elif asset_type == "essential":
                if "essentials" not in gp:
                    gp["essentials"] = {}
                ess_key = body.get("essential_type", "favicon")
                if is_active and gp["essentials"].get(ess_key):
                    _archive_asset_to_history(gp, gp["essentials"][ess_key])
                if is_active:
                    gp["essentials"][ess_key] = res
                else:
                    _archive_asset_to_history(gp, res)

            if "asset_index" not in gp:
                gp["asset_index"] = {}
            gp["asset_index"][res["asset_id"]] = {"type": asset_type}

        for i, res in enumerate(all_results):
            is_last = (i == len(all_results) - 1)
            _store_result(res, is_active=is_last)

        gp["updated_at"] = datetime.datetime.utcnow().isoformat()
        pkg.graphics_pack = gp
        pkg.updated_at = datetime.datetime.utcnow()
        from sqlalchemy.orm.attributes import flag_modified
        flag_modified(pkg, "graphics_pack")
        db.commit()

        return {
            "success": True,
            "asset": all_results[-1],
            "versions_generated": len(all_results),
            "all_asset_ids": [r["asset_id"] for r in all_results],
            "domain": domain,
        }

    except Exception as e:
        logger.error(f"Asset regeneration failed ({asset_type}, {domain}): {e}")
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/api/graphics/styles")
async def api_graphics_styles():
    return {
        "logo_styles": {k: v for k, v in LOGO_STYLES.items()},
        "separator_styles": {k: v for k, v in SEPARATOR_STYLES.items()},
        "site_essentials": {k: {"id": v["id"], "name": v["name"], "description": v["description"]} for k, v in SITE_ESSENTIALS.items()},
        "models": {
            "gemini-2.5-flash-image": "Nano Banana (Fast)",
            "gemini-3-pro-image-preview": "Nano Banana Pro (High Quality)",
        },
    }


@app.post("/api/graphics/{domain}/save-edited")
async def api_save_edited_asset(domain: str, request: Request, db: Session = Depends(get_db)):
    """Save a client-side edited image back to the graphics pack."""
    import base64
    import uuid as _uuid
    domain = domain.strip().lower()
    body = await request.json()

    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")

    image_b64 = body.get("image_base64", "")
    asset_type = body.get("asset_type", "")
    asset_key = body.get("asset_key", "")

    if not image_b64 or not asset_type:
        raise HTTPException(status_code=400, detail="image_base64 and asset_type required")

    if asset_type not in ("logo", "separator", "icon", "essential"):
        raise HTTPException(status_code=400, detail="asset_type must be logo, icon, separator, or essential")

    if asset_type in ("icon", "essential") and not asset_key:
        raise HTTPException(status_code=400, detail="asset_key is required for icon and essential types")

    gp = pkg.graphics_pack or {}
    if asset_type == "icon" and not (gp.get("icons", {}).get(asset_key)):
        raise HTTPException(status_code=400, detail=f"Icon '{asset_key}' does not exist — generate it first")
    if asset_type == "essential" and not (gp.get("essentials", {}).get(asset_key)):
        raise HTTPException(status_code=400, detail=f"Essential '{asset_key}' does not exist — generate it first")
    if asset_type == "logo" and not gp.get("logo"):
        raise HTTPException(status_code=400, detail="No logo exists — generate one first")
    if asset_type == "separator" and not gp.get("separator"):
        raise HTTPException(status_code=400, detail="No separator exists — generate one first")

    if "," in image_b64:
        image_b64 = image_b64.split(",", 1)[1]

    try:
        img_bytes = base64.b64decode(image_b64)
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid base64 image data")

    MAX_IMAGE_SIZE = 10 * 1024 * 1024
    if len(img_bytes) > MAX_IMAGE_SIZE:
        raise HTTPException(status_code=400, detail=f"Image too large ({len(img_bytes)} bytes). Maximum is 10MB.")

    if not (img_bytes[:8].startswith(b'\x89PNG') or img_bytes[:3] == b'\xff\xd8\xff' or img_bytes[:4] == b'RIFF'):
        raise HTTPException(status_code=400, detail="Invalid image format — only PNG, JPEG, and WebP are supported")

    from app.services.graphics import _archive_asset_to_history

    gp = pkg.graphics_pack or {}
    if "history" not in gp:
        gp["history"] = []

    new_asset_id = f"edited-{_uuid.uuid4().hex[:12]}"
    import os
    save_dir = os.path.join("static", "graphics", domain)
    os.makedirs(save_dir, exist_ok=True)
    filename = f"{asset_type}-edited-{new_asset_id}.png"
    filepath = os.path.join(save_dir, filename)
    with open(filepath, "wb") as f:
        f.write(img_bytes)

    new_url = f"/static/graphics/{domain}/{filename}"
    new_record = {
        "url": new_url,
        "asset_id": new_asset_id,
        "filename": filename,
        "revision": 1,
        "style": "edited",
        "style_name": "Hand-edited",
        "model_name": "filerobot-editor",
        "created_at": datetime.datetime.utcnow().isoformat(),
    }

    if asset_type == "logo":
        if gp.get("logo"):
            old = gp["logo"]
            new_record["revision"] = old.get("revision", 1) + 1
            _archive_asset_to_history(gp, old)
        gp["logo"] = new_record
    elif asset_type == "separator":
        if gp.get("separator"):
            old = gp["separator"]
            new_record["revision"] = old.get("revision", 1) + 1
            _archive_asset_to_history(gp, old)
        gp["separator"] = new_record
    elif asset_type == "icon":
        if "icons" not in gp:
            gp["icons"] = {}
        if gp["icons"].get(asset_key):
            old = gp["icons"][asset_key]
            new_record["revision"] = old.get("revision", 1) + 1
            _archive_asset_to_history(gp, old)
        gp["icons"][asset_key] = new_record
    elif asset_type == "essential":
        if "essentials" not in gp:
            gp["essentials"] = {}
        if gp["essentials"].get(asset_key):
            old = gp["essentials"][asset_key]
            new_record["revision"] = old.get("revision", 1) + 1
            _archive_asset_to_history(gp, old)
        gp["essentials"][asset_key] = new_record
    else:
        raise HTTPException(status_code=400, detail="asset_type must be logo, icon, separator, or essential")

    if "asset_index" not in gp:
        gp["asset_index"] = {}
    gp["asset_index"][new_asset_id] = {"type": asset_type}
    gp["updated_at"] = datetime.datetime.utcnow().isoformat()

    pkg.graphics_pack = gp
    pkg.updated_at = datetime.datetime.utcnow()
    from sqlalchemy.orm.attributes import flag_modified
    flag_modified(pkg, "graphics_pack")
    db.commit()

    return {"success": True, "asset": new_record, "domain": domain}


@app.get("/api/graphics/{domain}/history")
async def api_get_graphics_history(domain: str, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")
    gp = pkg.graphics_pack or {}
    history = gp.get("history", [])
    return {"domain": domain, "history": history, "count": len(history)}


@app.post("/api/graphics/{domain}/restore")
async def api_restore_history_asset(domain: str, request: Request, db: Session = Depends(get_db)):
    domain = domain.strip().lower()
    body = await request.json()
    asset_id = body.get("asset_id", "")

    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")

    gp = pkg.graphics_pack or {}
    history = gp.get("history", [])
    target = None
    for item in history:
        if item.get("asset_id") == asset_id:
            target = item
            break
    if not target:
        raise HTTPException(status_code=404, detail="Asset not found in history")

    from app.services.graphics import _archive_asset_to_history
    asset_type = target.get("type", "")

    if asset_type == "logo":
        if gp.get("logo"):
            _archive_asset_to_history(gp, gp["logo"])
        restored = {k: v for k, v in target.items() if k != "replaced_at"}
        gp["logo"] = restored
    elif asset_type == "icon":
        section = target.get("section", "")
        if "icons" not in gp:
            gp["icons"] = {}
        if gp["icons"].get(section):
            _archive_asset_to_history(gp, gp["icons"][section])
        restored = {k: v for k, v in target.items() if k != "replaced_at"}
        gp["icons"][section] = restored
    elif asset_type == "separator":
        if gp.get("separator"):
            _archive_asset_to_history(gp, gp["separator"])
        restored = {k: v for k, v in target.items() if k != "replaced_at"}
        gp["separator"] = restored
    elif asset_type == "essential":
        ess_type = target.get("essential_type", "")
        if "essentials" not in gp:
            gp["essentials"] = {}
        if gp["essentials"].get(ess_type):
            _archive_asset_to_history(gp, gp["essentials"][ess_type])
        restored = {k: v for k, v in target.items() if k != "replaced_at"}
        gp["essentials"][ess_type] = restored
    else:
        raise HTTPException(status_code=400, detail=f"Cannot restore asset type: {asset_type}")

    gp["updated_at"] = datetime.datetime.utcnow().isoformat()
    pkg.graphics_pack = gp
    pkg.updated_at = datetime.datetime.utcnow()
    from sqlalchemy.orm.attributes import flag_modified
    flag_modified(pkg, "graphics_pack")
    db.commit()

    return {"success": True, "restored_asset_id": asset_id, "asset_type": asset_type}


@app.post("/api/graphics/{domain}/upload-reference")
async def api_upload_reference_image(domain: str, request: Request):
    from starlette.datastructures import UploadFile as StarletteUpload
    form = await request.form()
    file = form.get("file")
    if not file or not hasattr(file, 'read'):
        raise HTTPException(status_code=400, detail="No file uploaded")

    content = await file.read()
    if len(content) > 10 * 1024 * 1024:
        raise HTTPException(status_code=400, detail="File too large (max 10MB)")

    ref_dir = Path(f"static/graphics/{domain}/references")
    ref_dir.mkdir(parents=True, exist_ok=True)
    import uuid as _uuid
    ext = os.path.splitext(file.filename)[1] if file.filename else ".png"
    fname = f"ref-{_uuid.uuid4().hex[:8]}{ext}"
    fpath = ref_dir / fname
    with open(fpath, "wb") as f:
        f.write(content)

    return {"url": f"/{fpath}", "filename": fname}


@app.post("/api/graphics/{domain}/port-to-brandkit")
async def api_port_graphics_to_brandkit(domain: str, db: Session = Depends(get_db)):
    from app.services.brandkit import port_graphics_to_brandkit
    result = port_graphics_to_brandkit(domain, db)
    if result.get("status") == "error":
        raise HTTPException(status_code=400, detail=result.get("message", "Unknown error"))
    return result


@app.post("/api/graphics/{domain}/port-single-to-brandkit")
async def api_port_single_asset_to_brandkit(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    asset_url = data.get("url", "")
    asset_filename = data.get("filename", "")
    asset_classification = data.get("classification", "other")

    if not asset_url:
        raise HTTPException(status_code=400, detail="Asset URL is required")

    from app.services.brandkit import port_single_asset_to_brandkit
    result = port_single_asset_to_brandkit(domain, asset_url, asset_filename, asset_classification, db)
    if result.get("status") == "error":
        raise HTTPException(status_code=400, detail=result.get("message", "Unknown error"))
    return result


@app.get("/api/graphics/{domain}/download-all")
async def api_download_all_graphics(domain: str, db: Session = Depends(get_db)):
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")
    gp = package.graphics_pack or {}
    from app.services.graphics import flatten_pack_assets
    assets = flatten_pack_assets(gp)
    if not assets:
        raise HTTPException(status_code=404, detail="No graphics pack assets found")

    zip_buffer = io.BytesIO()
    with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
        for asset in assets:
            url = asset.get("url", "")
            fname = asset.get("filename", os.path.basename(url))
            file_path = url.lstrip("/")
            if os.path.exists(file_path):
                asset_type = asset.get("type", "misc")
                zf.write(file_path, f"{domain}-graphics/{asset_type}/{fname}")

    zip_buffer.seek(0)
    from starlette.responses import StreamingResponse
    return StreamingResponse(
        zip_buffer,
        media_type="application/zip",
        headers={"Content-Disposition": f"attachment; filename={domain}-graphics-pack.zip"}
    )


@app.get("/api/job/{job_id}")
async def api_job_status(job_id: str):
    job = get_job_dict(job_id)
    if not job:
        raise HTTPException(status_code=404, detail="Job not found")
    return job


@app.get("/api/job/{job_id}/stream")
async def api_job_stream(job_id: str):
    async def event_generator():
        miss_count = 0
        while True:
            job = get_job_dict(job_id)
            if not job:
                miss_count += 1
                if miss_count >= 3:
                    yield {"event": "error", "data": json.dumps({"error": "Job not found"})}
                    break
                await asyncio.sleep(0.5)
                continue

            miss_count = 0
            yield {"event": "progress", "data": json.dumps(job)}

            if job.get("status") in ("completed", "failed"):
                break

            await asyncio.sleep(0.8)

    return EventSourceResponse(event_generator())


@app.get("/api/batch/{batch_id}/stream")
async def api_batch_stream(batch_id: str):
    async def batch_event_generator():
        miss_count = 0
        last_hash = None
        while True:
            job = get_job_dict(batch_id)
            if not job:
                miss_count += 1
                if miss_count >= 5:
                    yield {"event": "error", "data": json.dumps({"error": "Batch not found"})}
                    break
                await asyncio.sleep(1)
                continue

            miss_count = 0
            current_hash = f"{job.get('status')}_{job.get('progress_pct')}_{len((job.get('result') or {}).get('domains', []))}"

            if current_hash != last_hash:
                yield {"event": "batch_update", "data": json.dumps(job)}
                last_hash = current_hash
            else:
                yield {"event": "heartbeat", "data": json.dumps({"ts": datetime.datetime.utcnow().isoformat(), "status": job.get("status")})}

            if job.get("status") in ("completed", "failed", "stopped"):
                yield {"event": "batch_complete", "data": json.dumps(job)}
                break

            await asyncio.sleep(1.0)

    return EventSourceResponse(batch_event_generator())


@app.get("/api/jobs")
async def api_jobs_list(status: str = None, limit: int = 20):
    db = SessionLocal()
    try:
        query = db.query(Job).order_by(Job.created_at.desc())
        if status:
            if status == "active":
                query = query.filter(Job.status.in_(["pending", "running"]))
            else:
                query = query.filter(Job.status == status)
        jobs = query.limit(limit).all()
        return {"jobs": [j.to_dict() for j in jobs]}
    finally:
        db.close()


@app.post("/api/job/{job_id}/retry")
async def api_job_retry(job_id: str):
    db = SessionLocal()
    try:
        original_job = db.query(Job).filter(Job.job_id == job_id).first()
        if not original_job:
            raise HTTPException(status_code=404, detail="Job not found")
        if original_job.status not in ("failed",):
            raise HTTPException(status_code=400, detail="Only failed jobs can be retried")

        params = original_job.retry_params or {}
        job_type = original_job.job_type
        domain_name = params.get("domain") or original_job.domain

        new_job_id = str(uuid.uuid4())[:8]

        if job_type == "analyze":
            niche_hints = params.get("niche_hints", "")
            retry_params = {"domain": domain_name, "niche_hints": niche_hints}
            create_job(new_job_id, "analyze", domain_name, len(ANALYSIS_STEPS) - 1, ANALYSIS_STEPS,
                       retry_params=retry_params, retry_of=job_id)
            job_executor.submit(run_analysis_job, new_job_id, domain_name, niche_hints)
            return {"job_id": new_job_id, "domain": domain_name, "job_type": "analyze", "status": "started", "retry_of": job_id}

        elif job_type == "build":
            niche_name = params.get("niche_name", "")
            template_type = params.get("template_type", "hero")
            layout_style = params.get("layout_style", "single-scroll")
            density = params.get("density", "balanced")
            discovery_answers = params.get("discovery_answers")
            blueprint = params.get("blueprint")
            profile_slug = params.get("profile_slug", "default")

            if not niche_name:
                domain_record = db.query(Domain).filter(Domain.domain == domain_name).first()
                if domain_record and domain_record.analysis:
                    niches = domain_record.analysis.get("niches", [])
                    if niches:
                        niche_name = niches[0].get("name", "General")

            if not blueprint:
                blueprint = get_default_blueprint("comprehensive")

            retry_params = {
                "domain": domain_name, "niche_name": niche_name,
                "template_type": template_type, "layout_style": layout_style,
                "density": density, "discovery_answers": discovery_answers,
                "blueprint": blueprint, "profile_slug": profile_slug,
            }
            create_job(new_job_id, "build", domain_name, len(BUILD_STEPS) - 1, BUILD_STEPS,
                       retry_params=retry_params, retry_of=job_id)
            job_executor.submit(
                run_build_job, new_job_id, domain_name, niche_name, template_type,
                discovery_answers, layout_style, density, blueprint, profile_slug
            )
            return {"job_id": new_job_id, "domain": domain_name, "niche": niche_name, "job_type": "build", "status": "started", "retry_of": job_id}

        elif job_type == "ftp_deploy":
            raise HTTPException(status_code=400, detail="FTP deploy jobs should be retried from the deploy panel")

        else:
            raise HTTPException(status_code=400, detail=f"Retry not supported for job type: {job_type}")

    finally:
        db.close()


@app.get("/api/blueprint/default")
async def api_blueprint_default(depth: str = "comprehensive"):
    blueprint = get_default_blueprint(depth)
    return blueprint


@app.get("/api/blueprint/presets")
async def api_blueprint_presets():
    return {
        "presets": CONTENT_DEPTH_PRESETS,
        "categories": SECTION_CATEGORIES,
        "visual_options": VISUAL_ELEMENT_OPTIONS,
    }


@app.post("/api/blueprint/validate")
async def api_blueprint_validate(req: Request):
    data = await req.json()
    result = validate_blueprint(data)
    return result


@app.post("/api/blueprint/import-content")
async def api_blueprint_import_content(req: Request):
    data = await req.json()
    text = data.get("text", "")
    format_type = data.get("format", "auto")
    if not text.strip():
        raise HTTPException(status_code=400, detail="No content provided")
    parsed = parse_imported_content(text, format_type)
    return {"parsed": parsed, "field_count": len(parsed)}


@app.get("/api/blueprint/completeness/{domain}")
async def api_blueprint_completeness(domain: str, db: Session = Depends(get_db)):
    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="No package found")
    blueprint = get_default_blueprint("legendary")
    completeness = calculate_completeness(pkg.site_copy or {}, blueprint)
    return completeness


@app.get("/api/domains")
async def api_list_domains(db: Session = Depends(get_db)):
    domains = db.query(Domain).order_by(Domain.created_at.desc()).all()
    results = []
    for d in domains:
        pkg_count = db.query(Package).filter(Package.domain_id == d.id).count()
        results.append({
            "id": d.id, "domain": d.domain,
            "analyzed": d.analysis is not None,
            "analyzed_at": d.analyzed_at.isoformat() if d.analyzed_at else None,
            "analysis": d.analysis,
            "package_count": pkg_count,
            "created_at": d.created_at.isoformat() if d.created_at else None,
        })
    return results


@app.get("/api/packages/{domain}")
async def api_get_packages(domain: str, db: Session = Depends(get_db)):
    packages = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).all()
    results = []
    for p in packages:
        results.append({
            "id": p.id, "domain": p.domain_name, "chosen_niche": p.chosen_niche,
            "brand": p.brand, "site_copy": p.site_copy, "sales_letter": p.sales_letter,
            "hero_image_url": p.hero_image_url, "template_type": p.template_type,
            "discovery_answers": p.discovery_answers,
            "created_at": p.created_at.isoformat() if p.created_at else None,
        })
    return results


@app.get("/site/{domain}", response_class=HTMLResponse)
async def site_preview(request: Request, domain: str, db: Session = Depends(get_db)):
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="No package found for this domain")

    from app.services.aura import _normalize_site_copy
    from app.services.validators import validate_site_copy, validate_brand
    brand = package.brand or {}
    site_copy = _normalize_site_copy(package.site_copy or {})
    site_copy, copy_report = validate_site_copy(site_copy, auto_repair=True)
    brand, brand_report = validate_brand(brand, auto_repair=True)
    if copy_report.repairs or brand_report.repairs:
        logger.info(f"[site_preview:{domain}] Repaired {len(copy_report.repairs)} site_copy + {len(brand_report.repairs)} brand issues on read")
    recommended_idx = brand.get("recommended", 0)
    options = brand.get("options", [])
    chosen_brand = options[recommended_idx] if options and recommended_idx < len(options) else {"name": domain, "tagline": ""}

    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    section_assets = {}
    if kit:
        kit_assets = db.query(BrandKitAsset).filter(BrandKitAsset.brand_kit_id == kit.id).all()
        section_assets = resolve_assets_for_sections(kit_assets)

    from app.services.theme import generate_theme_config, generate_theme_css
    brand_tone = ""
    if kit and kit.summary:
        brand_tone = (kit.summary or {}).get("tone", "")
    theme = generate_theme_config(
        primary=brand.get("color_primary", "#4F46E5"),
        secondary=brand.get("color_secondary", "#7C3AED"),
        accent=brand.get("color_accent", "#06B6D4"),
        niche=package.chosen_niche or "",
        brand_tone=brand_tone,
        brand_data=brand,
        atmosphere=package.atmosphere,
    )
    theme_css = generate_theme_css(theme)

    augments = db.query(Augment).filter(Augment.domain_name == domain).order_by(Augment.created_at).all()

    return templates.TemplateResponse("site.html", {
        "request": request, "domain": domain, "brand": chosen_brand,
        "brand_data": brand, "brand_options": options, "site_copy": site_copy,
        "package": package, "hero_image_url": package.hero_image_url,
        "template_type": package.template_type or "hero",
        "layout_style": package.layout_style or "single-scroll",
        "density": package.density or "balanced",
        "section_assets": section_assets,
        "theme": theme, "theme_css": theme_css,
        "augments": augments,
        "graphics_pack": package.graphics_pack or {},
    })


@app.get("/sales/{domain}", response_class=HTMLResponse)
async def sales_letter_view(request: Request, domain: str, db: Session = Depends(get_db)):
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="No package found for this domain")

    raw_letter = package.sales_letter or ""
    site_copy = {}
    brand_data = {}
    has_markdown = False
    sales_letter_html = ""
    sales_letter_raw = raw_letter

    if isinstance(raw_letter, dict):
        parsed = raw_letter
    elif isinstance(raw_letter, str) and raw_letter.strip().startswith("{"):
        try:
            parsed = json.loads(raw_letter)
        except (json.JSONDecodeError, AttributeError):
            parsed = None
    else:
        parsed = None

    if parsed and isinstance(parsed, dict) and "site_copy" in parsed:
        site_copy = parsed.get("site_copy", {})
        if parsed.get("brand"):
            brand_data = parsed["brand"]
        if not brand_data and package.brand:
            brand_data = package.brand if isinstance(package.brand, dict) else {}
        sl_md = None
        if isinstance(site_copy, dict):
            resources = site_copy.get("resources", {})
            if isinstance(resources, dict):
                sl_md = resources.get("sales_letter_md") or resources.get("sales_letter_markdown") or resources.get("sales_letter")
            if not sl_md:
                for search_key in ["sales_letter_md", "sales_letter_markdown", "sales_letter", "content_markdown"]:
                    if search_key in site_copy and isinstance(site_copy[search_key], str):
                        sl_md = site_copy[search_key]
                        break
        if not sl_md and isinstance(parsed, dict):
            for search_key in ["content_markdown", "sales_letter_md", "sales_letter_markdown", "sales_letter", "content", "markdown"]:
                if search_key in parsed and isinstance(parsed[search_key], str) and len(parsed[search_key]) > 100:
                    sl_md = parsed[search_key]
                    break
        if sl_md and isinstance(sl_md, str) and len(sl_md.strip()) > 50:
            sales_letter_html = render_markdown(sl_md)
            sales_letter_raw = sl_md
            has_markdown = True
    elif parsed and isinstance(parsed, dict):
        for key in ["content_markdown", "body_markdown", "sales_letter_markdown", "sales_letter", "content", "markdown", "letter", "text", "body"]:
            if key in parsed and isinstance(parsed[key], str):
                sales_letter_html = render_markdown(parsed[key])
                sales_letter_raw = parsed[key]
                has_markdown = True
                break
        if not has_markdown:
            longest_val = ""
            for v in parsed.values():
                if isinstance(v, str) and len(v) > len(longest_val):
                    longest_val = v
            if len(longest_val) > 100:
                sales_letter_html = render_markdown(longest_val)
                sales_letter_raw = longest_val
                has_markdown = True
        brand_data = package.brand if isinstance(package.brand, dict) else {}
    else:
        if raw_letter and raw_letter.strip():
            sales_letter_html = render_markdown(raw_letter)
            has_markdown = True
        brand_data = package.brand if isinstance(package.brand, dict) else {}

    if not brand_data:
        brand_data = package.brand if isinstance(package.brand, dict) else {}

    if not site_copy and package.site_copy and isinstance(package.site_copy, dict):
        site_copy = package.site_copy

    brand_options = brand_data.get("options", [])
    rec_idx = brand_data.get("recommended_index", brand_data.get("recommended", 0))
    chosen_brand = brand_options[rec_idx] if brand_options and rec_idx < len(brand_options) else {}

    primary_color = brand_data.get("color_primary") or chosen_brand.get("color_primary") or chosen_brand.get("palette", {}).get("primary", "#4F46E5")
    accent_color = brand_data.get("color_secondary") or chosen_brand.get("color_secondary") or chosen_brand.get("palette", {}).get("accent", "#7C3AED")
    accent2 = brand_data.get("color_accent") or chosen_brand.get("color_accent", "#06B6D4")
    bg_color = chosen_brand.get("palette", {}).get("background", "#FFFFFF")

    from app.services.theme import generate_theme_config, generate_theme_css
    brand_tone = ""
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if kit and kit.summary:
        brand_tone = (kit.summary or {}).get("tone", "")
    theme = generate_theme_config(
        primary=primary_color,
        secondary=accent_color,
        accent=accent2,
        niche=package.chosen_niche or "",
        brand_tone=brand_tone,
        brand_data=brand_data,
        atmosphere=package.atmosphere,
    )
    theme_css = generate_theme_css(theme)

    hero_image_url = package.hero_image_url
    if kit:
        hero_assets = db.query(BrandKitAsset).filter(
            BrandKitAsset.brand_kit_id == kit.id,
            BrandKitAsset.classification.in_(["hero_banner", "lifestyle", "background_texture"])
        ).all()
        if hero_assets:
            bk_path = hero_assets[0].file_path
            if bk_path:
                hero_image_url = "/" + bk_path.lstrip("/") if not bk_path.startswith("/") else bk_path

    return templates.TemplateResponse("sales.html", {
        "request": request, "domain": domain,
        "chosen_niche": package.chosen_niche or "",
        "hero_image_url": hero_image_url,
        "has_markdown": has_markdown,
        "sales_letter_html": sales_letter_html,
        "sales_letter_raw": sales_letter_raw,
        "site_copy": site_copy,
        "brand_data": brand_data,
        "chosen_brand": chosen_brand,
        "primary_color": primary_color,
        "accent_color": accent_color,
        "bg_color": bg_color,
        "theme": theme,
        "theme_css": theme_css,
    })


@app.get("/api/export/{domain}")
async def api_export_domain(domain: str, db: Session = Depends(get_db)):
    domain_record = db.query(Domain).filter(Domain.domain == domain).first()
    if not domain_record:
        raise HTTPException(status_code=404, detail="Domain not found")

    packages = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).all()

    zip_buffer = io.BytesIO()
    with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
        if domain_record.analysis:
            analysis = domain_record.analysis
            zf.writestr(f"{domain}/analysis.json", json.dumps(analysis, indent=2))

            niches = analysis.get("niches", [])
            if niches:
                csv_buffer = io.StringIO()
                writer = csv.DictWriter(csv_buffer, fieldnames=[
                    "name", "description", "synopsis", "monetization_model",
                    "target_audience", "time_to_revenue", "valuation_band", "score",
                    "requires_inventory", "affiliate_programs"
                ])
                writer.writeheader()
                for n in niches:
                    row = {**n}
                    if "affiliate_programs" in row and isinstance(row["affiliate_programs"], list):
                        row["affiliate_programs"] = "; ".join(row["affiliate_programs"])
                    writer.writerow(row)
                zf.writestr(f"{domain}/niches.csv", csv_buffer.getvalue())

            if analysis.get("domain_summary"):
                zf.writestr(f"{domain}/domain_summary.txt", analysis["domain_summary"])

        from app.services.aura import _normalize_site_copy
        from app.services.validators import validate_site_copy, validate_brand

        for pkg in packages:
            niche_slug = pkg.chosen_niche.replace(" ", "_").lower()[:30]
            pkg_dir = f"{domain}/packages/{niche_slug}"

            brand = pkg.brand or {}
            site_copy = _normalize_site_copy(pkg.site_copy or {})
            site_copy, _ = validate_site_copy(site_copy, auto_repair=True)
            brand, _ = validate_brand(brand, auto_repair=True)
            chosen_brand = {"name": domain, "tagline": ""}
            opts = brand.get("options", [])
            rec = brand.get("recommended", 0)
            if opts and rec < len(opts):
                chosen_brand = opts[rec]

            site_html = generate_standalone_site_html(domain, chosen_brand, brand, site_copy, pkg.hero_image_url)
            zf.writestr(f"{pkg_dir}/site.html", site_html)

            if pkg.sales_letter:
                raw_sales = pkg.sales_letter
                if raw_sales.strip().startswith("{"):
                    try:
                        parsed_sales = json.loads(raw_sales)
                        raw_sales = parsed_sales.get("content_markdown") or parsed_sales.get("body_markdown") or parsed_sales.get("sales_letter_markdown") or parsed_sales.get("content") or parsed_sales.get("markdown") or parsed_sales.get("body") or raw_sales
                    except (json.JSONDecodeError, AttributeError):
                        pass
                sales_html = generate_standalone_sales_html(domain, pkg.chosen_niche, raw_sales, brand)
                zf.writestr(f"{pkg_dir}/sales_letter.html", sales_html)
                zf.writestr(f"{pkg_dir}/sales_letter.md", raw_sales)

            zf.writestr(f"{pkg_dir}/brand.json", json.dumps(brand, indent=2))
            zf.writestr(f"{pkg_dir}/site_copy.json", json.dumps(site_copy, indent=2))

            if pkg.discovery_answers:
                zf.writestr(f"{pkg_dir}/discovery_answers.json", json.dumps(pkg.discovery_answers, indent=2))

            if pkg.hero_image_url:
                img_path = pkg.hero_image_url.lstrip("/")
                if os.path.exists(img_path):
                    zf.write(img_path, f"{pkg_dir}/hero_image.png")

            feature_imgs = pkg.feature_images or {}
            for fi_key, fi_data in feature_imgs.items():
                if isinstance(fi_data, dict) and fi_data.get("url"):
                    fi_path = fi_data["url"].lstrip("/")
                    if os.path.exists(fi_path):
                        fi_filename = fi_data.get("filename", os.path.basename(fi_path))
                        zf.write(fi_path, f"{pkg_dir}/images/{fi_filename}")

            if pkg.calculators and isinstance(pkg.calculators, dict):
                calc_data = pkg.calculators
                specs = calc_data.get("specs", [])
                rendered = calc_data.get("rendered", {})
                for ci, spec in enumerate(specs):
                    calc_id = spec.get("id", f"calc_{ci}")
                    calc_name = re.sub(r'[^a-z0-9_]', '', (spec.get('title', '') or calc_id).replace(" ", "_").lower())[:40]
                    if calc_id in rendered:
                        zf.writestr(f"{pkg_dir}/calculators/{calc_name}.html", rendered[calc_id])
                    zf.writestr(f"{pkg_dir}/calculators/{calc_name}_spec.json", json.dumps(spec, indent=2, default=str))
                if calc_data.get("html_bundle"):
                    zf.writestr(f"{pkg_dir}/calculators/_all_calculators.html", calc_data["html_bundle"])

            if pkg.reference_library and isinstance(pkg.reference_library, dict):
                ref_lib = pkg.reference_library
                try:
                    from app.services.reference_library import render_reference_html
                    brand_colors_export = {}
                    opts_e = (pkg.brand or {}).get("options", [])
                    rec_e = (pkg.brand or {}).get("recommended", 0)
                    if opts_e and rec_e < len(opts_e):
                        sel = opts_e[rec_e]
                        brand_colors_export = {"primary": sel.get("color_primary", "#6366f1"), "secondary": sel.get("color_secondary", "#8b5cf6"), "accent": sel.get("color_accent", "#f59e0b")}
                    ref_html = render_reference_html(ref_lib, brand_colors_export)
                    zf.writestr(f"{pkg_dir}/reference_library/index.html", ref_html)
                except Exception:
                    pass
                ref_data = {k: v for k, v in ref_lib.items() if k != "sections"}
                ref_data["section_count"] = len(ref_lib.get("sections", {}))
                zf.writestr(f"{pkg_dir}/reference_library/data.json", json.dumps(ref_lib, indent=2, default=str))

            augments = db.query(Augment).filter(Augment.package_id == pkg.id).all()
            seen_slugs = set()
            for aug in augments:
                aug_slug = re.sub(r'[^a-z0-9_]', '', aug.title.replace(" ", "_").lower())[:40]
                if not aug_slug:
                    aug_slug = f"augment_{aug.id}"
                while aug_slug in seen_slugs:
                    aug_slug = f"{aug_slug}_{aug.id}"
                seen_slugs.add(aug_slug)
                if aug.html_content:
                    zf.writestr(f"{pkg_dir}/augments/{aug_slug}.html", aug.html_content)
                if aug.config:
                    zf.writestr(f"{pkg_dir}/augments/{aug_slug}_config.json", json.dumps(aug.config, indent=2))

        manifest_files = []
        for item in zf.infolist():
            manifest_files.append({
                "filename": item.filename,
                "size_bytes": item.file_size,
                "compress_size": item.compress_size,
                "date": f"{item.date_time[0]:04d}-{item.date_time[1]:02d}-{item.date_time[2]:02d}T{item.date_time[3]:02d}:{item.date_time[4]:02d}:{item.date_time[5]:02d}",
            })
        manifest = {
            "domain": domain,
            "exported_at": datetime.datetime.utcnow().isoformat(),
            "total_files": len(manifest_files),
            "files": manifest_files,
        }
        zf.writestr(f"{domain}/manifest.json", json.dumps(manifest, indent=2))

    zip_size = len(zip_buffer.getvalue())
    zip_buffer.seek(0)
    safe_name = domain.replace(".", "_")
    return StreamingResponse(
        zip_buffer, media_type="application/zip",
        headers={
            "Content-Disposition": f'attachment; filename="aura_{safe_name}_export.zip"',
            "X-Zip-Size": str(zip_size),
        }
    )


@app.get("/api/export/{domain}/pdf")
async def api_export_pdf(domain: str, screenshots: bool = True, db: Session = Depends(get_db)):
    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")

    from app.services.export import generate_package_pdf, capture_site_screenshots

    screenshot_paths = []
    if screenshots:
        try:
            base_url = f"http://0.0.0.0:5000"
            screenshot_paths = await capture_site_screenshots(domain, base_url, _DEV_PREVIEW_TOKEN)
        except Exception as e:
            print(f"[export] Screenshot capture failed, continuing without: {e}")

    pdf_bytes = generate_package_pdf(pkg, screenshots=screenshot_paths if screenshot_paths else None)

    import shutil
    for sp in screenshot_paths:
        try:
            os.unlink(sp)
        except Exception:
            pass
    if screenshot_paths:
        try:
            parent = os.path.dirname(screenshot_paths[0])
            if parent and parent.startswith("/tmp/aura_screenshots_"):
                shutil.rmtree(parent, ignore_errors=True)
        except Exception:
            pass

    safe_name = domain.replace(".", "_")
    return StreamingResponse(
        io.BytesIO(pdf_bytes),
        media_type="application/pdf",
        headers={"Content-Disposition": f'attachment; filename="aura_{safe_name}_presentation.pdf"'}
    )


@app.get("/api/export/valuation/{domain}")
async def export_valuation_pdf(domain: str, request: Request, db: Session = Depends(get_db)):
    username = request.session.get("username")
    if not username:
        raise HTTPException(status_code=401, detail="Authentication required")

    domain_record = db.query(Domain).filter(Domain.domain == domain).first()
    if not domain_record or not domain_record.analysis:
        raise HTTPException(status_code=404, detail="Domain not analyzed")

    from app.services.valuation import valuate_domain
    from app.services.export import generate_valuation_pdf

    valuation = valuate_domain(domain, domain_record.analysis)
    pdf_bytes = generate_valuation_pdf(domain, valuation)

    return Response(content=pdf_bytes, media_type="application/pdf",
                   headers={"Content-Disposition": f'attachment; filename="{domain}_valuation.pdf"'})


@app.get("/api/export/{domain}/md")
async def api_export_md(domain: str, db: Session = Depends(get_db)):
    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")

    from app.services.export import generate_package_markdown
    md_content = generate_package_markdown(pkg)

    safe_name = domain.replace(".", "_")
    return StreamingResponse(
        io.BytesIO(md_content.encode("utf-8")),
        media_type="text/markdown",
        headers={"Content-Disposition": f'attachment; filename="aura_{safe_name}_package.md"'}
    )


def _augment_slug(augment_id, title):
    import re
    slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-')
    return f"{augment_id}-{slug}" if slug else str(augment_id)


def _generate_standalone_tools_section(augments, primary, secondary):
    if not augments:
        return ""
    cards = ""
    for aug in augments:
        slug = _augment_slug(aug.id, aug.title)
        desc = aug.description or ""
        cards += f'''<a href="tools/{slug}.html" style="display:block;background:#fff;border-radius:16px;padding:24px;border:1px solid #e5e7eb;text-decoration:none;transition:transform 0.2s,box-shadow 0.2s" onmouseover="this.style.transform='translateY(-4px)';this.style.boxShadow='0 12px 40px rgba(0,0,0,0.08)'" onmouseout="this.style.transform='';this.style.boxShadow=''">
<div style="display:flex;align-items:start;gap:16px">
<div style="width:40px;height:40px;border-radius:10px;background:linear-gradient(135deg,{primary},{secondary});display:flex;align-items:center;justify-content:center;flex-shrink:0">
<span style="color:#fff;font-size:1.2rem">&#9881;</span>
</div>
<div style="flex:1;min-width:0">
<div style="font-weight:700;color:#1f2937;margin-bottom:4px">{aug.title}</div>
<div style="color:#6b7280;font-size:0.875rem;line-height:1.5">{desc[:120]}{"..." if len(desc) > 120 else ""}</div>
</div>
<span style="color:#9ca3af;font-size:1.2rem;flex-shrink:0">&#8594;</span>
</div></a>'''

    return f'''<section id="tools" class="section" style="background:#f9fafb">
<div class="container">
<h2 style="font-size:2rem;font-weight:800;text-align:center;margin-bottom:12px">Tools & Resources</h2>
<p style="text-align:center;color:#6b7280;margin-bottom:48px;max-width:600px;margin-left:auto;margin-right:auto">Interactive tools to help you get the most out of your business.</p>
<div class="grid-2" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px">{cards}</div>
</div></section>'''


def generate_standalone_augment_html(domain, brand, brand_data, augment, all_augments=None):
    import html as html_mod
    primary = brand_data.get("color_primary", "#4F46E5")
    secondary = brand_data.get("color_secondary", "#7C3AED")
    brand_name = brand.get("name", domain)
    tagline = brand.get("tagline", "")

    aug_title = html_mod.escape(augment.title or f"Tool {augment.id}")
    aug_desc = html_mod.escape(augment.description or "")

    other_links = ""
    for aug in (all_augments or []):
        if aug.id != augment.id:
            slug = _augment_slug(aug.id, aug.title or f"tool-{aug.id}")
            other_links += f'<a href="{slug}.html" style="color:#6b7280;font-size:0.875rem;font-weight:500;text-decoration:none">{html_mod.escape(aug.title or f"Tool {aug.id}")}</a> '

    content = augment.html_content or ""
    if not content:
        content = f'''<div style="padding:48px;text-align:center;color:#6b7280">
<p style="font-size:3rem;margin-bottom:16px">&#128338;</p>
<h3 style="font-weight:700;color:#374151;margin-bottom:8px">Content Being Generated</h3>
<p>This tool is still being prepared. Check back shortly.</p>
<a href="index.html" style="display:inline-block;margin-top:24px;background:{primary};color:#fff;padding:10px 24px;border-radius:8px;font-weight:600;text-decoration:none">Back to Site</a>
</div>'''

    return f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{aug_title} - {brand_name}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#1f2937;-webkit-font-smoothing:antialiased;background:#f9fafb}}
a{{color:{primary};text-decoration:none}}
.augment-wrap{{max-width:900px;margin:0 auto;padding:40px 24px}}
@media(max-width:768px){{.augment-wrap{{padding:24px 16px}}}}
</style></head>
<body>
<nav style="background:rgba(255,255,255,0.95);backdrop-filter:blur(8px);padding:12px 24px;border-bottom:1px solid #e5e7eb;position:sticky;top:0;z-index:100">
<div style="max-width:1100px;margin:0 auto;display:flex;justify-content:space-between;align-items:center">
<a href="../index.html" style="font-size:1.125rem;font-weight:800;background:linear-gradient(135deg,{primary},{secondary});-webkit-background-clip:text;-webkit-text-fill-color:transparent;text-decoration:none">{brand_name}</a>
<div style="display:flex;gap:16px;align-items:center">
<a href="../index.html" style="color:#6b7280;font-size:0.875rem;font-weight:500;text-decoration:none">&#8592; Back to Site</a>
{other_links}
</div></div></nav>

<div class="augment-wrap">
<div style="text-align:center;margin-bottom:32px">
<span style="display:inline-block;background:linear-gradient(135deg,{primary}22,{secondary}22);color:{primary};font-size:0.75rem;font-weight:600;padding:4px 12px;border-radius:999px;margin-bottom:12px">Interactive Tool</span>
<h1 style="font-size:1.875rem;font-weight:800;margin-bottom:8px">{aug_title}</h1>
<p style="color:#6b7280;max-width:600px;margin:0 auto">{aug_desc}</p>
</div>
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.06)">
{content}
</div>
</div>

<footer style="background:#111827;color:#9ca3af;padding:32px 24px;margin-top:48px">
<div style="max-width:1100px;margin:0 auto;text-align:center">
<p style="font-weight:700;color:#fff;margin-bottom:4px">{brand_name}</p>
<p style="font-size:0.8rem">{tagline}</p>
</div></footer>
</body></html>"""


def generate_standalone_site_html(domain, brand, brand_data, site_copy, hero_image_url=None, augments=None):
    primary = brand_data.get("color_primary", "#4F46E5")
    secondary = brand_data.get("color_secondary", "#7C3AED")
    accent = brand_data.get("color_accent", "#06B6D4")
    features = site_copy.get("features", [])
    faq = site_copy.get("faq_items", [])
    testimonials = site_copy.get("testimonials", [])

    def _hex_rgb(h):
        h = h.lstrip("#")
        if len(h) == 3:
            h = h[0]*2 + h[1]*2 + h[2]*2
        try:
            return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
        except Exception:
            return (79, 70, 229)

    pr, pg, pb = _hex_rgb(primary)
    sr, sg, sb = _hex_rgb(secondary)

    svg_icons = {
        "shield": '<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
        "chart": '<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>',
        "globe": '<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
        "zap": '<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
        "users": '<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
        "star": '<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
        "target": '<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>',
        "clock": '<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
        "heart": '<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
        "check": '<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>',
        "book": '<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
        "rocket": '<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>',
    }

    dot_grid = f"radial-gradient(circle, rgba({pr},{pg},{pb},0.07) 1.5px, transparent 1.5px)"

    features_html = ""
    for idx, f in enumerate(features):
        icon_key = f.get("icon", "star")
        icon_svg = svg_icons.get(icon_key, svg_icons["star"])
        features_html += f'''<div style="background:#ffffff;border-radius:18px;padding:32px 28px 28px;transition:transform 0.25s cubic-bezier(.4,0,.2,1),box-shadow 0.25s cubic-bezier(.4,0,.2,1);border:1px solid rgba({pr},{pg},{pb},0.12);border-top:4px solid {primary};box-shadow:0 1px 3px rgba(0,0,0,0.04),0 8px 24px rgba(0,0,0,0.07),0 20px 48px rgba({pr},{pg},{pb},0.05);position:relative;overflow:hidden;background-image:{dot_grid};background-size:22px 22px" onmouseover="this.style.transform='translateY(-6px)';this.style.boxShadow='0 2px 4px rgba(0,0,0,0.04),0 16px 40px rgba(0,0,0,0.1),0 32px 64px rgba({pr},{pg},{pb},0.1)'" onmouseout="this.style.transform='';this.style.boxShadow='0 1px 3px rgba(0,0,0,0.04),0 8px 24px rgba(0,0,0,0.07),0 20px 48px rgba({pr},{pg},{pb},0.05)'">
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,255,255,0) 30%,rgba(255,255,255,0.97) 100%);pointer-events:none"></div>
<div style="position:relative;z-index:1">
<div style="width:52px;height:52px;border-radius:14px;background:linear-gradient(135deg,rgba({pr},{pg},{pb},0.14),rgba({pr},{pg},{pb},0.06));border:1px solid rgba({pr},{pg},{pb},0.18);display:flex;align-items:center;justify-content:center;margin-bottom:20px;color:{primary};box-shadow:0 4px 12px rgba({pr},{pg},{pb},0.15)">{icon_svg}</div>
<h3 style="font-size:1.1rem;font-weight:700;margin-bottom:10px;color:#111827;line-height:1.35;font-family:\'Playfair Display\',Georgia,serif">{f.get("title","")}</h3>
<p style="color:#6b7280;font-size:0.9rem;line-height:1.7;margin:0">{f.get("description","")}</p>
</div>
<div style="position:absolute;bottom:0;left:0;right:0;height:1px;background:linear-gradient(90deg,{primary},transparent)"></div>
</div>'''

    faq_html = ""
    for i, item in enumerate(faq):
        faq_html += f'''<details style="background:#ffffff;border-radius:14px;padding:0;margin-bottom:10px;border:1px solid rgba({pr},{pg},{pb},0.1);box-shadow:0 2px 8px rgba(0,0,0,0.05),0 8px 24px rgba(0,0,0,0.04);overflow:hidden;border-left:3px solid {primary}">
<summary style="padding:20px 24px;cursor:pointer;font-weight:600;color:#1f2937;list-style:none;display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:0.975rem">{item.get("question","")}<span style="color:{primary};font-size:1.3rem;flex-shrink:0;line-height:1;transition:transform 0.2s">+</span></summary>
<div style="padding:0 24px 20px;color:#6b7280;line-height:1.8;font-size:0.9rem;border-top:1px solid rgba({pr},{pg},{pb},0.07)">{item.get("answer","")}</div></details>'''

    _star_svg = f'<svg width="14" height="14" fill="{primary}" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'
    _five_stars = _star_svg * 5

    testimonials_html = ""
    for t in testimonials:
        initial = (t.get("name") or "A")[0].upper()
        testimonials_html += f'''<div style="background:#ffffff;border-radius:18px;padding:36px 32px 28px;border:1px solid rgba({pr},{pg},{pb},0.1);border-top:4px solid {primary};box-shadow:0 1px 3px rgba(0,0,0,0.04),0 8px 24px rgba(0,0,0,0.07),0 20px 48px rgba({pr},{pg},{pb},0.05);position:relative;overflow:hidden;background-image:{dot_grid};background-size:22px 22px">
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,255,255,0) 20%,rgba(255,255,255,0.97) 100%);pointer-events:none"></div>
<div style="position:relative;z-index:1">
<div style="font-size:4rem;color:{primary};opacity:0.15;position:absolute;top:-8px;right:24px;font-family:Georgia,serif;line-height:1">&ldquo;</div>
<p style="color:#374151;font-style:italic;line-height:1.8;margin-bottom:24px;font-size:0.95rem;position:relative;z-index:1">{t.get("quote","")}</p>
<div style="display:flex;align-items:center;gap:14px;border-top:1px solid rgba({pr},{pg},{pb},0.08);padding-top:20px">
<div style="width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,{primary},{secondary});color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:1rem;flex-shrink:0;box-shadow:0 4px 12px rgba({pr},{pg},{pb},0.3)">{initial}</div>
<div><div style="font-weight:700;color:#111827;font-size:0.9rem">{t.get("name","")}</div><div style="color:#9ca3af;font-size:0.8rem;margin-top:2px">{t.get("role","")}</div></div>
<div style="margin-left:auto;display:flex;gap:2px">{_five_stars}</div>
</div></div></div>'''

    hero_bg = f'linear-gradient(135deg, {primary}, {secondary})'
    overlay = ''
    if hero_image_url:
        try:
            img_disk_path = hero_image_url.lstrip("/")
            if os.path.exists(img_disk_path):
                with open(img_disk_path, "rb") as img_f:
                    b64_data = base64.b64encode(img_f.read()).decode("utf-8")
                hero_bg = f'url(data:image/png;base64,{b64_data})'
                overlay = '<div style="position:absolute;inset:0;background:linear-gradient(135deg,rgba(0,0,0,0.6),rgba(0,0,0,0.3))"></div>'
            else:
                hero_bg = f'url({hero_image_url})'
                overlay = '<div style="position:absolute;inset:0;background:linear-gradient(135deg,rgba(0,0,0,0.6),rgba(0,0,0,0.3))"></div>'
        except Exception:
            pass

    eyebrow_style = f"display:inline-flex;align-items:center;gap:6px;background:rgba({pr},{pg},{pb},0.08);color:{primary};font-size:0.72rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;padding:6px 14px;border-radius:999px;border:1px solid rgba({pr},{pg},{pb},0.15);margin-bottom:16px"
    section_h2 = f"font-size:clamp(1.75rem,3.5vw,2.5rem);font-weight:800;color:#111827;line-height:1.2;font-family:'Playfair Display',Georgia,serif;margin-bottom:12px"
    divider = f'<div style="height:1px;background:linear-gradient(90deg,transparent 5%,rgba({pr},{pg},{pb},0.12) 30%,rgba({pr},{pg},{pb},0.12) 70%,transparent 95%)"></div>'

    return f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{brand.get('name', domain)} - {brand.get('tagline', '')}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#1f2937;-webkit-font-smoothing:antialiased;background:#ffffff}}
a{{color:{primary};text-decoration:none}}
details[open] summary span{{transform:rotate(45deg);display:inline-block}}
.section{{padding:96px 24px}}
.container{{max-width:1100px;margin:0 auto}}
.section-label{{font-size:0.72rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:{primary};margin-bottom:8px}}
h1,h2,h3{{font-family:'Playfair Display',Georgia,serif}}
@keyframes fadeUp{{from{{opacity:0;transform:translateY(20px)}}to{{opacity:1;transform:translateY(0)}}}}
.fade-up{{animation:fadeUp 0.6s ease forwards}}
@media(max-width:768px){{.grid-2,.grid-3{{grid-template-columns:1fr!important}}.section{{padding:56px 16px}}}}
</style></head>
<body>
<nav style="background:rgba(255,255,255,0.97);padding:14px 24px;border-bottom:1px solid rgba({pr},{pg},{pb},0.1);position:sticky;top:0;z-index:100;backdrop-filter:blur(12px);box-shadow:0 1px 12px rgba(0,0,0,0.06)">
<div style="max-width:1100px;margin:0 auto;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:1.2rem;font-weight:800;font-family:'Playfair Display',Georgia,serif;background:linear-gradient(135deg,{primary},{secondary});-webkit-background-clip:text;-webkit-text-fill-color:transparent">{brand.get('name', domain)}</span>
<div style="display:flex;gap:24px;align-items:center">
<a href="#about" style="color:#6b7280;font-size:0.85rem;font-weight:500;letter-spacing:0.01em">About</a>
<a href="#features" style="color:#6b7280;font-size:0.85rem;font-weight:500;letter-spacing:0.01em">Features</a>
<a href="#faq" style="color:#6b7280;font-size:0.85rem;font-weight:500;letter-spacing:0.01em">FAQ</a>
{'<a href="#tools" style="color:#6b7280;font-size:0.85rem;font-weight:500">Tools</a>' if augments else ''}
<a href="admin.html" style="color:#9ca3af;font-size:0.8rem">&#9881;</a>
<a href="#" style="background:linear-gradient(135deg,{primary},{secondary});color:#fff;padding:9px 22px;border-radius:8px;font-weight:600;font-size:0.85rem;box-shadow:0 4px 12px rgba({pr},{pg},{pb},0.3);transition:transform 0.2s,box-shadow 0.2s" onmouseover="this.style.transform='translateY(-1px)';this.style.boxShadow='0 8px 20px rgba({pr},{pg},{pb},0.4)'" onmouseout="this.style.transform='';this.style.boxShadow='0 4px 12px rgba({pr},{pg},{pb},0.3)'">{site_copy.get('cta_text', 'Get Started')}</a>
</div></div></nav>

<section style="background:{hero_bg};background-size:cover;background-position:center;position:relative;color:#fff;padding:128px 24px 100px;text-align:center;min-height:72vh;display:flex;align-items:center">
{overlay}
<div style="position:relative;z-index:1;max-width:820px;margin:0 auto">
<div style="display:inline-flex;align-items:center;gap:8px;background:rgba(255,255,255,0.12);border:1px solid rgba(255,255,255,0.25);color:rgba(255,255,255,0.9);font-size:0.75rem;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;padding:6px 16px;border-radius:999px;margin-bottom:24px;backdrop-filter:blur(4px)">{domain}</div>
<h1 style="font-size:clamp(2.2rem,5.5vw,3.75rem);font-weight:800;margin-bottom:20px;line-height:1.08;font-family:'Playfair Display',Georgia,serif;letter-spacing:-0.01em">{site_copy.get('headline', brand.get('name', ''))}</h1>
<p style="font-size:clamp(1rem,2.5vw,1.3rem);opacity:0.88;margin-bottom:20px;line-height:1.65;font-weight:400">{site_copy.get('subheadline', brand.get('tagline', ''))}</p>
<p style="font-size:1rem;opacity:0.75;max-width:580px;margin:0 auto 36px;line-height:1.75">{site_copy.get('hero_body', '')}</p>
<div style="display:flex;gap:14px;justify-content:center;flex-wrap:wrap">
<a href="#" style="display:inline-block;background:#fff;color:{primary};padding:15px 38px;border-radius:10px;font-weight:700;font-size:1rem;box-shadow:0 8px 32px rgba(0,0,0,0.25);transition:transform 0.2s,box-shadow 0.2s" onmouseover="this.style.transform='translateY(-2px)';this.style.boxShadow='0 14px 40px rgba(0,0,0,0.3)'" onmouseout="this.style.transform='';this.style.boxShadow='0 8px 32px rgba(0,0,0,0.25)'">{site_copy.get('cta_text', 'Get Started')}</a>
<a href="#about" style="display:inline-block;background:rgba(255,255,255,0.1);color:#fff;padding:15px 38px;border-radius:10px;font-weight:600;font-size:1rem;border:1.5px solid rgba(255,255,255,0.35);backdrop-filter:blur(4px)">Learn More</a>
</div></div></section>

{divider}
<section id="about" class="section" style="background:#ffffff">
<div class="container" style="text-align:center;max-width:780px">
<div style="{eyebrow_style}">Our Story</div>
<h2 style="{section_h2}">{site_copy.get('about_title', 'About Us')}</h2>
<p style="color:#6b7280;font-size:1.05rem;line-height:1.85;margin-top:16px">{site_copy.get('about', '')}</p>
</div></section>

{f'''{divider}
<section id="features" class="section" style="background:linear-gradient(180deg,#f9fafb,#f1f5f9)">
<div class="container">
<div style="text-align:center;margin-bottom:56px">
<div style="{eyebrow_style}">What We Offer</div>
<h2 style="{section_h2}">Everything You Need</h2>
<p style="color:#6b7280;margin-top:14px;font-size:1rem;line-height:1.75;max-width:560px;margin-left:auto;margin-right:auto">{site_copy.get("offer","")}</p>
</div>
<div class="grid-3" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:28px">{features_html}</div>
</div></section>''' if features_html else ''}

{f'''{divider}
<section class="section" style="background:#ffffff">
<div class="container">
<div style="text-align:center;margin-bottom:56px">
<div style="{eyebrow_style}">Testimonials</div>
<h2 style="{section_h2}">What People Say</h2>
</div>
<div class="grid-3" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:28px">{testimonials_html}</div>
</div></section>''' if testimonials_html else ''}

{f'''{divider}
<section id="faq" class="section" style="background:linear-gradient(180deg,#f9fafb,#eff6ff)">
<div class="container" style="max-width:720px">
<div style="text-align:center;margin-bottom:48px">
<div style="{eyebrow_style}">FAQ</div>
<h2 style="{section_h2}">Frequently Asked Questions</h2>
</div>
{faq_html}
</div></section>''' if faq_html else ''}

{_generate_standalone_tools_section(augments or [], primary, secondary)}

<section class="section" style="background:linear-gradient(135deg,{primary} 0%,{secondary} 100%);color:#fff;text-align:center;position:relative;overflow:hidden">
<div style="position:absolute;inset:0;background-image:radial-gradient(circle,rgba(255,255,255,0.06) 1.5px,transparent 1.5px);background-size:28px 28px;pointer-events:none"></div>
<div class="container" style="position:relative;z-index:1">
<h2 style="font-size:clamp(1.75rem,3.5vw,2.5rem);font-weight:800;margin-bottom:16px;font-family:'Playfair Display',Georgia,serif">Ready to Get Started?</h2>
<p style="opacity:0.88;margin-bottom:36px;font-size:1.05rem;max-width:500px;margin-left:auto;margin-right:auto;line-height:1.7">{site_copy.get('subheadline', '')}</p>
<a href="#" style="display:inline-block;background:#fff;color:{primary};padding:16px 44px;border-radius:10px;font-weight:700;font-size:1.05rem;box-shadow:0 8px 32px rgba(0,0,0,0.2);transition:transform 0.2s" onmouseover="this.style.transform='translateY(-2px)'" onmouseout="this.style.transform=''">{site_copy.get('cta_text', 'Get Started')}</a>
</div></section>

<footer style="background:#0f172a;color:#94a3b8;padding:56px 24px 40px">
<div style="max-width:1100px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:32px;padding-bottom:32px;border-bottom:1px solid rgba(255,255,255,0.08)">
<div>
<p style="font-size:1.2rem;font-weight:800;color:#fff;margin-bottom:6px;font-family:'Playfair Display',Georgia,serif">{brand.get('name', domain)}</p>
<p style="font-size:0.875rem;max-width:280px;line-height:1.6">{brand.get('tagline', '')}</p>
</div>
<div style="display:flex;gap:40px;flex-wrap:wrap">
<div><p style="color:#fff;font-weight:600;font-size:0.8rem;letter-spacing:0.08em;text-transform:uppercase;margin-bottom:12px">Site</p>
<div style="display:flex;flex-direction:column;gap:8px">
<a href="#about" style="color:#94a3b8;font-size:0.875rem;transition:color 0.2s" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#94a3b8'">About</a>
<a href="#features" style="color:#94a3b8;font-size:0.875rem" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#94a3b8'">Features</a>
<a href="#faq" style="color:#94a3b8;font-size:0.875rem" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#94a3b8'">FAQ</a>
</div></div></div></div>
<p style="margin-top:32px;font-size:0.8rem;color:#475569">&copy; {brand.get('name', domain)} &mdash; {domain}</p>
</div></footer>
</body></html>"""


def generate_standalone_sales_html(domain, niche, sales_letter_md, brand):
    rendered = render_markdown(sales_letter_md)
    brand_name = ""
    primary = "#4F46E5"
    opts = brand.get("options", [])
    rec = brand.get("recommended", 0)
    if opts and rec < len(opts):
        brand_name = opts[rec].get("name", "")
    primary = brand.get("color_primary", primary)

    return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Sales Letter - {domain}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#1f2937;background:linear-gradient(135deg,#f0f4ff,#faf5ff);min-height:100vh;padding:40px 20px}}
.container{{max-width:720px;margin:0 auto;background:#fff;border-radius:20px;padding:56px;box-shadow:0 4px 24px rgba(0,0,0,0.08)}}
h1{{font-size:1.875rem;font-weight:800;margin-bottom:8px;line-height:1.2}}
h2{{font-size:1.5rem;font-weight:700;margin:32px 0 12px;color:#1f2937}}
h3{{font-size:1.25rem;font-weight:600;margin:24px 0 8px;color:#374151}}
p{{margin-bottom:16px;line-height:1.8;color:#4b5563}}
ul,ol{{padding-left:24px;margin-bottom:16px}}
li{{margin-bottom:8px;color:#4b5563;line-height:1.7}}
strong{{color:#1f2937}}
hr{{border:none;border-top:1px solid #e5e7eb;margin:32px 0}}
blockquote{{border-left:4px solid {primary};padding:16px 24px;margin:24px 0;background:#f9fafb;border-radius:0 8px 8px 0}}
blockquote p{{color:#374151;font-style:italic;margin:0}}
@media(max-width:640px){{.container{{padding:32px 24px;border-radius:12px}}body{{padding:16px}}}}
</style></head>
<body><div class="container">
<div style="border-bottom:2px solid #e5e7eb;padding-bottom:24px;margin-bottom:32px">
<h1>{domain}</h1>
<p style="color:{primary};font-size:1.1rem;font-weight:600;margin-bottom:4px">Niche: {niche}</p>
{f'<p style="color:#9ca3af;font-size:0.95rem">Brand: {brand_name}</p>' if brand_name else ''}
</div>
<div class="prose">{rendered}</div>
<div style="margin-top:48px;padding-top:24px;border-top:2px solid #e5e7eb;text-align:center">
<p style="color:#9ca3af;font-size:0.85rem">Generated by Aura &mdash; Domain to Business Generator</p>
</div>
</div></body></html>"""


def _generate_standalone_doc_html(domain, brand_name, primary, secondary, doc_title, doc_tier, doc_content_md, all_docs_by_tier):
    rendered = render_markdown(doc_content_md)

    tier_nav_html = ""
    same_tier_docs = all_docs_by_tier.get(doc_tier, {})
    if same_tier_docs:
        links = []
        for dk, dv in same_tier_docs.items():
            links.append(f'<a href="{dk}.html" style="color:{primary};font-size:0.85rem;text-decoration:none;padding:4px 8px;border-radius:4px;background:#f3f4f6">{dv.get("title", dk)}</a>')
        tier_nav_html = f'<div style="margin-bottom:24px;display:flex;flex-wrap:wrap;gap:8px">{" ".join(links)}</div>'

    return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{doc_title} - {domain}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#1f2937;background:#f8fafc;min-height:100vh}}
.container{{max-width:780px;margin:0 auto;padding:40px 20px}}
.card{{background:#fff;border-radius:16px;padding:48px;box-shadow:0 4px 24px rgba(0,0,0,0.06);border:1px solid #e5e7eb}}
h1{{font-size:1.75rem;font-weight:800;margin-bottom:8px;line-height:1.2}}
h2{{font-size:1.4rem;font-weight:700;margin:28px 0 12px;color:#1f2937}}
h3{{font-size:1.15rem;font-weight:600;margin:20px 0 8px;color:#374151}}
p{{margin-bottom:14px;line-height:1.8;color:#4b5563}}
ul,ol{{padding-left:24px;margin-bottom:16px}}
li{{margin-bottom:6px;color:#4b5563;line-height:1.7}}
strong{{color:#1f2937}}
hr{{border:none;border-top:1px solid #e5e7eb;margin:28px 0}}
blockquote{{border-left:4px solid {primary};padding:14px 20px;margin:20px 0;background:#f9fafb;border-radius:0 8px 8px 0}}
blockquote p{{color:#374151;font-style:italic;margin:0}}
table{{width:100%;border-collapse:collapse;margin:16px 0}}
th,td{{border:1px solid #e5e7eb;padding:10px 14px;text-align:left;font-size:0.9rem}}
th{{background:#f3f4f6;font-weight:600}}
@media(max-width:640px){{.card{{padding:28px 20px;border-radius:12px}}.container{{padding:16px}}}}
</style></head>
<body>
<nav style="background:#fff;padding:14px 24px;border-bottom:1px solid #e5e7eb;position:sticky;top:0;z-index:100;backdrop-filter:blur(8px);background:rgba(255,255,255,0.95)">
<div style="max-width:780px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
<span style="font-weight:700;background:linear-gradient(135deg,{primary},{secondary});-webkit-background-clip:text;-webkit-text-fill-color:transparent">{brand_name}</span>
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap">
<a href="../../index.html" style="color:#6b7280;font-size:0.85rem;font-weight:500;text-decoration:none">&larr; Home</a>
<a href="../../admin.html" style="color:#6b7280;font-size:0.85rem;font-weight:500;text-decoration:none">&#9881; Admin</a>
</div>
</div></nav>
<div class="container">
<div class="card">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px">
<span style="background:linear-gradient(135deg,{primary},{secondary});color:#fff;padding:4px 12px;border-radius:6px;font-size:0.75rem;font-weight:600;text-transform:uppercase">{doc_tier}</span>
</div>
<h1>{doc_title}</h1>
<div style="height:1px;background:#e5e7eb;margin:20px 0"></div>
{tier_nav_html}
<div class="prose">{rendered}</div>
</div>
<div style="margin-top:32px;text-align:center">
<p style="color:#9ca3af;font-size:0.8rem">Generated by Aura &mdash; Domain to Business Generator</p>
</div>
</div>
</body></html>"""


def generate_standalone_admin_html(domain, brand, brand_data, site_copy, pkg, augments, brand_kit_assets, business_docs, graphics_assets, deployed_files_manifest):
    from app.main import _augment_slug
    primary = brand_data.get("color_primary", "#4F46E5")
    secondary = brand_data.get("color_secondary", "#7C3AED")
    brand_name = brand.get("name", domain)
    tagline = brand.get("tagline", "")
    niche = pkg.chosen_niche if pkg else ""
    gen_date = pkg.created_at.strftime("%B %d, %Y") if pkg and pkg.created_at else "N/A"

    total_pages = sum(1 for k in deployed_files_manifest if k.endswith(".html"))
    total_images = sum(1 for k in deployed_files_manifest if any(k.endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp")))
    total_docs = len(business_docs) if business_docs else 0
    total_files = len(deployed_files_manifest)

    pages_rows = ""
    page_list = []
    page_list.append(("index.html", "Main Site", "&#127968;"))
    if any(k == "sales.html" for k in deployed_files_manifest):
        page_list.append(("sales.html", "Sales Letter", "&#128176;"))
    page_list.append(("admin.html", "Site Admin (This Page)", "&#9881;"))
    for aug in (augments or []):
        slug = _augment_slug(aug.id, aug.title)
        page_list.append((f"tools/{slug}.html", f"Tool: {aug.title}", "&#9889;"))
    if business_docs:
        for dk, dv in business_docs.items():
            tier = dv.get("tier", "general")
            tier_slug = tier.lower().replace(" ", "-")
            page_list.append((f"docs/{tier_slug}/{dk}.html", f"Doc: {dv.get('title', dk)}", "&#128196;"))
    for path, title, icon in page_list:
        pages_rows += f'<tr><td style="padding:10px 14px;border-bottom:1px solid #334155">{icon} <a href="{path}" style="color:#60a5fa;text-decoration:none">{path}</a></td><td style="padding:10px 14px;border-bottom:1px solid #334155;color:#cbd5e1">{title}</td></tr>'

    gallery_html = ""
    img_items = []
    hero_url = pkg.hero_image_url if pkg else None
    if hero_url:
        img_items.append(("Hero Image", os.path.basename(hero_url), f"images/{os.path.basename(hero_url)}"))
    for fi_url in (pkg.feature_images or []) if pkg else []:
        if fi_url:
            img_items.append(("Feature Image", os.path.basename(fi_url), f"images/{os.path.basename(fi_url)}"))
    for ga in (graphics_assets or []):
        fn = ga.get("filename", os.path.basename(ga.get("url", "")))
        img_items.append(("Graphics Pack", fn, f"images/graphics/{fn}"))
    for bka in (brand_kit_assets or []):
        cls = bka.classification or "uncategorized"
        img_items.append((f"Brand Kit: {cls}", bka.filename, f"assets/brand-kit/{cls}/{bka.filename}"))
    for cat, fname, rel_path in img_items:
        gallery_html += f'''<div style="background:#0f172a;border-radius:10px;overflow:hidden;border:1px solid #334155">
<div style="height:140px;overflow:hidden;display:flex;align-items:center;justify-content:center;background:#1e293b"><img src="{rel_path}" style="max-width:100%;max-height:140px;object-fit:contain" alt="{fname}" onerror="this.style.display='none';this.parentElement.innerHTML='<span style=\\'color:#475569;font-size:2rem\\'>&#128444;</span>'"></div>
<div style="padding:10px 12px"><div style="font-size:0.75rem;color:#94a3b8;margin-bottom:2px">{cat}</div><div style="font-size:0.8rem;color:#e2e8f0;word-break:break-all">{fname}</div></div></div>'''

    docs_html = ""
    if business_docs:
        docs_by_tier = {}
        for dk, dv in business_docs.items():
            tier = dv.get("tier", "general")
            docs_by_tier.setdefault(tier, []).append((dk, dv))
        for tier, doc_list in docs_by_tier.items():
            tier_slug = tier.lower().replace(" ", "-")
            docs_html += f'<div style="margin-bottom:20px"><div style="display:flex;align-items:center;gap:8px;margin-bottom:12px"><span style="background:linear-gradient(135deg,{primary},{secondary});color:#fff;padding:3px 10px;border-radius:5px;font-size:0.75rem;font-weight:600">{tier}</span><span style="color:#64748b;font-size:0.8rem">{len(doc_list)} document{"s" if len(doc_list)!=1 else ""}</span></div>'
            for dk, dv in doc_list:
                docs_html += f'<a href="docs/{tier_slug}/{dk}.html" style="display:block;background:#0f172a;border:1px solid #334155;border-radius:8px;padding:12px 16px;margin-bottom:6px;text-decoration:none;color:#e2e8f0;font-size:0.9rem;transition:border-color 0.2s" onmouseover="this.style.borderColor=\'{primary}\'" onmouseout="this.style.borderColor=\'#334155\'">{dv.get("title", dk)}</a>'
            docs_html += '</div>'
    else:
        docs_html = '<p style="color:#64748b;font-size:0.9rem">No business documents generated yet.</p>'

    tools_html = ""
    if augments:
        for aug in augments:
            slug = _augment_slug(aug.id, aug.title)
            tools_html += f'<div style="background:#0f172a;border:1px solid #334155;border-radius:10px;padding:16px 20px;margin-bottom:8px"><div style="display:flex;justify-content:space-between;align-items:start;gap:12px;flex-wrap:wrap"><div><div style="font-weight:600;color:#e2e8f0;margin-bottom:4px">{aug.title}</div><div style="font-size:0.85rem;color:#94a3b8;line-height:1.5">{aug.description or ""}</div></div><a href="tools/{slug}.html" style="color:{primary};font-size:0.8rem;font-weight:600;text-decoration:none;white-space:nowrap">Open &rarr;</a></div></div>'
    else:
        tools_html = '<p style="color:#64748b;font-size:0.9rem">No interactive tools generated yet.</p>'

    manifest_rows = ""
    for fpath in sorted(deployed_files_manifest.keys()):
        val = deployed_files_manifest[fpath]
        if fpath.endswith(".html"):
            ftype = "HTML"
        elif any(fpath.endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp")):
            ftype = "Image"
        elif fpath.startswith("docs/"):
            ftype = "Document"
        elif fpath.startswith("assets/"):
            ftype = "Asset"
        else:
            ftype = "Other"
        size_str = f"{val:,} bytes" if isinstance(val, int) else str(val)
        manifest_rows += f'<tr><td style="padding:8px 12px;border-bottom:1px solid #334155;color:#cbd5e1;font-size:0.8rem;font-family:monospace">{fpath}</td><td style="padding:8px 12px;border-bottom:1px solid #334155;color:#94a3b8;font-size:0.8rem">{ftype}</td><td style="padding:8px 12px;border-bottom:1px solid #334155;color:#94a3b8;font-size:0.8rem;text-align:right">{size_str}</td></tr>'

    return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Site Admin - {domain}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
html{{scroll-behavior:smooth}}
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;-webkit-font-smoothing:antialiased}}
a{{color:#60a5fa;text-decoration:none}}
.section{{padding:48px 24px}}
.container{{max-width:1100px;margin:0 auto}}
.card{{background:#1e293b;border:1px solid #334155;border-radius:14px;padding:28px;margin-bottom:20px}}
@media(max-width:768px){{.grid-gallery{{grid-template-columns:repeat(2,1fr)!important}}.section{{padding:32px 16px}}.card{{padding:20px}}}}
@media(max-width:480px){{.grid-gallery{{grid-template-columns:1fr!important}}}}
</style></head>
<body>
<nav style="background:#1e293b;padding:14px 24px;border-bottom:1px solid #334155;position:sticky;top:0;z-index:100;backdrop-filter:blur(12px);background:rgba(30,41,59,0.95)">
<div style="max-width:1100px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:1.1rem;font-weight:800;background:linear-gradient(135deg,{primary},{secondary});-webkit-background-clip:text;-webkit-text-fill-color:transparent">{brand_name}</span>
<span style="background:#334155;color:#94a3b8;padding:3px 10px;border-radius:6px;font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.05em">Site Admin</span>
</div>
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;font-size:0.8rem">
<a href="#overview" style="color:#94a3b8">Overview</a>
<a href="#pages" style="color:#94a3b8">&#128196; Pages</a>
<a href="#graphics" style="color:#94a3b8">&#127912; Graphics</a>
<a href="#docs" style="color:#94a3b8">&#128203; Docs</a>
<a href="#tools" style="color:#94a3b8">&#9881; Tools</a>
<a href="#manifest" style="color:#94a3b8">&#128193; Files</a>
<a href="index.html" style="color:{primary};font-weight:600">&larr; View Site</a>
</div>
</div></nav>

<div class="section" id="overview">
<div class="container">
<div class="card" style="background:linear-gradient(135deg,{primary}22,{secondary}22);border-color:{primary}44">
<div style="display:flex;justify-content:space-between;align-items:start;flex-wrap:wrap;gap:20px">
<div>
<h1 style="font-size:1.6rem;font-weight:800;margin-bottom:6px">{brand_name}</h1>
<p style="color:#94a3b8;font-size:0.95rem;margin-bottom:4px">{domain}</p>
<p style="color:#64748b;font-size:0.85rem">Niche: {niche} &bull; {tagline}</p>
<p style="color:#475569;font-size:0.8rem;margin-top:8px">Generated: {gen_date}</p>
</div>
<div style="display:flex;gap:16px;flex-wrap:wrap">
<div style="text-align:center;background:#0f172a;border-radius:10px;padding:14px 20px;min-width:80px"><div style="font-size:1.5rem;font-weight:800;background:linear-gradient(135deg,{primary},{secondary});-webkit-background-clip:text;-webkit-text-fill-color:transparent">{total_pages}</div><div style="font-size:0.7rem;color:#64748b;text-transform:uppercase">Pages</div></div>
<div style="text-align:center;background:#0f172a;border-radius:10px;padding:14px 20px;min-width:80px"><div style="font-size:1.5rem;font-weight:800;background:linear-gradient(135deg,{primary},{secondary});-webkit-background-clip:text;-webkit-text-fill-color:transparent">{total_images}</div><div style="font-size:0.7rem;color:#64748b;text-transform:uppercase">Images</div></div>
<div style="text-align:center;background:#0f172a;border-radius:10px;padding:14px 20px;min-width:80px"><div style="font-size:1.5rem;font-weight:800;background:linear-gradient(135deg,{primary},{secondary});-webkit-background-clip:text;-webkit-text-fill-color:transparent">{total_docs}</div><div style="font-size:0.7rem;color:#64748b;text-transform:uppercase">Docs</div></div>
<div style="text-align:center;background:#0f172a;border-radius:10px;padding:14px 20px;min-width:80px"><div style="font-size:1.5rem;font-weight:800;background:linear-gradient(135deg,{primary},{secondary});-webkit-background-clip:text;-webkit-text-fill-color:transparent">{total_files}</div><div style="font-size:0.7rem;color:#64748b;text-transform:uppercase">Total Files</div></div>
</div>
</div>
</div>
</div></div>

<div class="section" id="pages" style="padding-top:0">
<div class="container">
<h2 style="font-size:1.3rem;font-weight:700;margin-bottom:16px;display:flex;align-items:center;gap:8px"><span>&#128196;</span> Pages Directory</h2>
<div class="card" style="padding:0;overflow:hidden">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="background:#0f172a"><th style="padding:12px 14px;text-align:left;color:#94a3b8;font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #334155">Path</th><th style="padding:12px 14px;text-align:left;color:#94a3b8;font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #334155">Description</th></tr></thead>
<tbody>{pages_rows}</tbody>
</table>
</div>
</div></div>

<div class="section" id="graphics" style="padding-top:0">
<div class="container">
<h2 style="font-size:1.3rem;font-weight:700;margin-bottom:16px;display:flex;align-items:center;gap:8px"><span>&#127912;</span> Graphics &amp; Images Gallery</h2>
<div class="grid-gallery" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px">
{gallery_html if gallery_html else '<p style="color:#64748b;font-size:0.9rem;grid-column:1/-1">No images available.</p>'}
</div>
</div></div>

<div class="section" id="docs" style="padding-top:0">
<div class="container">
<h2 style="font-size:1.3rem;font-weight:700;margin-bottom:16px;display:flex;align-items:center;gap:8px"><span>&#128203;</span> Business Documents</h2>
<div class="card">{docs_html}</div>
</div></div>

<div class="section" id="tools" style="padding-top:0">
<div class="container">
<h2 style="font-size:1.3rem;font-weight:700;margin-bottom:16px;display:flex;align-items:center;gap:8px"><span>&#9881;</span> Interactive Tools</h2>
<div class="card">{tools_html}</div>
</div></div>

<div class="section" id="manifest" style="padding-top:0">
<div class="container">
<h2 style="font-size:1.3rem;font-weight:700;margin-bottom:16px;display:flex;align-items:center;gap:8px"><span>&#128193;</span> Complete File Manifest</h2>
<details style="background:#1e293b;border:1px solid #334155;border-radius:14px;overflow:hidden">
<summary style="padding:16px 20px;cursor:pointer;color:#94a3b8;font-size:0.9rem;font-weight:600">Show all {total_files} deployed files</summary>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="background:#0f172a"><th style="padding:10px 12px;text-align:left;color:#94a3b8;font-size:0.7rem;text-transform:uppercase;border-bottom:1px solid #334155">File Path</th><th style="padding:10px 12px;text-align:left;color:#94a3b8;font-size:0.7rem;text-transform:uppercase;border-bottom:1px solid #334155">Type</th><th style="padding:10px 12px;text-align:right;color:#94a3b8;font-size:0.7rem;text-transform:uppercase;border-bottom:1px solid #334155">Size</th></tr></thead>
<tbody>{manifest_rows}</tbody>
</table>
</div>
</details>
</div></div>

<footer style="background:#0f172a;border-top:1px solid #1e293b;padding:32px 24px;text-align:center">
<p style="color:#475569;font-size:0.75rem">Powered by <span style="background:linear-gradient(135deg,{primary},{secondary});-webkit-background-clip:text;-webkit-text-fill-color:transparent;font-weight:700">Aura</span> &mdash; Domain to Business Generator</p>
</footer>
</body></html>"""


@app.get("/api/export/{domain}/niches-csv")
async def api_export_niches_csv(domain: str, db: Session = Depends(get_db)):
    domain_record = db.query(Domain).filter(Domain.domain == domain).first()
    if not domain_record or not domain_record.analysis:
        raise HTTPException(status_code=404, detail="Domain not analyzed")

    niches = domain_record.analysis.get("niches", [])
    output = io.StringIO()
    writer = csv.DictWriter(output, fieldnames=[
        "name", "description", "synopsis", "monetization_model",
        "target_audience", "time_to_revenue", "valuation_band", "score",
        "requires_inventory", "affiliate_programs"
    ])
    writer.writeheader()
    for n in niches:
        row = {k: n.get(k, "") for k in writer.fieldnames}
        if isinstance(row.get("affiliate_programs"), list):
            row["affiliate_programs"] = "; ".join(row["affiliate_programs"])
        writer.writerow(row)

    return StreamingResponse(
        io.BytesIO(output.getvalue().encode()),
        media_type="text/csv",
        headers={"Content-Disposition": f'attachment; filename="{domain}_niches.csv"'}
    )


@app.get("/_dev_admin/{token}/editor/{domain}", response_class=HTMLResponse)
@app.get("/editor/{domain}", response_class=HTMLResponse)
async def package_editor(request: Request, domain: str, db: Session = Depends(get_db), token: str = None):
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="No package found for this domain")

    from app.services.aura import _normalize_site_copy
    from app.services.validators import validate_site_copy, validate_brand
    brand = package.brand or {}
    site_copy = _normalize_site_copy(package.site_copy or {})
    site_copy, copy_report = validate_site_copy(site_copy, auto_repair=True)
    brand, brand_report = validate_brand(brand, auto_repair=True)
    if copy_report.repairs or brand_report.repairs:
        logger.info(f"[editor:{domain}] Repaired {len(copy_report.repairs)} site_copy + {len(brand_report.repairs)} brand issues on read")
    recommended_idx = brand.get("recommended", 0)
    options = brand.get("options", [])
    chosen_brand = options[recommended_idx] if options and recommended_idx < len(options) else {"name": domain, "tagline": ""}

    revisions = db.query(PackageRevision).filter(PackageRevision.package_id == package.id).order_by(PackageRevision.created_at.desc()).limit(50).all()
    augments = db.query(Augment).filter(Augment.package_id == package.id).order_by(Augment.created_at.desc()).all()

    asset_metadata = {}
    if package.hero_image_url:
        hero_path = package.hero_image_url.lstrip("/")
        if os.path.exists(hero_path):
            asset_metadata[package.hero_image_url] = {
                "size_bytes": os.path.getsize(hero_path),
                "modified_at": datetime.datetime.fromtimestamp(os.path.getmtime(hero_path)).isoformat(),
                "exists": True,
            }
    for fi_key, fi_data in (package.feature_images or {}).items():
        if isinstance(fi_data, dict) and fi_data.get("url"):
            fi_path = fi_data["url"].lstrip("/")
            if os.path.exists(fi_path):
                asset_metadata[fi_data["url"]] = {
                    "size_bytes": os.path.getsize(fi_path),
                    "modified_at": datetime.datetime.fromtimestamp(os.path.getmtime(fi_path)).isoformat(),
                    "exists": True,
                }

    brandkit_status = "none"
    section_assets = {}
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    if kit:
        brandkit_status = kit.status or "pending"
        kit_assets = db.query(BrandKitAsset).filter(BrandKitAsset.brand_kit_id == kit.id).all()
        section_assets = resolve_assets_for_sections(kit_assets)

    from app.services.theme import DEFAULT_ATMOSPHERE, TEXTURE_CATEGORIES, CURSOR_OPTIONS, GRADIENT_DIRECTIONS, ANIMATED_BACKGROUNDS
    current_atmosphere = {**DEFAULT_ATMOSPHERE, **(package.atmosphere or {})}

    site_designations = package.site_designations or {}
    if not site_designations.get("hero") and package.hero_image_url:
        site_designations["hero"] = package.hero_image_url

    custom_palettes = brand.get("custom_palettes", [])

    return templates.TemplateResponse("editor.html", {
        "request": request, "domain": domain, "package": package,
        "brand": chosen_brand, "brand_data": brand, "site_copy": site_copy,
        "hero_image_url": package.hero_image_url,
        "feature_images": package.feature_images or {},
        "template_type": package.template_type or "hero",
        "layout_style": package.layout_style or "single-scroll",
        "density": package.density or "balanced",
        "style_tier": getattr(package, "style_tier", None) or "premium",
        "revisions": revisions,
        "augments": augments,
        "augment_types": get_augment_types(),
        "asset_metadata": asset_metadata,
        "brandkit_status": brandkit_status,
        "section_assets": section_assets,
        "site_designations": site_designations,
        "current_page": "editor", "current_domain": domain,
        "atmosphere": current_atmosphere,
        "texture_categories": TEXTURE_CATEGORIES,
        "cursor_options": CURSOR_OPTIONS,
        "gradient_directions": GRADIENT_DIRECTIONS,
        "animation_styles": list(ANIMATED_BACKGROUNDS.keys()),
        "custom_palettes": custom_palettes,
    })


@app.post("/api/editor/{domain}/update-section")
async def api_update_section(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    section_key = data.get("section_key")
    new_value = data.get("value")
    if not section_key:
        raise HTTPException(status_code=400, detail="section_key required")

    site_copy = dict(package.site_copy or {})
    before = site_copy.get(section_key)

    if isinstance(new_value, list):
        site_copy[section_key] = new_value
    elif isinstance(new_value, dict):
        if isinstance(site_copy.get(section_key), dict):
            site_copy[section_key].update(new_value)
        else:
            site_copy[section_key] = new_value
    else:
        site_copy[section_key] = new_value

    from app.services.validators import validate_site_copy
    site_copy, val_report = validate_site_copy(site_copy, auto_repair=True)
    if val_report.repairs:
        logger.info(f"[update-section:{domain}] Auto-repaired {len(val_report.repairs)} issues after edit")

    package.site_copy = site_copy
    from sqlalchemy.orm.attributes import flag_modified
    flag_modified(package, "site_copy")

    rev = PackageRevision(
        package_id=package.id, revision_type="content", section_key=section_key,
        action="update", description=f"Updated {section_key}",
        before_data={"value": before} if before else None,
        after_data={"value": new_value},
    )
    db.add(rev)
    db.commit()

    return {"status": "ok", "section_key": section_key}


@app.post("/api/editor/{domain}/add-section")
async def api_add_section(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    section_key = data.get("section_key")
    default_value = data.get("default_value", "")
    if not section_key:
        raise HTTPException(status_code=400, detail="section_key required")

    site_copy = dict(package.site_copy or {})
    site_copy[section_key] = default_value
    package.site_copy = site_copy
    from sqlalchemy.orm.attributes import flag_modified
    flag_modified(package, "site_copy")

    rev = PackageRevision(
        package_id=package.id, revision_type="structure", section_key=section_key,
        action="add", description=f"Added section: {section_key}",
        after_data={"value": default_value},
    )
    db.add(rev)
    db.commit()

    return {"status": "ok", "section_key": section_key}


@app.post("/api/editor/{domain}/remove-section")
async def api_remove_section(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    section_key = data.get("section_key")
    if not section_key:
        raise HTTPException(status_code=400, detail="section_key required")

    site_copy = dict(package.site_copy or {})
    removed = site_copy.pop(section_key, None)
    package.site_copy = site_copy
    from sqlalchemy.orm.attributes import flag_modified
    flag_modified(package, "site_copy")

    rev = PackageRevision(
        package_id=package.id, revision_type="structure", section_key=section_key,
        action="remove", description=f"Removed section: {section_key}",
        before_data={"value": removed},
    )
    db.add(rev)
    db.commit()

    return {"status": "ok", "removed": section_key}


@app.post("/api/ai/upload")
async def api_ai_upload(files: list[UploadFile] = File(...)):
    import base64
    os.makedirs("static/uploads/ai", exist_ok=True)
    results = []
    allowed_img = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
    allowed_doc = {".txt", ".md", ".csv", ".json", ".xml", ".log", ".py", ".js", ".ts", ".pdf"}
    max_size = 20 * 1024 * 1024

    for f in files[:10]:
        content = await f.read()
        if len(content) > max_size:
            results.append({"name": f.filename, "error": "File too large (max 20MB)"})
            continue
        ext = os.path.splitext(f.filename or "")[1].lower()
        is_image = ext in allowed_img
        is_doc = ext in allowed_doc

        if not is_image and not is_doc:
            results.append({"name": f.filename, "error": f"Unsupported file type: {ext}"})
            continue

        ts = int(_time.time() * 1000)
        safe_name = re.sub(r'[^a-zA-Z0-9._-]', '_', f.filename or "upload")
        stored_name = f"{ts}_{safe_name}"
        path = f"static/uploads/ai/{stored_name}"
        with open(path, "wb") as out:
            out.write(content)

        entry = {
            "name": f.filename,
            "stored_name": stored_name,
            "url": f"/static/uploads/ai/{stored_name}",
            "size": len(content),
            "type": "image" if is_image else "document",
            "ext": ext,
        }

        if is_image and ext != ".svg":
            mime = f.content_type or f"image/{ext.lstrip('.')}"
            b64 = base64.b64encode(content).decode("utf-8")
            entry["base64"] = b64
            entry["mime"] = mime

        if is_doc:
            try:
                if ext == ".pdf":
                    try:
                        from PyPDF2 import PdfReader
                        import io
                        reader = PdfReader(io.BytesIO(content))
                        text = "\n".join(page.extract_text() or "" for page in reader.pages)
                    except ImportError:
                        text = "[PDF text extraction unavailable — install PyPDF2]"
                else:
                    text = content.decode("utf-8", errors="replace")
                if len(text) > 50000:
                    text = text[:50000] + "\n[...truncated at 50,000 chars]"
                entry["text_content"] = text
            except Exception as e:
                entry["text_content"] = f"[Error reading file: {str(e)[:200]}]"

        results.append(entry)

    return {"files": results}


@app.get("/api/ai/uploads/{filename}")
async def api_serve_ai_upload(filename: str):
    safe = re.sub(r'[^a-zA-Z0-9._-]', '', filename)
    path = f"static/uploads/ai/{safe}"
    if not os.path.exists(path):
        raise HTTPException(status_code=404, detail="File not found")
    from starlette.responses import FileResponse
    return FileResponse(
        path,
        headers={
            "Content-Disposition": f"inline; filename=\"{safe}\"",
            "X-Content-Type-Options": "nosniff",
        }
    )


@app.post("/api/editor/{domain}/ai-refine")
async def api_ai_refine(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    prompt = data.get("prompt", "")
    section_key = data.get("section_key")
    attachments = data.get("attachments", [])
    if not prompt:
        raise HTTPException(status_code=400, detail="prompt required")

    attachment_context = ""
    if attachments:
        doc_texts = []
        for att in attachments:
            if att.get("type") == "document" and att.get("text_content"):
                doc_texts.append(f"--- Attached Document: {att.get('name', 'unknown')} ---\n{att['text_content'][:20000]}")
            elif att.get("type") == "image":
                doc_texts.append(f"[Attached Image: {att.get('name', 'unknown')} — the user uploaded this as visual reference]")
        if doc_texts:
            attachment_context = "\n\nATTACHED REFERENCE FILES:\n" + "\n\n".join(doc_texts)

    site_copy = dict(package.site_copy or {})
    brand = package.brand or {}
    current_value = site_copy.get(section_key, "") if section_key else ""

    section_summary = {}
    for k, v in site_copy.items():
        if isinstance(v, list):
            section_summary[k] = f"{len(v)} items"
        elif isinstance(v, str) and v:
            section_summary[k] = f"{len(v)} chars"
        elif v:
            section_summary[k] = "present"

    brand_info = brand.get('options', [{}])
    selected_brand = brand_info[brand.get('recommended', 0)] if brand_info else {}

    system_prompt = """You are Aura, an expert business consultant and brand strategist. You are the user's trusted advisor — nurturing, knowledgeable, and proactive.

YOUR ROLE:
- You understand user INTENT, not just literal words. "Make a logo" means generate a site logo/brand mark. "Add more documents" means create additional content pages.
- Before making changes, briefly acknowledge what currently exists so the user has context.
- If a request is ambiguous or could go multiple directions, present clear options the user can pick from.
- When you DO modify content, return ONLY the refined content (no explanations mixed in).
- Be concise. Guide the user efficiently — consolidate related choices, pre-select sensible defaults, and minimize the number of decisions needed.

RESPONSE FORMAT — you must respond with valid JSON in one of these structures:

1. Direct update (you're confident):
{"action": "update", "content": <the refined content>, "message": "Brief summary of what was changed"}

2. Clarifying question WITH clickable options (PREFERRED when offering choices):
{"action": "clarify", "message": "Brief context about what you're asking", "options": [
  {"label": "A) Short descriptive label", "value": "The full instruction to execute if chosen", "recommended": true},
  {"label": "B) Another option", "value": "The full instruction for this choice"},
  {"label": "C) Yet another", "value": "The full instruction for this choice"}
]}

3. Simple clarify (only when free-form input is truly needed):
{"action": "clarify", "message": "Your question to the user"}

OPTION GUIDELINES:
- ALWAYS use "options" when you have 2-6 discrete choices to offer. This is strongly preferred over listing options in plain text.
- Each option needs "label" (short, starts with A/B/C letter) and "value" (the complete instruction that will be sent back as the user's next prompt).
- Mark ONE option as "recommended": true when there's a clear best choice for the user's niche.
- Keep options to 2-5 choices. If there are more, group them into categories first.
- The "value" should be self-contained so the AI can act on it directly without needing the original context restated.
- Consolidate where possible: instead of asking 3 separate questions, combine related choices into one set of options.

ACTION TYPES:
- "update": You are confident in what to change. Include "content" with the new value and "message" summarizing.
- "clarify": The request is ambiguous, involves non-content changes, or you need more info. Include "options" array when there are discrete choices.

RULES:
- For structured data (features, FAQ, testimonials), "content" must be valid JSON array/object.
- For text sections (headline, about, etc.), "content" must be a plain string.
- If the user asks about visual/design changes (logos, colors, images), use "clarify" to guide them to the Visual Assets or Style & Layout tabs.
- If the user says something vague like "make more documents" or "add pages", use "clarify" with options listing the specific sections/pages that can be added.
- Always be warm, knowledgeable, and specific in your messages.
- When suggesting content directions (e.g., data recovery sections, pricing tiers, feature sets), ALWAYS use the options format so the user can click to choose."""

    refine_prompt = f"""CURRENT PACKAGE STATE:
Domain: {domain}
Niche: {package.chosen_niche}
Brand name: {selected_brand.get('name', domain)}
Brand tagline: {selected_brand.get('tagline', '')}

EXISTING SECTIONS AND SIZES:
{json.dumps(section_summary, indent=2)}

{"TARGET SECTION: " + section_key if section_key else "NO SPECIFIC SECTION TARGETED (general request)"}
{"CURRENT CONTENT OF TARGET:" if section_key else ""}
{json.dumps(current_value, indent=2) if section_key and isinstance(current_value, (dict, list)) else (current_value if section_key else "")}

USER REQUEST: {prompt}
{attachment_context}

Respond with valid JSON using the format specified in your instructions."""

    has_image_attachments = any(a.get("type") == "image" and a.get("base64") for a in attachments)

    if has_image_attachments:
        messages_multimodal = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": []}
        ]
        for att in attachments:
            if att.get("type") == "image" and att.get("base64"):
                mime = att.get("mime", "image/png")
                messages_multimodal[1]["content"].append({
                    "type": "image_url",
                    "image_url": {"url": f"data:{mime};base64,{att['base64']}", "detail": "auto"}
                })
        messages_multimodal[1]["content"].append({"type": "text", "text": refine_prompt})
        try:
            from openai import OpenAI as _OAI
            _client = _OAI()
            resp = _client.chat.completions.create(model="gpt-4o", messages=messages_multimodal, max_completion_tokens=8192)
            refined_raw = resp.choices[0].message.content or ""
        except Exception as e:
            logger.error(f"Vision refine error: {e}")
            refined_raw = call_llm_text_routed("content_refine", refine_prompt, system_prompt)
    else:
        refined_raw = call_llm_text_routed("content_refine", refine_prompt, system_prompt)

    try:
        cleaned = refined_raw.strip()
        if cleaned.startswith("```"):
            cleaned = cleaned.split("\n", 1)[-1] if "\n" in cleaned else cleaned[3:]
        if cleaned.endswith("```"):
            cleaned = cleaned[:-3].strip()
        ai_response = json.loads(cleaned)
    except (json.JSONDecodeError, TypeError):
        ai_response = {"action": "update", "content": refined_raw, "message": "Content updated."}

    action = ai_response.get("action", "update")
    message = ai_response.get("message", "")
    before = None

    if action == "update" and section_key and "content" in ai_response:
        before = site_copy.get(section_key)
        new_content = ai_response["content"]
        site_copy[section_key] = new_content
        package.site_copy = site_copy
        from sqlalchemy.orm.attributes import flag_modified
        flag_modified(package, "site_copy")

    rev = PackageRevision(
        package_id=package.id, revision_type="ai_refine", section_key=section_key,
        action=action, description=f"AI {action}: {prompt[:100]}",
        before_data={"value": before} if before is not None else None,
        after_data={"value": site_copy.get(section_key)} if action == "update" and section_key else None,
        ai_prompt=prompt, ai_response=refined_raw[:2000],
    )
    db.add(rev)
    db.commit()

    response_data = {
        "status": "ok",
        "action": action,
        "message": message,
        "refined": site_copy.get(section_key, ai_response.get("content", "")) if action == "update" else None,
        "section_key": section_key
    }
    if "options" in ai_response and isinstance(ai_response["options"], list):
        response_data["options"] = ai_response["options"]
    return response_data


@app.post("/api/editor/{domain}/update-settings")
async def api_update_settings(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    changed = []
    if "template_type" in data:
        package.template_type = data["template_type"]
        changed.append("template_type")
    if "layout_style" in data:
        package.layout_style = data["layout_style"]
        changed.append("layout_style")
    if "density" in data:
        package.density = data["density"]
        changed.append("density")
    if "style_tier" in data:
        package.style_tier = data["style_tier"]
        changed.append("style_tier")
    if "color_primary" in data or "color_secondary" in data or "color_accent" in data:
        brand = dict(package.brand or {})
        for k in ("color_primary", "color_secondary", "color_accent"):
            if k in data:
                brand[k] = data[k]
        package.brand = brand
        from sqlalchemy.orm.attributes import flag_modified
        flag_modified(package, "brand")
        changed.append("colors")
    if "atmosphere" in data:
        from app.services.theme import DEFAULT_ATMOSPHERE
        current_atmo = dict(package.atmosphere or {})
        incoming = data["atmosphere"] or {}
        merged = {**DEFAULT_ATMOSPHERE, **current_atmo, **incoming}
        package.atmosphere = merged
        from sqlalchemy.orm.attributes import flag_modified
        flag_modified(package, "atmosphere")
        changed.append("atmosphere")

    rev = PackageRevision(
        package_id=package.id, revision_type="settings", action="update",
        description=f"Updated settings: {', '.join(changed)}",
        after_data=data,
    )
    db.add(rev)
    db.commit()

    return {"status": "ok", "changed": changed}


@app.post("/api/editor/{domain}/save-palette")
async def api_save_palette(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")
    name = (data.get("name") or "").strip()
    if not name:
        raise HTTPException(status_code=400, detail="Palette name required")
    primary = data.get("primary", "#000000")
    secondary = data.get("secondary", "#000000")
    accent = data.get("accent", "#000000")
    brand = dict(package.brand or {})
    palettes = list(brand.get("custom_palettes", []))
    palettes.append({"name": name, "primary": primary, "secondary": secondary, "accent": accent})
    brand["custom_palettes"] = palettes
    package.brand = brand
    from sqlalchemy.orm.attributes import flag_modified
    flag_modified(package, "brand")
    db.commit()
    return {"status": "ok", "custom_palettes": palettes}


@app.delete("/api/editor/{domain}/delete-palette/{index}")
async def api_delete_palette(domain: str, index: int, db: Session = Depends(get_db)):
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")
    brand = dict(package.brand or {})
    palettes = list(brand.get("custom_palettes", []))
    if 0 <= index < len(palettes):
        palettes.pop(index)
    brand["custom_palettes"] = palettes
    package.brand = brand
    from sqlalchemy.orm.attributes import flag_modified
    flag_modified(package, "brand")
    db.commit()
    return {"status": "ok", "custom_palettes": palettes}


@app.post("/api/editor/{domain}/generate-asset")
async def api_generate_asset(domain: str, request: Request, db: Session = Depends(get_db)):
    try:
        data = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid request body")

    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    asset_type = data.get("asset_type", "hero")
    prompt_hint = data.get("prompt", "")
    count = min(max(int(data.get("count", 1)), 1), 4)
    model = data.get("model", "dall-e-3")
    use_gemini = model.startswith("gemini")
    brand = package.brand or {}
    primary_color = brand.get("color_primary", "#4F46E5")

    brand_name = "Business"
    opts = brand.get("options", [])
    rec = brand.get("recommended", 0)
    if opts and rec < len(opts):
        brand_name = opts[rec].get("name", "Business")

    base_prompt = f"""Create a professional, modern image for a website about {package.chosen_niche}. 
Brand: "{brand_name}". Style: Clean, modern, professional. High-quality.
Color hint: {primary_color}. Do NOT include text or logos."""

    if asset_type == "hero":
        full_prompt = f"{base_prompt}\nThis is a hero banner image - wide, dramatic, atmospheric. {prompt_hint}"
        size = "1536x1024"
    elif asset_type == "section":
        full_prompt = f"{base_prompt}\nThis is a section header image - represents the concept of: {prompt_hint}"
        size = "1024x1024"
    elif asset_type == "feature":
        full_prompt = f"{base_prompt}\nThis is a feature illustration - clean icon-style or conceptual image for: {prompt_hint}"
        size = "1024x1024"
    elif asset_type == "background":
        full_prompt = f"{base_prompt}\nThis is a seamless background texture - subtle, tileable, professional. {prompt_hint}"
        size = "1024x1024"
    else:
        full_prompt = f"{base_prompt}\n{prompt_hint}"
        size = "1024x1024"

    def _generate_single(idx):
        try:
            if use_gemini:
                from app.services.graphics import _generate_image_gemini, MODELS as GFX_MODELS
                gemini_model = model if model in GFX_MODELS else "gemini-2.5-flash-image"
                image_data = _generate_image_gemini(full_prompt, gemini_model)
            else:
                image_data = generate_image_routed("graphics_prompt", full_prompt, size=size)
            if not image_data:
                return None
            filename = f"{domain.replace('.', '_')}_{asset_type}_{uuid.uuid4().hex[:6]}.png"
            img_path = os.path.join("static", "images", filename)
            with open(img_path, "wb") as f:
                f.write(image_data)
            file_size = os.path.getsize(img_path)
            img_url = f"/static/images/{filename}"
            created_at = datetime.datetime.utcnow().isoformat()
            return {
                "url": img_url,
                "type": asset_type,
                "prompt": prompt_hint,
                "created_at": created_at,
                "size_bytes": file_size,
                "filename": filename,
                "is_primary": False,
                "index": idx,
                "model": model,
            }
        except Exception as e:
            logger.error(f"Image generation {idx+1}/{count} failed ({model}): {e}")
            return None

    results = []
    for i in range(count):
        result = _generate_single(i)
        if result:
            results.append(result)

    if not results:
        model_label = "Gemini" if use_gemini else "DALL-E"
        raise HTTPException(status_code=500, detail=f"Image generation failed via {model_label}. Try again with 1 image or a different model.")

    results.sort(key=lambda r: r["index"])

    from sqlalchemy.orm.attributes import flag_modified
    feature_imgs = dict(package.feature_images or {})

    set_hero = asset_type == "hero" and not package.hero_image_url

    for i, img_info in enumerate(results):
        img_key = f"{asset_type}_{uuid.uuid4().hex[:6]}"
        is_primary = set_hero and i == 0
        img_info["is_primary"] = is_primary
        feature_imgs[img_key] = {
            "url": img_info["url"],
            "type": img_info["type"],
            "prompt": img_info["prompt"],
            "created_at": img_info["created_at"],
            "size_bytes": img_info["size_bytes"],
            "filename": img_info["filename"],
            "is_primary": is_primary,
        }
        if is_primary:
            package.hero_image_url = img_info["url"]

    package.feature_images = feature_imgs
    flag_modified(package, "feature_images")

    rev = PackageRevision(
        package_id=package.id, revision_type="asset", section_key=asset_type,
        action="generate", description=f"AI-generated {len(results)} {asset_type} image(s)",
        after_data={"images": [{"url": r["url"], "filename": r["filename"]} for r in results]},
    )
    db.add(rev)
    db.commit()

    clean_results = [{k: v for k, v in r.items() if k != "index"} for r in results]
    return {"status": "ok", "images": clean_results}


DESIGNATION_SLOTS = {
    "hero": {"label": "Hero Banner", "icon": "photo"},
    "logo": {"label": "Site Logo", "icon": "globe"},
    "favicon": {"label": "Favicon", "icon": "star"},
    "og_image": {"label": "Social Share (OG)", "icon": "share"},
    "about_image": {"label": "About Section", "icon": "user"},
    "background": {"label": "Background Texture", "icon": "layers"},
}


@app.post("/api/editor/{domain}/set-primary-image")
async def api_set_primary_image(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    image_url = data.get("image_url")
    slot = data.get("slot", "hero")
    if not image_url:
        raise HTTPException(status_code=400, detail="image_url required")

    if slot not in DESIGNATION_SLOTS:
        raise HTTPException(status_code=400, detail=f"Invalid slot. Valid: {', '.join(DESIGNATION_SLOTS.keys())}")

    ALLOWED_PREFIXES = ("/static/images/", "/static/graphics/", "/static/uploads/")
    if not any(image_url.startswith(p) for p in ALLOWED_PREFIXES):
        raise HTTPException(status_code=400, detail="Invalid image path")

    img_path = image_url.lstrip("/")
    real_path = os.path.realpath(img_path)
    allowed_dirs = [os.path.realpath(p.strip("/")) for p in ALLOWED_PREFIXES]
    if not any(real_path.startswith(d) for d in allowed_dirs) or not os.path.exists(real_path):
        raise HTTPException(status_code=400, detail="Image file not found")

    from sqlalchemy.orm.attributes import flag_modified
    designations = dict(package.site_designations or {})
    old_value = designations.get(slot)
    designations[slot] = image_url
    package.site_designations = designations
    flag_modified(package, "site_designations")

    if slot == "hero":
        old_hero = package.hero_image_url
        package.hero_image_url = image_url
        feature_imgs = dict(package.feature_images or {})
        for key, img in feature_imgs.items():
            if isinstance(img, dict):
                img["is_primary"] = (img.get("url") == image_url)
        package.feature_images = feature_imgs
        flag_modified(package, "feature_images")

    rev = PackageRevision(
        package_id=package.id, revision_type="asset", section_key=slot,
        action="designate", description=f"Designated image for {DESIGNATION_SLOTS[slot]['label']}: {image_url}",
        before_data={"slot": slot, "old_value": old_value},
        after_data={"slot": slot, "image_url": image_url},
    )
    db.add(rev)
    db.commit()

    return {"status": "ok", "designations": designations}


@app.post("/api/editor/{domain}/remove-designation")
async def api_remove_designation(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    slot = data.get("slot")
    if not slot or slot not in DESIGNATION_SLOTS:
        raise HTTPException(status_code=400, detail=f"Invalid slot. Valid: {', '.join(DESIGNATION_SLOTS.keys())}")

    from sqlalchemy.orm.attributes import flag_modified
    designations = dict(package.site_designations or {})
    old_value = designations.pop(slot, None)
    package.site_designations = designations
    flag_modified(package, "site_designations")

    if slot == "hero":
        package.hero_image_url = None

    rev = PackageRevision(
        package_id=package.id, revision_type="asset", section_key=slot,
        action="undesignate", description=f"Removed designation for {DESIGNATION_SLOTS[slot]['label']}",
        before_data={"slot": slot, "old_value": old_value},
        after_data={"slot": slot, "image_url": None},
    )
    db.add(rev)
    db.commit()

    return {"status": "ok", "designations": designations}


@app.get("/api/editor/{domain}/designations")
async def api_get_designations(domain: str, db: Session = Depends(get_db)):
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")
    designations = dict(package.site_designations or {})
    if not designations.get("hero") and package.hero_image_url:
        designations["hero"] = package.hero_image_url
    return {"designations": designations, "slots": DESIGNATION_SLOTS}


@app.post("/api/editor/{domain}/delete-asset")
async def api_delete_asset(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    image_url = data.get("image_url")
    if not image_url:
        raise HTTPException(status_code=400, detail="image_url required")

    ALLOWED_PREFIXES = ("/static/images/", "/static/graphics/", "/static/uploads/")
    if not any(image_url.startswith(p) for p in ALLOWED_PREFIXES):
        raise HTTPException(status_code=400, detail="Invalid image path")

    from sqlalchemy.orm.attributes import flag_modified
    feature_imgs = dict(package.feature_images or {})
    deleted_key = None
    deleted_info = None
    for key, img in list(feature_imgs.items()):
        if isinstance(img, dict) and img.get("url") == image_url:
            deleted_key = key
            deleted_info = img
            del feature_imgs[key]
            break

    was_hero = package.hero_image_url == image_url
    if was_hero:
        package.hero_image_url = None

    if deleted_key is None and not was_hero:
        raise HTTPException(status_code=404, detail="Asset not found in package")

    img_path = image_url.lstrip("/")
    safe_dir = os.path.realpath("static/images")
    real_path = os.path.realpath(img_path)
    file_removed = False
    if real_path.startswith(safe_dir) and os.path.exists(real_path):
        try:
            os.remove(real_path)
            file_removed = True
        except Exception as e:
            logger.warning(f"Could not delete file {real_path}: {e}")

    package.feature_images = feature_imgs
    flag_modified(package, "feature_images")

    rev = PackageRevision(
        package_id=package.id, revision_type="asset", section_key="delete",
        action="delete", description=f"Deleted asset: {image_url}",
        before_data={"image_url": image_url, "was_hero": was_hero, "asset_info": deleted_info},
        after_data={"file_removed": file_removed},
    )
    db.add(rev)
    db.commit()

    return {"status": "ok", "was_hero": was_hero, "file_removed": file_removed}


@app.post("/api/editor/{domain}/set-focal-point")
async def api_set_focal_point(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    image_url = data.get("image_url")
    focal_x = data.get("focal_x", 0.5)
    focal_y = data.get("focal_y", 0.5)

    if not image_url:
        raise HTTPException(status_code=400, detail="image_url required")

    focal_x = max(0.0, min(1.0, float(focal_x)))
    focal_y = max(0.0, min(1.0, float(focal_y)))

    from sqlalchemy.orm.attributes import flag_modified
    feature_imgs = dict(package.feature_images or {})
    updated = False
    for key, img in feature_imgs.items():
        if isinstance(img, dict) and img.get("url") == image_url:
            img["focal_point"] = {"x": focal_x, "y": focal_y}
            updated = True
            break

    if not updated:
        raise HTTPException(status_code=404, detail="Asset not found in package")

    package.feature_images = feature_imgs
    flag_modified(package, "feature_images")
    db.commit()

    return {"status": "ok", "focal_point": {"x": focal_x, "y": focal_y}}


@app.post("/api/editor/{domain}/upload-asset")
async def api_upload_asset(domain: str, asset_type: str = Form("custom"), file: UploadFile = File(...), db: Session = Depends(get_db)):
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    contents = await file.read()
    ext = os.path.splitext(file.filename)[1] or ".png"
    filename = f"{domain.replace('.', '_')}_{asset_type}_{uuid.uuid4().hex[:6]}{ext}"
    img_path = os.path.join("static", "images", filename)
    with open(img_path, "wb") as f:
        f.write(contents)
    img_url = f"/static/images/{filename}"

    if asset_type == "hero":
        package.hero_image_url = img_url
    else:
        feature_imgs = dict(package.feature_images or {})
        feature_imgs[asset_type + "_" + uuid.uuid4().hex[:4]] = {"url": img_url, "type": asset_type, "uploaded": True}
        package.feature_images = feature_imgs
        from sqlalchemy.orm.attributes import flag_modified
        flag_modified(package, "feature_images")

    rev = PackageRevision(
        package_id=package.id, revision_type="asset", section_key=asset_type,
        action="upload", description=f"Uploaded {asset_type} image: {file.filename}",
        after_data={"url": img_url, "filename": file.filename},
    )
    db.add(rev)
    db.commit()

    return {"status": "ok", "url": img_url, "asset_type": asset_type}


@app.get("/api/editor/{domain}/history")
async def api_editor_history(domain: str, db: Session = Depends(get_db)):
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    revisions = db.query(PackageRevision).filter(PackageRevision.package_id == package.id).order_by(PackageRevision.created_at.desc()).limit(100).all()

    return [{
        "id": r.id, "type": r.revision_type, "section": r.section_key,
        "action": r.action, "description": r.description,
        "ai_prompt": r.ai_prompt[:200] if r.ai_prompt else None,
        "ai_response": r.ai_response[:200] if r.ai_response else None,
        "has_full_data": bool(r.ai_prompt and len(r.ai_prompt) > 200) or bool(r.ai_response and len(r.ai_response) > 200),
        "created_at": r.created_at.isoformat() if r.created_at else None,
    } for r in revisions]


@app.get("/api/editor/{domain}/revision/{rev_id}")
async def api_revision_detail(domain: str, rev_id: int, db: Session = Depends(get_db)):
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")
    rev = db.query(PackageRevision).filter(
        PackageRevision.id == rev_id,
        PackageRevision.package_id == package.id
    ).first()
    if not rev:
        raise HTTPException(status_code=404, detail="Revision not found")
    return {
        "id": rev.id, "type": rev.revision_type, "section": rev.section_key,
        "action": rev.action, "description": rev.description,
        "ai_prompt": rev.ai_prompt, "ai_response": rev.ai_response,
        "before_data": rev.before_data, "after_data": rev.after_data,
        "created_at": rev.created_at.isoformat() if rev.created_at else None,
    }


@app.get("/api/augment-types")
async def api_augment_types():
    return get_augment_types()


@app.get("/api/augments/{domain}")
async def api_list_augments(domain: str, db: Session = Depends(get_db)):
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    augments = db.query(Augment).filter(Augment.package_id == package.id).order_by(Augment.created_at.desc()).all()
    return [{
        "id": a.id, "type": a.augment_type, "title": a.title,
        "description": a.description, "config": a.config,
        "created_at": a.created_at.isoformat() if a.created_at else None,
    } for a in augments]


@app.post("/api/augments/{domain}/suggest")
async def api_suggest_augments(domain: str, db: Session = Depends(get_db)):
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    job_id = str(uuid.uuid4())[:8]
    create_job(job_id, "augment_suggest", domain, len(AUGMENT_SUGGEST_STEPS) - 1, AUGMENT_SUGGEST_STEPS)
    job_executor.submit(run_augment_suggest_job, job_id, domain)

    return {"job_id": job_id, "domain": domain, "status": "started"}


@app.post("/api/augments/{domain}/generate")
async def api_generate_augment(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="Package not found")

    augment_type = data.get("type", "estimator")
    title = data.get("title", "")
    custom_instructions = data.get("instructions", "")
    suggestion_meta = {
        "target_audience": data.get("target_audience", ""),
        "benefit_level": data.get("benefit_level", 3),
        "benefit_reason": data.get("benefit_reason", ""),
        "category": data.get("category", "engagement"),
    }

    job_id = str(uuid.uuid4())[:8]
    create_job(job_id, "augment_generate", domain, len(AUGMENT_GENERATE_STEPS) - 1, AUGMENT_GENERATE_STEPS)
    job_executor.submit(run_augment_generate_job, job_id, domain, augment_type, title, custom_instructions, suggestion_meta)

    return {"job_id": job_id, "domain": domain, "type": augment_type, "status": "started"}


@app.post("/api/augments/auto-fill")
async def api_augments_auto_fill(request: Request, db: Session = Depends(get_db)):
    """One-click: find all packages below augment threshold and fill them up via AI suggest+generate."""
    username = request.session.get("username")
    if not username:
        raise HTTPException(status_code=401)

    data = {}
    try:
        data = await request.json()
    except Exception:
        pass

    min_augments = int(data.get("min_augments", 2))
    max_augments = int(data.get("max_augments", 4))
    run_limit = min(int(data.get("limit", 30)), 50)

    packages = db.query(Package).filter(Package.site_copy.isnot(None)).order_by(Package.created_at.desc()).all()

    to_fill = []
    already_ok = 0
    for pkg in packages:
        count = db.query(Augment).filter(Augment.package_id == pkg.id).count()
        if count < min_augments and count < max_augments:
            to_fill.append(pkg.domain_name)
            if len(to_fill) >= run_limit:
                break
        else:
            already_ok += 1

    if not to_fill:
        return {"message": "All packages already meet the augment threshold", "domains": [], "job_id": None, "total": 0, "already_ok": already_ok}

    master_job_id = str(uuid.uuid4())[:8]
    steps = [{"key": f"d{i}", "label": d} for i, d in enumerate(to_fill)] + [{"key": "complete", "label": "Complete"}]
    create_job(master_job_id, "auto_augment_fill", "all-domains", len(to_fill), steps)

    domains_snapshot = list(to_fill)

    def _run_auto_fill():
        results = []
        for idx, domain in enumerate(domains_snapshot):
            update_job(master_job_id, status="running",
                       current_step=f"Processing {domain} ({idx + 1}/{len(domains_snapshot)})",
                       steps_completed=idx, current_step_key=f"d{idx}",
                       progress_pct=int(80 * idx / len(domains_snapshot)))
            res = run_auto_augment_for_domain(domain, min_augments, max_augments)
            results.append(res)

        queued = sum(1 for r in results if r.get("status") == "queued")
        skipped = sum(1 for r in results if r.get("status") == "skipped")
        errors = sum(1 for r in results if r.get("status") == "error")
        total_jobs = sum(len(r.get("jobs", [])) for r in results)

        update_job(master_job_id, status="completed",
                   current_step=f"Done — {queued} domains queued, {total_jobs} augment jobs started, {skipped} skipped",
                   steps_completed=len(domains_snapshot),
                   current_step_key="complete",
                   progress_pct=100,
                   result={"domains_processed": len(domains_snapshot), "queued": queued, "skipped": skipped, "errors": errors, "total_augment_jobs": total_jobs, "details": results})

    job_executor.submit(_run_auto_fill)

    return JSONResponse({
        "job_id": master_job_id,
        "domains": domains_snapshot,
        "total": len(domains_snapshot),
        "already_ok": already_ok,
        "message": f"Auto-fill started for {len(domains_snapshot)} domains"
    })


@app.get("/augment/{augment_id}", response_class=HTMLResponse)
async def augment_preview(augment_id: int, db: Session = Depends(get_db)):
    augment = db.query(Augment).filter(Augment.id == augment_id).first()
    if not augment:
        raise HTTPException(status_code=404, detail="Augment not found")
    return HTMLResponse(content=augment.html_content or "<p>No content</p>")


@app.get("/site/{domain}/tools/{augment_id}", response_class=HTMLResponse)
async def augment_page_view(request: Request, domain: str, augment_id: int, db: Session = Depends(get_db)):
    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        raise HTTPException(status_code=404, detail="No package found for this domain")

    augment = db.query(Augment).filter(Augment.id == augment_id, Augment.domain_name == domain).first()
    if not augment:
        raise HTTPException(status_code=404, detail="Augment not found")

    all_augments = db.query(Augment).filter(Augment.domain_name == domain).order_by(Augment.created_at).all()

    brand = package.brand or {}
    recommended_idx = brand.get("recommended", 0)
    options = brand.get("options", [])
    chosen_brand = options[recommended_idx] if options and recommended_idx < len(options) else {"name": domain, "tagline": ""}

    from app.services.theme import generate_theme_config, generate_theme_css
    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    brand_tone = ""
    if kit and kit.summary:
        brand_tone = (kit.summary or {}).get("tone", "")
    theme = generate_theme_config(
        primary=brand.get("color_primary", "#4F46E5"),
        secondary=brand.get("color_secondary", "#7C3AED"),
        accent=brand.get("color_accent", "#06B6D4"),
        niche=package.chosen_niche or "",
        brand_tone=brand_tone,
        brand_data=brand,
        atmosphere=package.atmosphere,
    )
    theme_css = generate_theme_css(theme)

    return templates.TemplateResponse("augment_page.html", {
        "request": request, "domain": domain,
        "brand": chosen_brand, "brand_data": brand,
        "augment": augment, "other_augments": all_augments,
        "theme": theme, "theme_css": theme_css,
    })


@app.delete("/api/augments/{augment_id}")
async def api_delete_augment(augment_id: int, db: Session = Depends(get_db)):
    augment = db.query(Augment).filter(Augment.id == augment_id).first()
    if not augment:
        raise HTTPException(status_code=404, detail="Augment not found")

    rev = PackageRevision(
        package_id=augment.package_id, revision_type="augment",
        section_key=augment.augment_type,
        action="delete", description=f"Deleted augment: {augment.title}",
        before_data={"type": augment.augment_type, "title": augment.title},
    )
    db.add(rev)
    db.delete(augment)
    db.commit()

    return {"status": "ok"}


@app.put("/api/augments/{augment_id}")
async def api_update_augment(augment_id: int, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    augment = db.query(Augment).filter(Augment.id == augment_id).first()
    if not augment:
        raise HTTPException(status_code=404, detail="Augment not found")

    if "title" in data:
        augment.title = data["title"]
    if "description" in data:
        augment.description = data["description"]
    if "config" in data:
        augment.config = data["config"]
    if "html_content" in data:
        augment.html_content = data["html_content"]

    rev = PackageRevision(
        package_id=augment.package_id, revision_type="augment",
        section_key=augment.augment_type,
        action="update", description=f"Updated augment: {augment.title}",
    )
    db.add(rev)
    db.commit()

    return {"status": "ok"}


@app.get("/sop", response_class=HTMLResponse)
async def sop_page(request: Request):
    return templates.TemplateResponse("sop.html", {"request": request, "current_page": "sop", "current_domain": ""})


@app.get("/architecture", response_class=HTMLResponse)
async def architecture_page(request: Request):
    return templates.TemplateResponse("architecture.html", {"request": request, "current_page": "architecture", "current_domain": ""})


@app.get("/api/page-metadata")
async def page_metadata(db: Session = Depends(get_db)):
    from sqlalchemy import func as sa_func
    now = datetime.datetime.utcnow()

    def fmt(dt):
        if not dt:
            return None
        return dt.isoformat() + "Z"

    def age_label(dt):
        if not dt:
            return "unknown"
        delta = now - dt
        if delta.total_seconds() < 60:
            return "just now"
        if delta.total_seconds() < 3600:
            return f"{int(delta.total_seconds() // 60)}m ago"
        if delta.total_seconds() < 86400:
            return f"{int(delta.total_seconds() // 3600)}h ago"
        return f"{delta.days}d ago"

    def file_mtime(path):
        try:
            return datetime.datetime.fromtimestamp(os.path.getmtime(path))
        except Exception:
            return None

    pages = {}

    roadmap_latest = db.query(sa_func.max(RoadmapItem.updated_at)).scalar()
    roadmap_created = db.query(sa_func.max(RoadmapItem.created_at)).scalar()
    roadmap_ts = roadmap_latest or roadmap_created
    roadmap_count = db.query(RoadmapItem).count()
    pages["roadmap"] = {
        "page": "/roadmap", "label": "Roadmap",
        "last_updated": fmt(roadmap_ts), "age": age_label(roadmap_ts),
        "record_count": roadmap_count, "source": "roadmap_items table",
    }

    tasks_latest = db.query(sa_func.max(BuildTask.updated_at)).scalar()
    tasks_created = db.query(sa_func.max(BuildTask.created_at)).scalar()
    tasks_ts = tasks_latest or tasks_created
    tasks_count = db.query(BuildTask).count()
    pages["tasks"] = {
        "page": "/tasks", "label": "Build Tasks",
        "last_updated": fmt(tasks_ts), "age": age_label(tasks_ts),
        "record_count": tasks_count, "source": "build_tasks table",
    }

    sop_mtime = file_mtime("app/templates/sop.html")
    pages["sop"] = {
        "page": "/sop", "label": "Technical SOP",
        "last_updated": fmt(sop_mtime), "age": age_label(sop_mtime),
        "source": "app/templates/sop.html file mtime",
    }

    prompt_files = ["app/services/aura.py", "app/services/blueprint.py"]
    prompt_mtimes = [file_mtime(f) for f in prompt_files]
    prompt_latest = max((m for m in prompt_mtimes if m), default=None)
    pages["prompts"] = {
        "page": "/prompts", "label": "Prompt Laboratory",
        "last_updated": fmt(prompt_latest), "age": age_label(prompt_latest),
        "source": "aura.py + blueprint.py file mtimes",
        "source_files": {f: fmt(file_mtime(f)) for f in prompt_files},
    }

    arch_mtime = file_mtime("app/templates/architecture.html")
    pkg_count = db.query(Package).count()
    job_latest = db.query(sa_func.max(Job.updated_at)).scalar()
    arch_ts = max(filter(None, [arch_mtime, job_latest]), default=None)
    pages["architecture"] = {
        "page": "/architecture", "label": "Architecture Map",
        "last_updated": fmt(arch_ts), "age": age_label(arch_ts),
        "source": "architecture.html mtime + job activity",
        "packages_count": pkg_count,
    }

    pkg_latest = db.query(sa_func.max(Package.updated_at)).scalar()
    pkg_created = db.query(sa_func.max(Package.created_at)).scalar()
    pkg_ts = pkg_latest or pkg_created
    domain_count = db.query(Domain).count()
    brandkit_latest = db.query(sa_func.max(BrandKit.updated_at)).scalar()
    profile_latest = db.query(sa_func.max(SiteProfile.updated_at)).scalar()
    dash_ts = max(filter(None, [pkg_ts, brandkit_latest, profile_latest, job_latest]), default=None)
    pages["dashboard"] = {
        "page": "/", "label": "Dashboard",
        "last_updated": fmt(dash_ts), "age": age_label(dash_ts),
        "domain_count": domain_count, "package_count": pkg_count,
        "source": "packages + brandkits + profiles + jobs",
    }

    profiles = db.query(SiteProfile).all()
    profile_ts = max((p.updated_at for p in profiles if p.updated_at), default=None)
    pages["profiles"] = {
        "page": "/api/profiles", "label": "Site Profiles",
        "last_updated": fmt(profile_ts), "age": age_label(profile_ts),
        "record_count": len(profiles), "source": "site_profiles table",
    }

    ctx_latest = db.query(sa_func.max(GlobalContext.updated_at)).scalar()
    proj_latest = db.query(sa_func.max(ProjectContext.updated_at)).scalar()
    ctx_ts = max(filter(None, [ctx_latest, proj_latest]), default=None)
    pages["context"] = {
        "page": "/api/global-context", "label": "Context Engine",
        "last_updated": fmt(ctx_ts), "age": age_label(ctx_ts),
        "source": "global_context + project_contexts tables",
    }

    scan_time = now.isoformat() + "Z"
    stale_threshold = now - datetime.timedelta(hours=24)
    stale_pages = []
    for key, meta in pages.items():
        if meta.get("last_updated"):
            ts = datetime.datetime.fromisoformat(meta["last_updated"].rstrip("Z"))
            if ts < stale_threshold:
                stale_pages.append(key)

    return {
        "scanned_at": scan_time,
        "pages": pages,
        "summary": {
            "total_pages": len(pages),
            "stale_pages": stale_pages,
            "stale_count": len(stale_pages),
            "freshness_threshold": "24 hours",
        },
    }


@app.get("/api/page-metadata/{domain}")
async def page_metadata_domain(domain: str, db: Session = Depends(get_db)):
    from sqlalchemy import func as sa_func
    now = datetime.datetime.utcnow()

    def fmt(dt):
        if not dt:
            return None
        return dt.isoformat() + "Z"

    def age_label(dt):
        if not dt:
            return "unknown"
        delta = now - dt
        if delta.total_seconds() < 60:
            return "just now"
        if delta.total_seconds() < 3600:
            return f"{int(delta.total_seconds() // 60)}m ago"
        if delta.total_seconds() < 86400:
            return f"{int(delta.total_seconds() // 3600)}h ago"
        return f"{delta.days}d ago"

    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        return {"domain": domain, "found": False}

    last_revision = db.query(sa_func.max(PackageRevision.created_at)).filter(PackageRevision.package_id == package.id).scalar()
    last_augment = db.query(sa_func.max(Augment.updated_at)).filter(Augment.package_id == package.id).scalar()
    revision_count = db.query(PackageRevision).filter(PackageRevision.package_id == package.id).count()
    augment_count = db.query(Augment).filter(Augment.package_id == package.id).count()

    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    brandkit_ts = kit.updated_at if kit else None
    brandkit_asset_count = db.query(BrandKitAsset).filter(BrandKitAsset.brand_kit_id == kit.id).count() if kit else 0

    ctx = db.query(ProjectContext).filter(ProjectContext.domain == domain).first()
    context_ts = ctx.updated_at if ctx else None

    content_latest = max(filter(None, [
        package.updated_at, package.created_at, last_revision,
        last_augment, brandkit_ts, context_ts
    ]), default=None)

    return {
        "domain": domain, "found": True,
        "package": {
            "id": package.id, "niche": package.chosen_niche,
            "created_at": fmt(package.created_at),
            "updated_at": fmt(package.updated_at),
            "age_created": age_label(package.created_at),
            "age_updated": age_label(package.updated_at),
        },
        "revisions": {"count": revision_count, "latest": fmt(last_revision), "age": age_label(last_revision)},
        "augments": {"count": augment_count, "latest": fmt(last_augment), "age": age_label(last_augment)},
        "brandkit": {
            "status": kit.status if kit else "none",
            "asset_count": brandkit_asset_count,
            "updated_at": fmt(brandkit_ts), "age": age_label(brandkit_ts),
        },
        "context": {"updated_at": fmt(context_ts), "age": age_label(context_ts)},
        "content_latest": fmt(content_latest),
        "content_age": age_label(content_latest),
    }


@app.get("/api/quality-gates")
async def quality_gates(db: Session = Depends(get_db)):
    gates = []

    gate_db_persistence = {"name": "State Persistence", "description": "All job state is stored in the database, not in-memory",
                           "passed": True, "details": []}
    try:
        jobs = db.query(Job).limit(5).all()
        gate_db_persistence["details"].append(f"{db.query(Job).count()} jobs tracked in DB")
        if any(j.status == "running" for j in jobs):
            gate_db_persistence["details"].append("Active jobs found in DB (refresh-proof)")
    except Exception as e:
        gate_db_persistence["passed"] = False
        gate_db_persistence["details"].append(f"DB query failed: {str(e)}")
    gates.append(gate_db_persistence)

    gate_executor = {"name": "Bounded Execution", "description": "Background jobs use bounded ThreadPoolExecutor (max 4 workers)",
                     "passed": True, "details": []}
    try:
        max_w = job_executor._max_workers
        gate_executor["details"].append(f"ThreadPoolExecutor max_workers={max_w}")
        if max_w > 8:
            gate_executor["passed"] = False
            gate_executor["details"].append("Too many workers - risk of resource exhaustion")
    except:
        gate_executor["details"].append("Executor configured")
    gates.append(gate_executor)

    gate_isolation = {"name": "Concurrent Isolation", "description": "Each job has independent tracking, no shared mutable state",
                      "passed": True, "details": []}
    running_jobs = db.query(Job).filter(Job.status == "running").all()
    if len(running_jobs) > 1:
        domains = [j.domain for j in running_jobs]
        if len(set(domains)) == len(domains):
            gate_isolation["details"].append(f"{len(running_jobs)} concurrent jobs, all different domains")
        else:
            gate_isolation["details"].append(f"{len(running_jobs)} concurrent jobs (some same-domain)")
    else:
        gate_isolation["details"].append("No concurrent jobs running (isolation verified by design)")
    gate_isolation["details"].append("DB-per-job tracking: each job has own row with independent progress")
    gates.append(gate_isolation)

    gate_refresh = {"name": "Refresh Recovery", "description": "Page refresh reconnects to active job streams via DB",
                    "passed": True, "details": []}
    gate_refresh["details"].append("SSE streams read from DB (not in-memory)")
    gate_refresh["details"].append("Job Queue panel loads from /api/jobs on page load")
    gate_refresh["details"].append("Active jobs auto-reconnect SSE on page load")
    gates.append(gate_refresh)

    gate_feedback = {"name": "Actionable Feedback", "description": "Health checks provide per-package pass/fail with clickable links",
                     "passed": True, "details": []}
    pkg_count = db.query(Package).count()
    gate_feedback["details"].append(f"{pkg_count} packages available for health validation")
    gate_feedback["details"].append("5 checks per package: brand, sections, hero, sales letter, render")
    gate_feedback["details"].append("Links to View Site, Edit Package from health modal")
    gates.append(gate_feedback)

    gate_cleanup = {"name": "Job Retention", "description": "Jobs older than 30 days are automatically cleaned up",
                    "passed": True, "details": []}
    cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=30)
    old_jobs = db.query(Job).filter(Job.created_at < cutoff).count()
    gate_cleanup["details"].append(f"{old_jobs} jobs older than 30 days" + (" (will be cleaned)" if old_jobs > 0 else ""))
    gates.append(gate_cleanup)

    total = len(gates)
    passed = sum(1 for g in gates if g["passed"])

    return {
        "total": total,
        "passed": passed,
        "failed": total - passed,
        "all_passed": passed == total,
        "gates": gates
    }


@app.get("/prompts", response_class=HTMLResponse)
async def prompts_page(request: Request):
    from app.services.aura import SYSTEM_PROMPT_ANALYZER, SYSTEM_PROMPT_BUILDER, SYSTEM_PROMPT_BUILDER_LEGENDARY
    from app.services.blueprint import DEFAULT_SECTIONS, CONTENT_DEPTH_PRESETS, SECTION_CATEGORIES, LEGENDARY_SECTION_OVERRIDES
    return templates.TemplateResponse("prompts.html", {
        "request": request,
        "system_prompt_analyzer": SYSTEM_PROMPT_ANALYZER,
        "system_prompt_builder": SYSTEM_PROMPT_BUILDER,
        "system_prompt_builder_legendary": SYSTEM_PROMPT_BUILDER_LEGENDARY,
        "sections": DEFAULT_SECTIONS,
        "depth_presets": CONTENT_DEPTH_PRESETS,
        "categories": SECTION_CATEGORIES,
        "legendary_overrides": LEGENDARY_SECTION_OVERRIDES,
        "current_page": "prompts", "current_domain": "",
    })


@app.get("/strategy", response_class=HTMLResponse)
async def strategy_page(request: Request):
    return templates.TemplateResponse("strategy.html", {
        "request": request,
        "current_page": "strategy",
        "current_domain": "",
        "current_user": request.session.get("username"),
        "is_admin": request.session.get("is_admin", False),
    })

@app.get("/_dev_admin/N5G4K8fWLY9MrapEkZnw_g/strategy", response_class=HTMLResponse)
async def strategy_page_dev(request: Request):
    return templates.TemplateResponse("strategy.html", {
        "request": request,
        "current_page": "strategy",
        "current_domain": "",
        "current_user": "_dev_admin",
        "is_admin": True,
    })

@app.get("/roadmap", response_class=HTMLResponse)
async def roadmap_page(request: Request, db: Session = Depends(get_db)):
    items = db.query(RoadmapItem).order_by(RoadmapItem.created_at.desc()).all()
    return templates.TemplateResponse("roadmap.html", {"request": request, "items": items, "current_page": "roadmap", "current_domain": ""})


@app.post("/api/roadmap")
async def add_roadmap_item(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    item = RoadmapItem(
        title=data.get("title", ""),
        description=data.get("description", ""),
        category=data.get("category", "other"),
        status="planned"
    )
    db.add(item)
    db.commit()
    return {"status": "ok", "id": item.id}


@app.patch("/api/roadmap/{item_id}")
async def update_roadmap_item(item_id: int, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    item = db.query(RoadmapItem).filter(RoadmapItem.id == item_id).first()
    if not item:
        raise HTTPException(status_code=404, detail="Not found")
    if "status" in data:
        item.status = data["status"]
        if data["status"] == "done" and not item.completed_at:
            item.completed_at = datetime.datetime.utcnow()
        elif data["status"] != "done":
            item.completed_at = None
    if "title" in data:
        item.title = data["title"]
    if "description" in data:
        item.description = data["description"]
    db.commit()
    return {"status": "ok"}


@app.delete("/api/roadmap/{item_id}")
async def delete_roadmap_item(item_id: int, db: Session = Depends(get_db)):
    item = db.query(RoadmapItem).filter(RoadmapItem.id == item_id).first()
    if not item:
        raise HTTPException(status_code=404, detail="Not found")
    db.delete(item)
    db.commit()
    return {"status": "ok"}


@app.get("/api/context/{domain}")
async def api_get_context(domain: str, db: Session = Depends(get_db)):
    from app.services.context_engine import get_full_context_for_domain
    ctx = db.query(ProjectContext).filter(ProjectContext.domain == domain).first()
    assembled = get_full_context_for_domain(domain, db)
    return {
        "domain": domain,
        "context_state": ctx.context_state if ctx else {},
        "event_log": ctx.event_log if ctx else [],
        "assembled_context": assembled,
        "assembled_chars": len(assembled),
    }


@app.get("/api/global-context")
async def api_get_global_context(db: Session = Depends(get_db)):
    gc = db.query(GlobalContext).first()
    if not gc:
        return {"master_rules": "", "style_prefs": {}, "guardrails": {}}
    return {
        "id": gc.id,
        "master_rules": gc.master_rules or "",
        "style_prefs": gc.style_prefs or {},
        "guardrails": gc.guardrails or {},
        "updated_at": gc.updated_at.isoformat() + "Z" if gc.updated_at else None,
    }


@app.put("/api/global-context")
async def api_update_global_context(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    gc = db.query(GlobalContext).first()
    if not gc:
        gc = GlobalContext()
        db.add(gc)
    if "master_rules" in data:
        gc.master_rules = data["master_rules"]
    if "style_prefs" in data:
        gc.style_prefs = data["style_prefs"]
    if "guardrails" in data:
        gc.guardrails = data["guardrails"]
    db.commit()
    return {"status": "ok"}


@app.get("/api/profiles")
async def api_list_profiles(db: Session = Depends(get_db)):
    profiles = db.query(SiteProfile).order_by(SiteProfile.is_default.desc(), SiteProfile.name).all()
    return {"profiles": [{
        "id": p.id, "name": p.name, "slug": p.slug, "description": p.description,
        "is_default": bool(p.is_default),
        "section_count": len(p.config.get("sections", [])) if p.config else 0,
        "depth_count": len(p.config.get("depth_presets", {})) if p.config else 0,
        "created_at": p.created_at.isoformat() + "Z" if p.created_at else None,
    } for p in profiles]}


@app.get("/api/profiles/{slug}")
async def api_get_profile(slug: str, db: Session = Depends(get_db)):
    profile = db.query(SiteProfile).filter(SiteProfile.slug == slug).first()
    if not profile:
        raise HTTPException(status_code=404, detail="Profile not found")
    return {
        "id": profile.id, "name": profile.name, "slug": profile.slug,
        "description": profile.description, "is_default": bool(profile.is_default),
        "config": profile.config,
        "created_at": profile.created_at.isoformat() + "Z" if profile.created_at else None,
        "updated_at": profile.updated_at.isoformat() + "Z" if profile.updated_at else None,
    }


@app.post("/api/profiles")
async def api_create_profile(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    name = data.get("name", "").strip()
    if not name:
        raise HTTPException(status_code=400, detail="Profile name is required")
    slug = slugify(name)
    existing = db.query(SiteProfile).filter(SiteProfile.slug == slug).first()
    if existing:
        raise HTTPException(status_code=400, detail=f"Profile with slug '{slug}' already exists")
    config = data.get("config", build_default_profile_config())
    errors = validate_profile_config(config)
    if errors:
        raise HTTPException(status_code=400, detail={"validation_errors": errors})
    profile = SiteProfile(
        name=name, slug=slug,
        description=data.get("description", ""),
        is_default=0,
        config=config,
    )
    db.add(profile)
    db.commit()
    return {"status": "ok", "id": profile.id, "slug": profile.slug}


@app.put("/api/profiles/{slug}")
async def api_update_profile(slug: str, request: Request, db: Session = Depends(get_db)):
    profile = db.query(SiteProfile).filter(SiteProfile.slug == slug).first()
    if not profile:
        raise HTTPException(status_code=404, detail="Profile not found")
    data = await request.json()
    if "name" in data:
        profile.name = data["name"]
    if "description" in data:
        profile.description = data["description"]
    if "config" in data:
        errors = validate_profile_config(data["config"])
        if errors:
            raise HTTPException(status_code=400, detail={"validation_errors": errors})
        profile.config = data["config"]
    db.commit()
    return {"status": "ok"}


@app.delete("/api/profiles/{slug}")
async def api_delete_profile(slug: str, db: Session = Depends(get_db)):
    profile = db.query(SiteProfile).filter(SiteProfile.slug == slug).first()
    if not profile:
        raise HTTPException(status_code=404, detail="Profile not found")
    if profile.is_default:
        raise HTTPException(status_code=400, detail="Cannot delete the default profile")
    db.delete(profile)
    db.commit()
    return {"status": "ok"}


@app.post("/api/profiles/import")
async def api_import_profile(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    name = data.get("name", "").strip()
    config = data.get("config")
    if not name or not config:
        raise HTTPException(status_code=400, detail="Both 'name' and 'config' are required")
    errors = validate_profile_config(config)
    if errors:
        raise HTTPException(status_code=400, detail={"validation_errors": errors})
    slug = slugify(name)
    existing = db.query(SiteProfile).filter(SiteProfile.slug == slug).first()
    if existing:
        existing.name = name
        existing.description = data.get("description", existing.description)
        existing.config = config
        db.commit()
        return {"status": "ok", "action": "updated", "id": existing.id, "slug": slug}
    profile = SiteProfile(
        name=name, slug=slug,
        description=data.get("description", ""),
        is_default=0, config=config,
    )
    db.add(profile)
    db.commit()
    return {"status": "ok", "action": "created", "id": profile.id, "slug": slug}


@app.get("/api/profiles/{slug}/export")
async def api_export_profile(slug: str, db: Session = Depends(get_db)):
    profile = db.query(SiteProfile).filter(SiteProfile.slug == slug).first()
    if not profile:
        raise HTTPException(status_code=404, detail="Profile not found")
    return {
        "name": profile.name,
        "slug": profile.slug,
        "description": profile.description,
        "config": profile.config,
        "exported_at": datetime.datetime.utcnow().isoformat() + "Z",
    }


@app.get("/api/profiles/{slug}/discovery-fields")
async def api_get_discovery_fields(slug: str, db: Session = Depends(get_db)):
    profile = db.query(SiteProfile).filter(SiteProfile.slug == slug).first()
    if not profile:
        raise HTTPException(status_code=404, detail="Profile not found")
    fields = profile.config.get("discovery_fields", DISCOVERY_FIELDS_DEFAULT)
    return {"profile": profile.name, "slug": slug, "discovery_fields": fields}


@app.get("/tasks", response_class=HTMLResponse)
async def tasks_page(request: Request, db: Session = Depends(get_db)):
    lists = db.query(BuildTask.list_name).distinct().all()
    list_names = sorted(set(l[0] for l in lists)) if lists else []
    return templates.TemplateResponse("tasks.html", {"request": request, "list_names": list_names, "current_page": "tasks", "current_domain": ""})


@app.get("/api/tasks")
async def api_get_tasks(request: Request, db: Session = Depends(get_db)):
    list_name = request.query_params.get("list", None)
    q = db.query(BuildTask)
    if list_name:
        q = q.filter(BuildTask.list_name == list_name)
    status_order = case(
        (BuildTask.status == "in_progress", 0),
        (BuildTask.status == "pending", 1),
        else_=2,
    )
    tasks = q.order_by(BuildTask.list_name, status_order, BuildTask.sort_order, BuildTask.id).all()
    grouped = {}
    for t in tasks:
        if t.list_name not in grouped:
            grouped[t.list_name] = []
        grouped[t.list_name].append({
            "id": t.id, "title": t.title, "description": t.description,
            "status": t.status, "category": t.category, "sort_order": t.sort_order,
            "created_at": t.created_at.isoformat() + "Z" if t.created_at else None,
            "updated_at": t.updated_at.isoformat() + "Z" if t.updated_at else None,
            "completed_at": t.completed_at.isoformat() + "Z" if t.completed_at else None,
        })
    return {"lists": grouped}


@app.post("/api/tasks")
async def api_create_task(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    list_name = data.get("list_name", "Default")
    max_order = db.query(BuildTask).filter(BuildTask.list_name == list_name).count()
    task = BuildTask(
        list_name=list_name,
        title=data.get("title", ""),
        description=data.get("description", ""),
        status="pending",
        category=data.get("category", ""),
        sort_order=max_order,
    )
    db.add(task)
    db.commit()
    return {"status": "ok", "id": task.id}


@app.post("/api/tasks/bulk")
async def api_bulk_create_tasks(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    list_name = data.get("list_name", "Default")
    tasks_data = data.get("tasks", [])
    created = []
    for i, td in enumerate(tasks_data):
        task = BuildTask(
            list_name=list_name,
            title=td.get("title", ""),
            description=td.get("description", ""),
            status=td.get("status", "pending"),
            category=td.get("category", ""),
            sort_order=i,
        )
        db.add(task)
        db.flush()
        created.append(task.id)
    db.commit()
    return {"status": "ok", "count": len(created), "ids": created}


@app.patch("/api/tasks/{task_id}")
async def api_update_task(task_id: int, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    task = db.query(BuildTask).filter(BuildTask.id == task_id).first()
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    if "status" in data:
        task.status = data["status"]
        if data["status"] == "completed":
            task.completed_at = datetime.datetime.utcnow()
        elif data["status"] == "pending":
            task.completed_at = None
    if "title" in data:
        task.title = data["title"]
    if "description" in data:
        task.description = data["description"]
    if "category" in data:
        task.category = data["category"]
    db.commit()
    return {"status": "ok"}


@app.delete("/api/tasks/{task_id}")
async def api_delete_task(task_id: int, db: Session = Depends(get_db)):
    task = db.query(BuildTask).filter(BuildTask.id == task_id).first()
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    db.delete(task)
    db.commit()
    return {"status": "ok"}


@app.delete("/api/tasks/list/{list_name}")
async def api_delete_task_list(list_name: str, db: Session = Depends(get_db)):
    db.query(BuildTask).filter(BuildTask.list_name == list_name).delete()
    db.commit()
    return {"status": "ok"}


@app.get("/api/valuation/{domain}")
async def api_domain_valuation(domain: str, db: Session = Depends(get_db)):
    domain_record = db.query(Domain).filter(Domain.domain == domain).first()
    if not domain_record or not domain_record.analysis:
        raise HTTPException(status_code=404, detail="Domain not found or not analyzed yet")

    valuation = valuate_domain(domain, domain_record.analysis)
    return valuation


@app.get("/api/health/validate")
async def health_validate(db: Session = Depends(get_db)):
    from app.services.validators import validate_db_package, validate_db_analysis, validate_site_copy, validate_brand
    from app.services.aura import _normalize_site_copy

    domains = db.query(Domain).all()
    packages = db.query(Package).all()

    domain_reports = []
    for d in domains:
        report = validate_db_analysis(d)
        domain_reports.append(report.to_dict())

    package_reports = []
    for pkg in packages:
        report = validate_db_package(pkg)

        render_ok = True
        render_error = ""
        try:
            brand = pkg.brand or {}
            site_copy = _normalize_site_copy(pkg.site_copy or {})
            site_copy, _ = validate_site_copy(site_copy, auto_repair=True)
            brand, _ = validate_brand(brand, auto_repair=True)
            recommended_idx = brand.get("recommended", 0)
            options = brand.get("options", [])
            chosen_brand = options[recommended_idx] if options and recommended_idx < len(options) else {"name": pkg.domain_name, "tagline": ""}

            from app.services.theme import generate_theme_config, generate_theme_css
            _hc_theme = generate_theme_config(
                primary=brand.get("color_primary", "#4F46E5"),
                secondary=brand.get("color_secondary", "#7C3AED"),
                accent=brand.get("color_accent", "#06B6D4"),
                niche=pkg.chosen_niche or "",
            )
            _hc_theme_css = generate_theme_css(_hc_theme)
            template = templates.get_template("site.html")
            template.render(
                request=None,
                domain=pkg.domain_name,
                brand=chosen_brand,
                brand_data=brand,
                brand_options=options,
                site_copy=site_copy,
                package=pkg,
                hero_image_url=pkg.hero_image_url,
                template_type=pkg.template_type or "hero",
                layout_style=pkg.layout_style or "single-scroll",
                density=pkg.density or "balanced",
                section_assets={},
                theme=_hc_theme, theme_css=_hc_theme_css,
                augments=[],
            )
        except Exception as e:
            render_ok = False
            render_error = str(e)

        sections_present = []
        sections_missing = []
        sc = pkg.site_copy or {}
        if isinstance(sc, dict):
            expected_sections = ["hero", "features", "benefits", "about", "faq", "cta", "testimonials", "pricing"]
            for sec in expected_sections:
                if sc.get(sec):
                    sections_present.append(sec)
                else:
                    sections_missing.append(sec)

        brand_data = pkg.brand or {}
        brand_options = brand_data.get("options", [])
        has_brand = len(brand_options) > 0
        has_hero_image = bool(pkg.hero_image_url)
        has_sales_letter = bool(pkg.sales_letter)

        hero_file_exists = False
        if pkg.hero_image_url:
            hero_path = pkg.hero_image_url.lstrip("/")
            hero_file_exists = os.path.isfile(hero_path)

        checks = [
            {"name": "Brand identity", "passed": has_brand, "detail": f"{len(brand_options)} options" if has_brand else "Missing"},
            {"name": "Site sections", "passed": len(sections_missing) == 0,
             "detail": f"{len(sections_present)}/{len(sections_present)+len(sections_missing)} present" + (f", missing: {', '.join(sections_missing)}" if sections_missing else "")},
            {"name": "Hero image", "passed": has_hero_image and hero_file_exists,
             "detail": "Present & file exists" if has_hero_image and hero_file_exists else ("URL set but file missing" if has_hero_image else "Not generated")},
            {"name": "Sales letter", "passed": has_sales_letter, "detail": f"{len(pkg.sales_letter)} chars" if has_sales_letter else "Not generated"},
            {"name": "Render test", "passed": render_ok, "detail": render_error if not render_ok else "Template renders OK"},
        ]

        pkg_result = report.to_dict()
        pkg_result["render_test"] = {"passed": render_ok, "error": render_error}
        pkg_result["domain_name"] = pkg.domain_name
        pkg_result["niche"] = pkg.chosen_niche
        pkg_result["package_id"] = pkg.id
        pkg_result["template_type"] = pkg.template_type or "hero"
        pkg_result["sections_present"] = sections_present
        pkg_result["sections_missing"] = sections_missing
        pkg_result["checks"] = checks
        pkg_result["has_hero_image"] = has_hero_image
        pkg_result["has_sales_letter"] = has_sales_letter
        package_reports.append(pkg_result)

    total_errors = sum(r["error_count"] for r in domain_reports) + sum(r["error_count"] for r in package_reports)
    total_warnings = sum(r["warning_count"] for r in domain_reports) + sum(r["warning_count"] for r in package_reports)
    render_failures = sum(1 for r in package_reports if not r.get("render_test", {}).get("passed", True))

    overall_healthy = total_errors == 0 and render_failures == 0

    return {
        "healthy": overall_healthy,
        "summary": {
            "domains_checked": len(domains),
            "packages_checked": len(packages),
            "total_errors": total_errors,
            "total_warnings": total_warnings,
            "render_failures": render_failures,
        },
        "domains": domain_reports,
        "packages": package_reports,
    }


@app.post("/api/packages/site-audit/run")
async def run_site_audit(db: Session = Depends(get_db)):
    """
    Scan all packages and report which site assets are present/missing.
    No LLM involved — pure DB + filesystem check. Stores result in AppSettings.
    Returns the audit report immediately.
    """
    from sqlalchemy import func as sa_func

    packages = db.query(Package).order_by(Package.domain_name).all()
    augment_counts = {}
    aug_rows = db.query(Augment.domain_name, sa_func.count(Augment.id)).group_by(Augment.domain_name).all()
    for domain_name, cnt in aug_rows:
        augment_counts[domain_name] = cnt

    gaps = {
        "no_hero": [],
        "hero_file_missing": [],
        "no_sales_letter": [],
        "thin_sales_letter": [],
        "no_augments": [],
        "thin_augments": [],
        "no_calculators": [],
        "no_reference_library": [],
        "no_graphics_pack": [],
        "no_force_multiplier": [],
        "thin_site_copy": [],
    }

    domains_report = {}
    for pkg in packages:
        domain = pkg.domain_name
        sc = pkg.site_copy or {}
        aug_count = augment_counts.get(domain, 0)

        hero_url = pkg.hero_image_url
        hero_file_ok = False
        if hero_url:
            hero_path = hero_url.lstrip("/")
            hero_file_ok = os.path.isfile(hero_path)

        sales_letter = pkg.sales_letter or ""
        calcs = pkg.calculators or {}
        calc_specs = calcs.get("specs", []) if isinstance(calcs, dict) else []
        ref_lib = pkg.reference_library or {}
        gfx = pkg.graphics_pack or {}
        gfx_assets = []
        if isinstance(gfx, dict):
            for _k, _v in gfx.items():
                if isinstance(_v, list):
                    gfx_assets.extend(_v)
        biz_box = pkg.business_box or {}
        biz_docs = {}
        if isinstance(biz_box, dict):
            for _tier_k, _tier_v in biz_box.items():
                if isinstance(_tier_v, dict):
                    biz_docs.update(_tier_v)

        features = sc.get("features", [])
        if isinstance(features, dict):
            features = features.get("features", [])
        feature_count = len(features) if isinstance(features, list) else 0

        faq = sc.get("faq_items", sc.get("faq", []))
        if isinstance(faq, dict):
            faq = faq.get("faq_items", [])
        faq_count = len(faq) if isinstance(faq, list) else 0

        testimonials = sc.get("testimonials", [])
        if isinstance(testimonials, dict):
            testimonials = testimonials.get("testimonials", [])
        testimonial_count = len(testimonials) if isinstance(testimonials, list) else 0

        site_copy_score = (
            (1 if sc.get("headline") else 0) +
            (1 if sc.get("about") else 0) +
            (1 if feature_count >= 4 else 0) +
            (1 if faq_count >= 3 else 0) +
            (1 if testimonial_count >= 2 else 0)
        )

        pkg_report = {
            "domain": domain,
            "niche": pkg.chosen_niche or "",
            "quality_score": pkg.quality_score,
            "updated_at": pkg.updated_at.isoformat() + "Z" if pkg.updated_at else None,
            "assets": {
                "hero_image": {
                    "status": "ok" if hero_file_ok else ("url_only" if hero_url else "missing"),
                    "value": hero_url,
                },
                "sales_letter": {
                    "status": "ok" if len(sales_letter) >= 500 else ("thin" if sales_letter else "missing"),
                    "value": len(sales_letter),
                },
                "augments": {
                    "status": "ok" if aug_count >= 2 else ("partial" if aug_count == 1 else "missing"),
                    "value": aug_count,
                },
                "calculators": {
                    "status": "ok" if len(calc_specs) >= 2 else ("partial" if calc_specs else "missing"),
                    "value": len(calc_specs),
                },
                "reference_library": {
                    "status": "ok" if bool(ref_lib) else "missing",
                    "value": len(ref_lib.get("sections", [])) if isinstance(ref_lib, dict) else (1 if ref_lib else 0),
                },
                "graphics_pack": {
                    "status": "ok" if len(gfx_assets) >= 3 else ("partial" if gfx_assets else "missing"),
                    "value": len(gfx_assets),
                },
                "force_multiplier": {
                    "status": "ok" if len(biz_docs) >= 10 else ("partial" if biz_docs else "missing"),
                    "value": len(biz_docs),
                },
                "site_copy": {
                    "status": "ok" if site_copy_score >= 4 else ("partial" if site_copy_score >= 2 else "missing"),
                    "value": site_copy_score,
                    "detail": {
                        "features": feature_count,
                        "faq": faq_count,
                        "testimonials": testimonial_count,
                    },
                },
            },
        }
        domains_report[domain] = pkg_report

        if not hero_url:
            gaps["no_hero"].append(domain)
        elif not hero_file_ok:
            gaps["hero_file_missing"].append(domain)

        if not sales_letter:
            gaps["no_sales_letter"].append(domain)
        elif len(sales_letter) < 500:
            gaps["thin_sales_letter"].append(domain)

        if aug_count == 0:
            gaps["no_augments"].append(domain)
        elif aug_count < 2:
            gaps["thin_augments"].append(domain)

        if not calc_specs:
            gaps["no_calculators"].append(domain)

        if not ref_lib:
            gaps["no_reference_library"].append(domain)

        if not gfx_assets:
            gaps["no_graphics_pack"].append(domain)

        if not biz_docs:
            gaps["no_force_multiplier"].append(domain)

        if site_copy_score < 3:
            gaps["thin_site_copy"].append(domain)

    gap_counts = {k: len(v) for k, v in gaps.items()}
    report = {
        "last_run": datetime.datetime.utcnow().isoformat() + "Z",
        "total_packages": len(packages),
        "gap_counts": gap_counts,
        "gaps": gaps,
        "domains": domains_report,
    }

    setting = db.query(AppSettings).filter(AppSettings.key == "site_audit_report").first()
    if setting:
        setting.value = json.dumps(report)
        setting.updated_at = datetime.datetime.utcnow()
    else:
        setting = AppSettings(key="site_audit_report", value=json.dumps(report))
        db.add(setting)
    db.commit()

    return report


@app.get("/api/packages/site-audit/report")
async def get_site_audit_report(db: Session = Depends(get_db)):
    setting = db.query(AppSettings).filter(AppSettings.key == "site_audit_report").first()
    if not setting or not setting.value:
        return {"last_run": None, "total_packages": 0, "gap_counts": {}, "gaps": {}, "domains": {}}
    return json.loads(setting.value)


@app.get("/api/legendary-scan/{domain}")
async def legendary_scan(domain: str, db: Session = Depends(get_db)):
    from app.services.legendary_scanner import scan_package
    from app.services.aura import _normalize_site_copy

    package = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not package:
        return {"error": f"No package found for {domain}", "domain": domain}

    if package.site_copy and isinstance(package.site_copy, dict):
        package.site_copy = _normalize_site_copy(dict(package.site_copy))

    kit = db.query(BrandKit).filter(BrandKit.domain == domain).first()
    kit_assets = db.query(BrandKitAsset).filter(BrandKitAsset.brand_kit_id == kit.id).all() if kit else []

    ctx = db.query(ProjectContext).filter(ProjectContext.domain == domain).first()
    ctx_keywords = None
    if ctx and ctx.context_state and isinstance(ctx.context_state, dict):
        ctx_keywords = ctx.context_state.get("keywords", [])

    result = scan_package(
        package=package,
        brand_kit=kit,
        brand_kit_assets=kit_assets,
        context_keywords=ctx_keywords,
    )
    return result


@app.get("/api/legendary-scan")
async def legendary_scan_all(db: Session = Depends(get_db)):
    from app.services.legendary_scanner import scan_package
    from app.services.aura import _normalize_site_copy

    packages = db.query(Package).order_by(Package.created_at.desc()).all()
    results = []

    for package in packages:
        try:
            if package.site_copy and isinstance(package.site_copy, dict):
                package.site_copy = _normalize_site_copy(dict(package.site_copy))

            kit = db.query(BrandKit).filter(BrandKit.domain == package.domain_name).first()
            kit_assets = db.query(BrandKitAsset).filter(BrandKitAsset.brand_kit_id == kit.id).all() if kit else []

            ctx = db.query(ProjectContext).filter(ProjectContext.domain == package.domain_name).first()
            ctx_keywords = None
            if ctx and ctx.context_state and isinstance(ctx.context_state, dict):
                ctx_keywords = ctx.context_state.get("keywords", [])

            result = scan_package(
                package=package,
                brand_kit=kit,
                brand_kit_assets=kit_assets,
                context_keywords=ctx_keywords,
            )
            results.append({
                "domain": package.domain_name,
                "score": result["overall_score"],
                "rating": result["overall_rating"],
                "summary": result["summary"],
                "categories": {k: {"rating": v["rating"], "score": v["score"]} for k, v in result["categories"].items()},
                "top_suggestions": result["suggestions"][:3],
            })
        except Exception as e:
            results.append({"domain": package.domain_name, "score": 0, "rating": "error", "error": str(e)})

    results.sort(key=lambda r: r.get("score", 0), reverse=True)
    avg_score = round(sum(r.get("score", 0) for r in results) / len(results)) if results else 0
    legendary_count = sum(1 for r in results if r.get("rating") == "legendary")

    return {
        "total_packages": len(results),
        "average_score": avg_score,
        "legendary_count": legendary_count,
        "packages": results,
    }


@app.get("/advisor", response_class=HTMLResponse)
async def advisor_page(request: Request, db: Session = Depends(get_db)):
    concepts = db.query(Concept).order_by(Concept.created_at.desc()).all()
    batch_plans = db.query(BatchPlan).order_by(BatchPlan.created_at.desc()).all()
    return templates.TemplateResponse("advisor.html", {
        "request": request,
        "concepts": concepts,
        "batch_plans": batch_plans,
        "statuses": CONCEPT_STATUSES,
        "current_page": "advisor", "current_domain": "",
    })


@app.post("/api/advisor/concepts")
async def api_create_concept(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    raw_input = data.get("raw_input", "").strip()
    if not raw_input:
        raise HTTPException(status_code=400, detail="Concept text is required")
    title = data.get("title", raw_input[:100])
    domain = data.get("domain")

    advisor_ctx = build_advisor_context(db)

    project_ctx = None
    if domain:
        from app.services.context_engine import get_full_context_for_domain
        project_ctx = get_full_context_for_domain(domain, db)

    concept = Concept(
        title=title,
        raw_input=raw_input,
        status="captured",
        source_type=data.get("source_type", "manual"),
        source_id=data.get("source_id"),
        domain=domain,
        context_snapshot={"advisor": advisor_ctx, "project": project_ctx},
        tags=data.get("tags", []),
    )
    db.add(concept)
    db.commit()
    return concept.to_dict()


@app.get("/api/advisor/concepts")
async def api_list_concepts(request: Request, db: Session = Depends(get_db)):
    q = db.query(Concept)
    status = request.query_params.get("status")
    if status:
        q = q.filter(Concept.status == status)
    source = request.query_params.get("source_type")
    if source:
        q = q.filter(Concept.source_type == source)
    domain = request.query_params.get("domain")
    if domain:
        q = q.filter(Concept.domain == domain)
    concepts = q.order_by(Concept.created_at.desc()).all()
    return {"concepts": [c.to_dict() for c in concepts]}


@app.get("/api/advisor/concepts/{concept_id}")
async def api_get_concept(concept_id: int, db: Session = Depends(get_db)):
    concept = db.query(Concept).filter(Concept.id == concept_id).first()
    if not concept:
        raise HTTPException(status_code=404, detail="Concept not found")
    result = concept.to_dict()
    result["revisions"] = [r.to_dict() for r in concept.revisions]
    return result


@app.patch("/api/advisor/concepts/{concept_id}")
async def api_update_concept(concept_id: int, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    concept = db.query(Concept).filter(Concept.id == concept_id).first()
    if not concept:
        raise HTTPException(status_code=404, detail="Concept not found")
    if "status" in data and data["status"] in CONCEPT_STATUSES:
        concept.status = data["status"]
    if "title" in data:
        concept.title = data["title"]
    if "raw_input" in data:
        concept.raw_input = data["raw_input"]
    if "tags" in data:
        concept.tags = data["tags"]
    if "domain" in data:
        concept.domain = data["domain"]
    if data.get("note"):
        rev = ConceptRevision(
            concept_id=concept.id,
            revision_type="note",
            content={"note": data["note"]},
        )
        db.add(rev)
    db.commit()
    return concept.to_dict()


@app.delete("/api/advisor/concepts/{concept_id}")
async def api_delete_concept(concept_id: int, db: Session = Depends(get_db)):
    concept = db.query(Concept).filter(Concept.id == concept_id).first()
    if not concept:
        raise HTTPException(status_code=404, detail="Concept not found")
    db.delete(concept)
    db.commit()
    return {"deleted": True}


def run_advisor_analyze_job(job_id: str, concept_id: int):
    db = SessionLocal()
    try:
        update_job(job_id, status="running", current_step="Loading concept", current_step_key="init",
                   progress_pct=5, started_at=datetime.datetime.utcnow(), step_started_at=datetime.datetime.utcnow())

        concept = db.query(Concept).filter(Concept.id == concept_id).first()
        if not concept:
            update_job(job_id, status="failed", error="Concept not found")
            return

        update_job(job_id, current_step="Assembling context", current_step_key="context", progress_pct=20,
                   step_started_at=datetime.datetime.utcnow())
        advisor_ctx = build_advisor_context(db)
        project_ctx = ""
        if concept.domain:
            from app.services.context_engine import get_full_context_for_domain
            project_ctx = get_full_context_for_domain(concept.domain, db)

        update_job(job_id, current_step="AI analyzing concept", current_step_key="ai_analysis", progress_pct=40,
                   step_started_at=datetime.datetime.utcnow())
        prompt = build_analysis_prompt(concept.title, concept.raw_input, advisor_ctx, project_ctx)
        response = call_llm_text_routed("force_multiplier", prompt)

        try:
            analysis = json.loads(response)
        except json.JSONDecodeError:
            cleaned = response.strip()
            if cleaned.startswith("```"):
                cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:]
            if cleaned.endswith("```"):
                cleaned = cleaned[:-3]
            analysis = json.loads(cleaned.strip())

        rev = ConceptRevision(
            concept_id=concept.id,
            revision_type="analysis",
            content=analysis,
            context_snapshot={"advisor": advisor_ctx, "project": project_ctx[:2000] if project_ctx else None},
        )
        db.add(rev)
        concept.status = "analyzed"
        db.commit()

        update_job(job_id, status="completed", current_step="Analysis complete", current_step_key="complete",
                   progress_pct=100, result=analysis, completed_at=datetime.datetime.utcnow())

    except Exception as e:
        logger.error(f"Advisor analyze job failed: {e}")
        update_job(job_id, status="failed", current_step=f"Error: {str(e)}", error=str(e))
    finally:
        db.close()


@app.post("/api/advisor/concepts/{concept_id}/analyze")
async def api_analyze_concept(concept_id: int, db: Session = Depends(get_db)):
    concept = db.query(Concept).filter(Concept.id == concept_id).first()
    if not concept:
        raise HTTPException(status_code=404, detail="Concept not found")
    job_id = str(uuid.uuid4())[:8]
    create_job(job_id, "advisor_analyze", concept.title[:50], len(ADVISOR_ANALYZE_STEPS), ADVISOR_ANALYZE_STEPS)
    job_executor.submit(run_advisor_analyze_job, job_id, concept_id)
    return {"job_id": job_id}


def run_advisor_plan_job(job_id: str, concept_id: int):
    db = SessionLocal()
    try:
        update_job(job_id, status="running", current_step="Loading concept", current_step_key="init",
                   progress_pct=5, started_at=datetime.datetime.utcnow(), step_started_at=datetime.datetime.utcnow())

        concept = db.query(Concept).filter(Concept.id == concept_id).first()
        if not concept:
            update_job(job_id, status="failed", error="Concept not found")
            return

        analysis_rev = db.query(ConceptRevision).filter(
            ConceptRevision.concept_id == concept_id,
            ConceptRevision.revision_type == "analysis"
        ).order_by(ConceptRevision.created_at.desc()).first()

        analysis = analysis_rev.content if analysis_rev else {}

        update_job(job_id, current_step="Assembling context", current_step_key="context", progress_pct=20,
                   step_started_at=datetime.datetime.utcnow())
        advisor_ctx = build_advisor_context(db)
        project_ctx = ""
        if concept.domain:
            from app.services.context_engine import get_full_context_for_domain
            project_ctx = get_full_context_for_domain(concept.domain, db)

        update_job(job_id, current_step="AI generating plan", current_step_key="ai_plan", progress_pct=40,
                   step_started_at=datetime.datetime.utcnow())
        prompt = build_plan_prompt(concept.title, concept.raw_input, analysis, advisor_ctx, project_ctx)
        response = call_llm_text_routed("force_multiplier", prompt)

        try:
            plan = json.loads(response)
        except json.JSONDecodeError:
            cleaned = response.strip()
            if cleaned.startswith("```"):
                cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:]
            if cleaned.endswith("```"):
                cleaned = cleaned[:-3]
            plan = json.loads(cleaned.strip())

        rev = ConceptRevision(
            concept_id=concept.id,
            revision_type="plan",
            content=plan,
            context_snapshot={"advisor": advisor_ctx},
        )
        db.add(rev)
        concept.status = "planned"
        db.commit()

        update_job(job_id, status="completed", current_step="Plan complete", current_step_key="complete",
                   progress_pct=100, result=plan, completed_at=datetime.datetime.utcnow())

    except Exception as e:
        logger.error(f"Advisor plan job failed: {e}")
        update_job(job_id, status="failed", current_step=f"Error: {str(e)}", error=str(e))
    finally:
        db.close()


@app.post("/api/advisor/concepts/{concept_id}/plan")
async def api_plan_concept(concept_id: int, db: Session = Depends(get_db)):
    concept = db.query(Concept).filter(Concept.id == concept_id).first()
    if not concept:
        raise HTTPException(status_code=404, detail="Concept not found")
    job_id = str(uuid.uuid4())[:8]
    create_job(job_id, "advisor_plan", concept.title[:50], len(ADVISOR_PLAN_STEPS), ADVISOR_PLAN_STEPS)
    job_executor.submit(run_advisor_plan_job, job_id, concept_id)
    return {"job_id": job_id}


@app.post("/api/advisor/concepts/{concept_id}/export")
async def api_export_concept(concept_id: int, db: Session = Depends(get_db)):
    concept = db.query(Concept).filter(Concept.id == concept_id).first()
    if not concept:
        raise HTTPException(status_code=404, detail="Concept not found")
    result = concept.to_dict()
    result["revisions"] = [r.to_dict() for r in concept.revisions]
    return JSONResponse(result, headers={"Content-Disposition": f"attachment; filename=concept_{concept_id}.json"})


@app.post("/api/advisor/import")
async def api_import_concept(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    concept = Concept(
        title=data.get("title", "Imported concept"),
        raw_input=data.get("raw_input", ""),
        status=data.get("status", "captured"),
        source_type="import",
        domain=data.get("domain"),
        context_snapshot=data.get("context_snapshot"),
        tags=data.get("tags", []),
    )
    db.add(concept)
    db.flush()
    for rev_data in data.get("revisions", []):
        rev = ConceptRevision(
            concept_id=concept.id,
            revision_type=rev_data.get("revision_type", "note"),
            content=rev_data.get("content", {}),
            context_snapshot=rev_data.get("context_snapshot"),
        )
        db.add(rev)
    db.commit()
    return concept.to_dict()


def run_advisor_batch_job(job_id: str, concept_ids: list, batch_plan_id: int):
    db = SessionLocal()
    try:
        update_job(job_id, status="running", current_step="Loading concepts", current_step_key="init",
                   progress_pct=5, started_at=datetime.datetime.utcnow(), step_started_at=datetime.datetime.utcnow())

        concepts = db.query(Concept).filter(Concept.id.in_(concept_ids)).all()
        if not concepts:
            update_job(job_id, status="failed", error="No concepts found")
            return

        update_job(job_id, current_step="Assembling context", current_step_key="context", progress_pct=20,
                   step_started_at=datetime.datetime.utcnow())
        advisor_ctx = build_advisor_context(db)

        analyses = {}
        for c in concepts:
            rev = db.query(ConceptRevision).filter(
                ConceptRevision.concept_id == c.id,
                ConceptRevision.revision_type == "analysis"
            ).order_by(ConceptRevision.created_at.desc()).first()
            if rev:
                analyses[c.id] = rev.content

        concept_dicts = [{"id": c.id, "title": c.title, "raw_input": c.raw_input} for c in concepts]

        update_job(job_id, current_step="AI synthesizing batch plan", current_step_key="ai_batch", progress_pct=40,
                   step_started_at=datetime.datetime.utcnow())
        prompt = build_batch_prompt(concept_dicts, analyses, advisor_ctx)
        response = call_llm_text_routed("force_multiplier", prompt)

        try:
            batch_result = json.loads(response)
        except json.JSONDecodeError:
            cleaned = response.strip()
            if cleaned.startswith("```"):
                cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:]
            if cleaned.endswith("```"):
                cleaned = cleaned[:-3]
            batch_result = json.loads(cleaned.strip())

        batch_plan = db.query(BatchPlan).filter(BatchPlan.id == batch_plan_id).first()
        if batch_plan:
            batch_plan.plan_content = batch_result
            batch_plan.title = batch_result.get("batch_title", batch_plan.title)
            batch_plan.dependency_graph = batch_result.get("dependency_graph")
            batch_plan.parallel_tracks = batch_result.get("parallel_tracks")
            batch_plan.status = "ready"
            db.commit()

        update_job(job_id, status="completed", current_step="Batch plan complete", current_step_key="complete",
                   progress_pct=100, result=batch_result, completed_at=datetime.datetime.utcnow())

    except Exception as e:
        logger.error(f"Advisor batch job failed: {e}")
        update_job(job_id, status="failed", current_step=f"Error: {str(e)}", error=str(e))
    finally:
        db.close()


@app.post("/api/advisor/batch")
async def api_batch_plan(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    concept_ids = data.get("concept_ids", [])
    if len(concept_ids) < 2:
        raise HTTPException(status_code=400, detail="Need at least 2 concepts for a batch plan")
    concepts = db.query(Concept).filter(Concept.id.in_(concept_ids)).all()
    if len(concepts) < 2:
        raise HTTPException(status_code=400, detail="Not enough valid concepts found")

    batch_plan = BatchPlan(
        title=data.get("title", f"Batch plan: {', '.join(c.title[:30] for c in concepts[:3])}"),
        status="generating",
    )
    db.add(batch_plan)
    db.flush()
    for c in concepts:
        batch_plan.concepts.append(c)
    db.commit()

    job_id = str(uuid.uuid4())[:8]
    create_job(job_id, "advisor_batch", "Batch plan", len(ADVISOR_BATCH_STEPS), ADVISOR_BATCH_STEPS)
    job_executor.submit(run_advisor_batch_job, job_id, concept_ids, batch_plan.id)
    return {"job_id": job_id, "batch_plan_id": batch_plan.id}


@app.get("/api/advisor/batch/{batch_id}")
async def api_get_batch_plan(batch_id: int, db: Session = Depends(get_db)):
    batch = db.query(BatchPlan).filter(BatchPlan.id == batch_id).first()
    if not batch:
        raise HTTPException(status_code=404, detail="Batch plan not found")
    return batch.to_dict()


@app.get("/_dev_admin/{token}/deploy", response_class=HTMLResponse)
async def dev_deploy_page(token: str, request: Request, db: Session = Depends(get_db)):
    if token != _DEV_PREVIEW_TOKEN:
        raise HTTPException(status_code=403)
    request.session["username"] = "_dev_preview"
    return await deploy_page(request, db)


@app.get("/deploy", response_class=HTMLResponse)
async def deploy_page(request: Request, db: Session = Depends(get_db)):
    profiles = db.query(FtpProfile).order_by(FtpProfile.created_at).all()
    bindings = db.query(FtpProjectBinding).all()
    logs = db.query(DeploymentLog).order_by(DeploymentLog.created_at.desc()).limit(50).all()
    domains = db.query(Domain).order_by(Domain.domain).all()
    packages = db.query(Package).all()
    domain_has_package = {p.domain_name for p in packages}
    return templates.TemplateResponse("deploy.html", {
        "request": request,
        "profiles": profiles,
        "bindings": bindings,
        "logs": logs,
        "domains": domains,
        "domain_has_package": domain_has_package,
        "current_page": "deploy", "current_domain": "",
    })


@app.post("/api/ftp/profiles")
async def api_create_ftp_profile(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    for field in ["label", "host", "username", "password"]:
        if not data.get(field):
            raise HTTPException(status_code=400, detail=f"{field} is required")
    profile = FtpProfile(
        label=data["label"],
        host=data["host"],
        port=data.get("port", 22),
        username=data["username"],
        encrypted_password=encrypt_password(data["password"]),
        base_path=data.get("base_path", "/"),
        protocol=data.get("protocol", "sftp"),
        is_default=data.get("is_default", False),
    )
    db.add(profile)
    db.commit()
    return profile.to_dict()


@app.get("/api/ftp/profiles")
async def api_list_ftp_profiles(db: Session = Depends(get_db)):
    profiles = db.query(FtpProfile).order_by(FtpProfile.created_at).all()
    return {"profiles": [p.to_dict() for p in profiles]}


@app.put("/api/ftp/profiles/{profile_id}")
async def api_update_ftp_profile(profile_id: int, request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    profile = db.query(FtpProfile).filter(FtpProfile.id == profile_id).first()
    if not profile:
        raise HTTPException(status_code=404, detail="Profile not found")
    if "label" in data:
        profile.label = data["label"]
    if "host" in data:
        profile.host = data["host"]
    if "port" in data:
        profile.port = data["port"]
    if "username" in data:
        profile.username = data["username"]
    if "password" in data and data["password"]:
        profile.encrypted_password = encrypt_password(data["password"])
    if "base_path" in data:
        profile.base_path = data["base_path"]
    if "protocol" in data:
        profile.protocol = data["protocol"]
    if "is_default" in data:
        profile.is_default = data["is_default"]
    db.commit()
    return profile.to_dict()


@app.delete("/api/ftp/profiles/{profile_id}")
async def api_delete_ftp_profile(profile_id: int, db: Session = Depends(get_db)):
    profile = db.query(FtpProfile).filter(FtpProfile.id == profile_id).first()
    if not profile:
        raise HTTPException(status_code=404, detail="Profile not found")
    db.delete(profile)
    db.commit()
    return {"deleted": True}


@app.post("/api/ftp/test-connection/{profile_id}")
async def api_test_ftp_connection(profile_id: int, db: Session = Depends(get_db)):
    profile = db.query(FtpProfile).filter(FtpProfile.id == profile_id).first()
    if not profile:
        raise HTTPException(status_code=404, detail="Profile not found")
    password = decrypt_password(profile.encrypted_password)
    result = test_ftp_connection(profile.host, profile.port, profile.username, password, profile.protocol, profile.base_path)
    return result


@app.post("/api/ftp/bindings")
async def api_create_ftp_binding(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    profile_id = data.get("ftp_profile_id")
    domain = data.get("domain")
    if not profile_id or not domain:
        raise HTTPException(status_code=400, detail="ftp_profile_id and domain are required")
    existing = db.query(FtpProjectBinding).filter(
        FtpProjectBinding.domain == domain,
        FtpProjectBinding.ftp_profile_id == profile_id,
    ).first()
    if existing:
        existing.target_directory = data.get("target_directory", existing.target_directory)
        existing.is_default = data.get("is_default", existing.is_default)
        db.commit()
        return existing.to_dict()
    binding = FtpProjectBinding(
        ftp_profile_id=profile_id,
        domain=domain,
        target_directory=data.get("target_directory", f"/{domain}"),
        is_default=data.get("is_default", True),
    )
    db.add(binding)
    db.commit()
    return binding.to_dict()


@app.get("/api/ftp/bindings")
async def api_list_ftp_bindings(request: Request, db: Session = Depends(get_db)):
    domain = request.query_params.get("domain")
    q = db.query(FtpProjectBinding)
    if domain:
        q = q.filter(FtpProjectBinding.domain == domain)
    bindings = q.all()
    return {"bindings": [b.to_dict() for b in bindings]}


@app.delete("/api/ftp/bindings/{binding_id}")
async def api_delete_ftp_binding(binding_id: int, db: Session = Depends(get_db)):
    binding = db.query(FtpProjectBinding).filter(FtpProjectBinding.id == binding_id).first()
    if not binding:
        raise HTTPException(status_code=404, detail="Binding not found")
    db.delete(binding)
    db.commit()
    return {"deleted": True}


def run_ftp_deploy_job(job_id: str, domain: str, profile_id: int, target_directory: str):
    db = SessionLocal()
    try:
        update_job(job_id, status="running", current_step="Preparing deployment", current_step_key="init",
                   progress_pct=5, started_at=datetime.datetime.utcnow(), step_started_at=datetime.datetime.utcnow())

        profile = db.query(FtpProfile).filter(FtpProfile.id == profile_id).first()
        if not profile:
            update_job(job_id, status="failed", error="FTP profile not found")
            return

        deploy_log = DeploymentLog(
            ftp_profile_id=profile_id,
            domain=domain,
            status="uploading",
            started_at=datetime.datetime.utcnow(),
        )
        db.add(deploy_log)
        db.commit()

        update_job(job_id, current_step="Collecting files", current_step_key="collect", progress_pct=15,
                   step_started_at=datetime.datetime.utcnow())
        files = collect_deploy_files(domain, db)
        if not files:
            deploy_log.status = "failed"
            deploy_log.error_message = "No files to deploy"
            deploy_log.completed_at = datetime.datetime.utcnow()
            db.commit()
            update_job(job_id, status="failed", error="No files to deploy")
            return

        deploy_log.files_total = len(files)
        db.commit()

        update_job(job_id, current_step="Connecting to server", current_step_key="connect", progress_pct=30,
                   step_started_at=datetime.datetime.utcnow())

        password = decrypt_password(profile.encrypted_password)
        remote_path = os.path.join(profile.base_path, target_directory.strip("/")).replace("\\", "/")

        def progress_cb(uploaded, total, filename):
            pct = 30 + int(65 * uploaded / max(total, 1))
            update_job(job_id, current_step=f"Uploading {filename}", current_step_key="upload",
                       progress_pct=pct, step_started_at=datetime.datetime.utcnow())
            deploy_log.files_uploaded = uploaded
            db.commit()

        update_job(job_id, current_step="Uploading files", current_step_key="upload", progress_pct=35,
                   step_started_at=datetime.datetime.utcnow())

        if profile.protocol == "sftp":
            uploaded = deploy_via_sftp(profile.host, profile.port, profile.username, password, remote_path, files, progress_cb)
        else:
            uploaded = deploy_via_ftp(profile.host, profile.port, profile.username, password, profile.protocol, remote_path, files, progress_cb)

        deploy_log.status = "completed"
        deploy_log.files_uploaded = uploaded
        deploy_log.completed_at = datetime.datetime.utcnow()
        if deploy_log.started_at and deploy_log.completed_at:
            deploy_log.duration_seconds = int((deploy_log.completed_at - deploy_log.started_at).total_seconds())
        db.commit()

        update_job(job_id, status="completed", current_step="Deployment complete", current_step_key="complete",
                   progress_pct=100, result={"files_uploaded": uploaded, "target": remote_path},
                   completed_at=datetime.datetime.utcnow())

    except Exception as e:
        logger.error(f"FTP deploy job failed for {domain}: {e}")
        try:
            deploy_log = db.query(DeploymentLog).filter(
                DeploymentLog.domain == domain, DeploymentLog.status == "uploading"
            ).order_by(DeploymentLog.created_at.desc()).first()
            if deploy_log:
                deploy_log.status = "failed"
                deploy_log.error_message = str(e)
                deploy_log.completed_at = datetime.datetime.utcnow()
                db.commit()
        except Exception:
            pass
        update_job(job_id, status="failed", current_step=f"Error: {str(e)}", error=str(e))
    finally:
        db.close()


@app.post("/api/ftp/deploy/{domain}")
async def api_ftp_deploy(domain: str, request: Request, db: Session = Depends(get_db)):
    data = await request.json() if request.headers.get("content-type") == "application/json" else {}
    profile_id = data.get("profile_id")
    target_directory = data.get("target_directory")

    if not profile_id:
        binding = db.query(FtpProjectBinding).filter(
            FtpProjectBinding.domain == domain,
            FtpProjectBinding.is_default == True,
        ).first()
        if not binding:
            raise HTTPException(status_code=400, detail="No default FTP binding for this domain. Set up a binding first.")
        profile_id = binding.ftp_profile_id
        target_directory = target_directory or binding.target_directory

    if not target_directory:
        target_directory = f"/{domain}"

    job_id = str(uuid.uuid4())[:8]
    create_job(job_id, "ftp_deploy", domain, len(FTP_DEPLOY_STEPS), FTP_DEPLOY_STEPS)
    job_executor.submit(run_ftp_deploy_job, job_id, domain, profile_id, target_directory)
    return {"job_id": job_id}


@app.post("/api/ftp/deploy-all")
async def api_ftp_deploy_all(request: Request, db: Session = Depends(get_db)):
    username = request.session.get("username")
    if not username:
        raise HTTPException(status_code=401)
    data = await request.json() if request.headers.get("content-type") == "application/json" else {}
    domains_filter = data.get("domains")  # optional list to limit which domains to deploy

    bindings = db.query(FtpProjectBinding).all()
    if domains_filter:
        bindings = [b for b in bindings if b.domain in domains_filter]

    jobs = []
    for binding in bindings:
        pkg = db.query(Package).filter(Package.domain_name == binding.domain).filter(Package.site_copy.isnot(None)).first()
        if not pkg:
            jobs.append({"domain": binding.domain, "job_id": None, "skipped": True, "reason": "No package"})
            continue
        job_id = str(uuid.uuid4())[:8]
        create_job(job_id, "ftp_deploy", binding.domain, len(FTP_DEPLOY_STEPS), FTP_DEPLOY_STEPS)
        job_executor.submit(run_ftp_deploy_job, job_id, binding.domain, binding.ftp_profile_id, binding.target_directory)
        jobs.append({"domain": binding.domain, "job_id": job_id, "skipped": False})

    return {"jobs": jobs, "total": len(jobs), "skipped": sum(1 for j in jobs if j.get("skipped"))}


@app.post("/api/ftp/bind-all-unbound")
async def api_ftp_bind_all_unbound(request: Request, db: Session = Depends(get_db)):
    """Automatically bind every domain with a package (no existing binding) to the default FTP profile."""
    username = request.session.get("username")
    if not username:
        raise HTTPException(status_code=401)

    default_profile = db.query(FtpProfile).filter(FtpProfile.is_default == True).first()
    if not default_profile:
        raise HTTPException(status_code=400, detail="No default FTP profile configured. Set one in Server Profiles first.")

    data = {}
    try:
        data = await request.json()
    except Exception:
        pass
    target_base = data.get("base_path", "/sites")

    packages = db.query(Package).filter(Package.site_copy.isnot(None)).all()
    existing_bound = {b.domain for b in db.query(FtpProjectBinding).all()}

    bound = []
    skipped = []
    for pkg in packages:
        domain = pkg.domain_name
        if domain in existing_bound:
            skipped.append({"domain": domain, "reason": "already_bound"})
            continue
        target_dir = f"{target_base.rstrip('/')}/{domain}"
        binding = FtpProjectBinding(
            domain=domain,
            ftp_profile_id=default_profile.id,
            target_directory=target_dir,
            is_default=True,
        )
        db.add(binding)
        bound.append({"domain": domain, "target_directory": target_dir})

    db.commit()
    return {
        "bound": bound,
        "skipped": skipped,
        "profile_used": default_profile.label,
        "total_bound": len(bound),
        "total_skipped": len(skipped),
    }


@app.get("/api/ftp/deployments/{domain}")
async def api_deployment_history(domain: str, db: Session = Depends(get_db)):
    logs = db.query(DeploymentLog).filter(DeploymentLog.domain == domain).order_by(DeploymentLog.created_at.desc()).limit(20).all()
    return {"deployments": [l.to_dict() for l in logs]}


ADVISOR_CHAT_SYSTEM = """You are Aura, an expert AI business strategist, brand consultant, and domain monetization advisor. You have deep knowledge of niche markets, affiliate programs, website development, SEO, and digital business strategy.

You are having a conversation with the user who owns or is developing a domain-based business. Be direct, insightful, and actionable. Use the project context provided to give highly specific advice rather than generic suggestions.

Key behaviors:
- Reference specific data from the user's brand kit, package, and domain analysis when available
- Suggest concrete next steps rather than vague recommendations
- When discussing their website, reference their actual sections and content
- Be concise but information-dense — the user values density over padding
- If you notice gaps in their business package, proactively mention them
- When relevant, suggest how to leverage their existing assets better"""


def build_chat_context(domain: str, source_page: str, db: Session) -> str:
    parts = []
    if source_page:
        page_labels = {
            "dashboard": "Main Dashboard — viewing all domains and packages",
            "editor": f"Package Editor — actively editing {domain or 'a domain'}",
            "advisor": "Advisor Workbench — managing concepts and plans",
            "deploy": "Deployment Manager — publishing sites",
            "sop": "SOP Documentation — reviewing procedures",
            "roadmap": "Roadmap — planning features",
            "tasks": "Task Tracker — managing build tasks",
            "prompts": "Prompt Editor — customizing AI prompts",
            "architecture": "Architecture Diagrams — reviewing system design",
        }
        label = page_labels.get(source_page, source_page)
        parts.append(f"[PAGE CONTEXT] User is currently on: {label}")

    if domain:
        from app.services.context_engine import get_full_context_for_domain
        ctx = get_full_context_for_domain(domain, db)
        if ctx:
            parts.append(ctx)

        pkg = db.query(Package).filter(Package.domain == domain).first()
        if pkg:
            pkg_info = []
            if pkg.chosen_niche:
                pkg_info.append(f"Chosen niche: {pkg.chosen_niche}")
            if pkg.brand_identity:
                bi = pkg.brand_identity
                if isinstance(bi, dict):
                    pkg_info.append(f"Brand: {bi.get('brand_name', 'N/A')} — {bi.get('tagline', 'N/A')}")
            if pkg.site_content:
                sc = pkg.site_content
                sections = sc.get("sections", []) if isinstance(sc, dict) else []
                pkg_info.append(f"Site sections: {len(sections)} generated")
            has_hero = bool(pkg.hero_image_url)
            has_sales = bool(pkg.sales_letter)
            pkg_info.append(f"Hero image: {'yes' if has_hero else 'missing'}")
            pkg_info.append(f"Sales letter: {'yes' if has_sales else 'missing'}")
            if pkg_info:
                parts.append("[PACKAGE STATE]\n" + "\n".join(pkg_info))

        dom = db.query(Domain).filter(Domain.domain == domain).first()
        if dom and dom.analysis:
            analysis = dom.analysis
            if isinstance(analysis, dict):
                niches = analysis.get("niches", [])
                if niches:
                    niche_names = [n.get("name", "?") for n in niches[:5]] if isinstance(niches, list) else []
                    parts.append(f"[DOMAIN ANALYSIS] Top niches identified: {', '.join(niche_names)}")
    else:
        domains = db.query(Domain).all()
        if domains:
            domain_list = [d.domain for d in domains[:10]]
            parts.append(f"[WORKSPACE] Domains in workspace: {', '.join(domain_list)}")

    return "\n\n".join(parts) if parts else ""


@app.get("/advisor/chat", response_class=HTMLResponse)
async def advisor_chat_page(request: Request, db: Session = Depends(get_db)):
    domain = request.query_params.get("domain", "")
    page = request.query_params.get("page", "")
    conversations = db.query(ChatConversation).order_by(ChatConversation.updated_at.desc()).limit(50).all()
    domains = db.query(Domain).order_by(Domain.domain).all()
    return templates.TemplateResponse("advisor_chat.html", {
        "request": request,
        "conversations": conversations,
        "domains": domains,
        "initial_domain": domain,
        "initial_page": page,
        "current_page": "advisor_chat", "current_domain": domain,
    })


@app.post("/api/advisor/chat/conversations")
async def api_create_chat(request: Request, db: Session = Depends(get_db)):
    data = await request.json()
    domain = data.get("domain", "").strip() or None
    source_page = data.get("source_page", "").strip() or None
    title = data.get("title", "").strip() or "New Chat"
    conv = ChatConversation(title=title, domain=domain, source_page=source_page)
    db.add(conv)
    db.commit()
    db.refresh(conv)
    return conv.to_dict(include_messages=True)


@app.get("/api/advisor/chat/conversations")
async def api_list_chats(request: Request, db: Session = Depends(get_db)):
    q = db.query(ChatConversation).order_by(ChatConversation.updated_at.desc())
    domain = request.query_params.get("domain")
    if domain:
        q = q.filter(ChatConversation.domain == domain)
    convos = q.limit(50).all()
    return {"conversations": [c.to_dict() for c in convos]}


@app.get("/api/advisor/chat/conversations/{conv_id}")
async def api_get_chat(conv_id: int, db: Session = Depends(get_db)):
    conv = db.query(ChatConversation).filter(ChatConversation.id == conv_id).first()
    if not conv:
        raise HTTPException(status_code=404, detail="Conversation not found")
    return conv.to_dict(include_messages=True)


@app.delete("/api/advisor/chat/conversations/{conv_id}")
async def api_delete_chat(conv_id: int, db: Session = Depends(get_db)):
    conv = db.query(ChatConversation).filter(ChatConversation.id == conv_id).first()
    if not conv:
        raise HTTPException(status_code=404, detail="Conversation not found")
    db.delete(conv)
    db.commit()
    return {"ok": True}


@app.post("/api/advisor/chat/conversations/{conv_id}/messages")
async def api_send_chat_message(conv_id: int, request: Request, db: Session = Depends(get_db)):
    conv = db.query(ChatConversation).filter(ChatConversation.id == conv_id).first()
    if not conv:
        raise HTTPException(status_code=404, detail="Conversation not found")

    data = await request.json()
    user_content = data.get("content", "").strip()
    if not user_content:
        raise HTTPException(status_code=400, detail="Message content required")
    chat_attachments = data.get("attachments", [])

    domain_override = data.get("domain")
    if domain_override and domain_override != conv.domain:
        conv.domain = domain_override

    source_page = data.get("source_page") or conv.source_page or ""

    attachment_text_parts = []
    for att in chat_attachments:
        if att.get("type") == "document" and att.get("text_content"):
            attachment_text_parts.append(f"[Attached: {att.get('name', 'file')}]\n{att['text_content'][:20000]}")
        elif att.get("type") == "image":
            attachment_text_parts.append(f"[Attached Image: {att.get('name', 'image')}]")
    display_content = user_content
    if attachment_text_parts:
        display_content = user_content + "\n\n" + "\n".join(attachment_text_parts)

    context_block = build_chat_context(conv.domain or "", source_page, db)
    if len(context_block) > 12000:
        context_block = context_block[:12000] + "\n[...context truncated for length]"

    user_msg = ChatMessage(conversation_id=conv.id, role="user", content=display_content)
    db.add(user_msg)
    db.commit()

    history = db.query(ChatMessage).filter(
        ChatMessage.conversation_id == conv.id
    ).order_by(ChatMessage.created_at).all()

    has_vision = any(a.get("type") == "image" and a.get("base64") for a in chat_attachments)

    messages = [{"role": "system", "content": ADVISOR_CHAT_SYSTEM}]
    if context_block:
        messages.append({"role": "system", "content": f"[PROJECT CONTEXT]\n{context_block}"})

    for msg in history[:-1]:
        messages.append({"role": msg.role, "content": msg.content})

    if has_vision:
        user_multimodal = []
        for att in chat_attachments:
            if att.get("type") == "image" and att.get("base64"):
                mime = att.get("mime", "image/png")
                user_multimodal.append({
                    "type": "image_url",
                    "image_url": {"url": f"data:{mime};base64,{att['base64']}", "detail": "auto"}
                })
        user_multimodal.append({"type": "text", "text": display_content})
        messages.append({"role": "user", "content": user_multimodal})
    else:
        messages.append({"role": "user", "content": display_content})

    if len(messages) > 22:
        system_msgs = [m for m in messages if m["role"] == "system"]
        chat_msgs = [m for m in messages if m["role"] != "system"]
        messages = system_msgs + chat_msgs[-20:]

    conv_id_local = conv.id
    conv_domain = conv.domain
    history_len = len(history)
    use_vision_model = has_vision

    async def event_generator():
        full_response = ""
        try:
            yield {"event": "status", "data": json.dumps({"status": "context_built"})}
            yield {"event": "status", "data": json.dumps({"status": "thinking"})}

            if use_vision_model:
                from openai import OpenAI as _VisionOAI
                _vclient = _VisionOAI()
                stream = await asyncio.to_thread(
                    lambda: _vclient.chat.completions.create(
                        model="gpt-4o", messages=messages,
                        max_completion_tokens=4096, stream=True
                    )
                )
            else:
                stream = await asyncio.to_thread(call_llm_stream_routed, "advisor_chat", messages, 4096)

            yield {"event": "status", "data": json.dumps({"status": "streaming"})}

            while True:
                chunk = await asyncio.to_thread(next, stream, None)
                if chunk is None:
                    break
                if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content:
                    token = chunk.choices[0].delta.content
                    full_response += token
                    yield {"event": "token", "data": json.dumps({"token": token})}
        except Exception as e:
            logger.error(f"Chat stream error: {e}")
            error_msg = "I encountered an issue processing your request. Please try again."
            full_response = error_msg
            yield {"event": "error", "data": json.dumps({"error": str(e)[:200]})}
            yield {"event": "token", "data": json.dumps({"token": error_msg})}

        def save_response():
            with SessionLocal() as save_db:
                assistant_msg = ChatMessage(conversation_id=conv_id_local, role="assistant", content=full_response, context_snapshot={"domain": conv_domain, "page": source_page})
                save_db.add(assistant_msg)
                c = save_db.query(ChatConversation).filter(ChatConversation.id == conv_id_local).first()
                if c:
                    c.updated_at = datetime.datetime.utcnow()
                    if c.title == "New Chat" and history_len <= 1:
                        c.title = user_content[:80] + ("..." if len(user_content) > 80 else "")
                save_db.commit()
        await asyncio.to_thread(save_response)

        yield {"event": "done", "data": json.dumps({"message_id": "saved"})}

    return EventSourceResponse(event_generator())


@app.get("/maximization/{domain}", response_class=HTMLResponse)
async def maximization_page(request: Request, domain: str, db: Session = Depends(get_db)):
    try:
        pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
        if not pkg:
            raise HTTPException(status_code=404, detail="Package not found for this domain")

        business_box = pkg.business_box or {}
        score_data = calculate_maximization_score(business_box)

        return templates.TemplateResponse("maximization.html", {
            "request": request,
            "package": pkg,
            "domain": domain,
            "score_data": score_data,
            "doc_registry": DOC_REGISTRY,
            "tiers": TIERS,
            "current_page": "maximization",
            "current_domain": domain,
            "business_box": business_box,
        })
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Error loading maximization page for {domain}: {e}")
        raise HTTPException(status_code=500, detail=str(e))


PRIORITY_PACK_DOCS = [
    "business_plan", "legal_nda", "legal_privacy_policy",
    "legal_terms_of_service", "contract_service", "sop_customer_onboarding",
    "marketing_email_campaign", "calculator_pricing",
]


def _run_build_missing_components(job_id: str, domain: str, requested_components: list = None):
    from app.services.marketplace import score_package_completeness, PACKAGE_COMPONENTS
    from app.services.llm import call_llm_routed
    from sqlalchemy.orm.attributes import flag_modified
    db = SessionLocal()
    try:
        update_job(job_id, status="running", current_step="Analyzing missing components...", current_step_key="init")
        pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
        if not pkg:
            update_job(job_id, status="failed", error="Package not found")
            return

        pkg_data = _extract_pkg_data(pkg)
        completeness = score_package_completeness(pkg_data)
        missing_ids = [m["id"] for m in completeness["missing"]]

        generatable = ["theme_customization", "force_multiplier_docs", "market_research",
                       "competitor_analysis", "seo_strategy", "ad_copy_suite", "email_sequences"]
        skip_reason = {
            "domain_analysis": "requires package regeneration",
            "brand_identity": "requires package regeneration",
            "basic_site_copy": "requires package regeneration",
            "full_site_copy": "requires package regeneration",
            "hero_images": "requires package regeneration",
            "sales_letter": "requires package regeneration",
            "graphics_pack": "requires graphics pack generation",
            "deployment_ready": "requires FTP credentials",
        }

        if requested_components:
            to_build = [c for c in requested_components if c in missing_ids and c in generatable]
        else:
            to_build = [c for c in missing_ids if c in generatable]

        skipped = [{"id": c, "label": PACKAGE_COMPONENTS.get(c, {}).get("label", c),
                     "reason": skip_reason.get(c, "not auto-generatable")}
                    for c in missing_ids if c not in generatable]

        if not to_build:
            update_job(job_id, status="completed", current_step="Nothing to auto-generate",
                      progress_pct=100, steps_completed=0,
                      result={"built": [], "skipped": skipped, "message": "All generatable components already present"})
            return

        total = len(to_build)
        update_job(job_id, total_steps=total, current_step=f"Building {total} components...")
        niche = pkg.chosen_niche or "general"
        brand = pkg.brand or {}
        atmosphere = pkg.atmosphere or {}
        built = []

        for idx, comp_id in enumerate(to_build):
            label = PACKAGE_COMPONENTS.get(comp_id, {}).get("label", comp_id)
            update_job(job_id, current_step=f"Generating {label}... ({idx+1}/{total})",
                      current_step_key=comp_id, steps_completed=idx,
                      progress_pct=int(((idx) / total) * 100) if total > 1 else 50)
            try:
                if comp_id == "theme_customization":
                    from app.services.theme import generate_theme_config, generate_theme_css
                    brand_identity = {}
                    if isinstance(brand, dict):
                        options = brand.get("options", [])
                        rec_idx = brand.get("recommended_idx", 0)
                        if options and len(options) > rec_idx:
                            brand_identity = options[rec_idx] if isinstance(options[rec_idx], dict) else {}
                    colors = brand_identity.get("brand_colors") or brand_identity.get("colors") or {}
                    primary = colors.get("primary", "#4F46E5") if isinstance(colors, dict) else "#4F46E5"
                    secondary = colors.get("secondary", "#7C3AED") if isinstance(colors, dict) else "#7C3AED"
                    accent = colors.get("accent", "#06B6D4") if isinstance(colors, dict) else "#06B6D4"
                    tone = brand_identity.get("tone", "professional")
                    theme = generate_theme_config(primary=primary, secondary=secondary, accent=accent,
                                                  niche=niche, brand_tone=tone, brand_data=brand)
                    theme_css = generate_theme_css(theme)
                    atmosphere["theme"] = theme
                    atmosphere["theme_css"] = theme_css
                    pkg.atmosphere = atmosphere
                    flag_modified(pkg, "atmosphere")
                    db.commit()
                    built.append(comp_id)

                elif comp_id == "force_multiplier_docs":
                    sub_job_id = str(uuid.uuid4())
                    now = datetime.datetime.utcnow()
                    sub_job = Job(
                        job_id=sub_job_id, job_type="business_doc", domain=domain,
                        status="queued", current_step="Queued...", current_step_key="init",
                        steps_completed=0, total_steps=1, progress_pct=0, steps_detail=[],
                        started_at=now, created_at=now, updated_at=now,
                    )
                    db.add(sub_job)
                    db.commit()
                    _run_business_doc_generation(sub_job_id, domain, all_missing=True)
                    db.refresh(pkg)
                    built.append(comp_id)

                elif comp_id == "market_research":
                    import asyncio
                    from app.services.marketplace import run_market_research
                    loop = asyncio.new_event_loop()
                    asyncio.set_event_loop(loop)
                    try:
                        research = loop.run_until_complete(run_market_research(domain, niche))
                    finally:
                        loop.close()
                    result_json = json.loads(pkg.result_json) if pkg.result_json else {}
                    result_json["market_research"] = research
                    pkg.result_json = json.dumps(result_json)
                    flag_modified(pkg, "result_json")
                    db.commit()
                    built.append(comp_id)

                else:
                    prompts = {
                        "competitor_analysis": f"Analyze the competitive landscape for a '{niche}' business using the domain {domain}. Identify 5-8 key competitors, their strengths/weaknesses, market positioning, pricing strategies, and differentiation opportunities. Return as JSON with keys: competitors (array), market_gaps, positioning_recommendations, threat_level.",
                        "seo_strategy": f"Create a comprehensive SEO strategy for a '{niche}' business at {domain}. Include: primary_keywords (15-20), long_tail_keywords (20-30), content_pillars (5-7 topic clusters), on_page_recommendations, link_building_strategy, local_seo_tips, technical_seo_checklist. Return as JSON.",
                        "ad_copy_suite": f"Generate a complete advertising copy suite for a '{niche}' business ({domain}). Include: google_ads (5 responsive search ads with headlines and descriptions), facebook_ads (3 ad sets with primary text, headline, description), instagram_captions (5 posts), linkedin_ads (2 sponsored content pieces), email_subject_lines (10 options). Return as JSON.",
                        "email_sequences": f"Create email marketing sequences for a '{niche}' business ({domain}). Include: welcome_series (5 emails), nurture_sequence (7 emails), sales_sequence (5 emails), re_engagement (3 emails). Each email should have subject, preview_text, body_outline, cta, send_timing. Return as JSON.",
                    }
                    prompt = prompts.get(comp_id, "")
                    if prompt:
                        system_prompt = "You are an expert digital marketing strategist. Provide detailed, actionable, data-driven content. Always respond with valid JSON."
                        stage = "market_research" if comp_id in ("competitor_analysis", "market_research") else "content_generation"
                        raw = call_llm_routed(stage, prompt, system_prompt=system_prompt, max_tokens=4096)
                        try:
                            parsed = json.loads(raw)
                        except json.JSONDecodeError:
                            parsed = {"raw_content": raw, "generated_at": datetime.datetime.utcnow().isoformat()}
                        result_json = json.loads(pkg.result_json) if pkg.result_json else {}
                        result_json[comp_id] = parsed
                        pkg.result_json = json.dumps(result_json)
                        flag_modified(pkg, "result_json")
                        db.commit()
                        built.append(comp_id)

            except Exception as e:
                logger.error(f"Failed to build {comp_id} for {domain}: {e}")

        update_job(job_id, status="completed",
                  current_step=f"Built {len(built)}/{total} components",
                  progress_pct=100, steps_completed=total,
                  result={"built": built, "skipped": skipped,
                          "built_count": len(built), "total_attempted": total})
    except Exception as e:
        logger.error(f"Build missing components failed for {domain}: {e}")
        update_job(job_id, status="failed", error=str(e), current_step=f"Error: {str(e)}")
    finally:
        db.close()


def _run_business_doc_generation(job_id: str, domain: str, doc_type: str = None, tier: str = None, all_missing: bool = False, priority_pack: bool = False):
    db = SessionLocal()
    try:
        update_job(job_id, status="running", current_step="Loading package data...", current_step_key="init")

        pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
        if not pkg:
            update_job(job_id, status="failed", error="Package not found")
            return

        brand_data = pkg.brand or {}
        niche = pkg.chosen_niche
        discovery = pkg.discovery_answers or {}
        business_box = pkg.business_box or {}
        documents = business_box.get("documents", {})
        total = 1
        generated_count = 0

        if doc_type:
            update_job(job_id, total_steps=1, current_step=f"Generating {DOC_REGISTRY[doc_type]['label']}...")
            result = generate_document(doc_type, domain, niche, brand_data, discovery)
            documents[doc_type] = result
            generated_count = 1
            update_job(job_id, steps_completed=1, progress_pct=100)
        elif tier:
            tier_docs = get_tier_doc_types(tier)
            missing_in_tier = [dt for dt in tier_docs if dt["key"] not in documents]
            total = len(missing_in_tier)
            if total == 0:
                update_job(job_id, status="completed", current_step="All documents in this tier already generated!", progress_pct=100, steps_completed=0, result={"score": calculate_maximization_score(business_box)})
                return
            update_job(job_id, total_steps=total)

            for idx, dt in enumerate(missing_in_tier):
                dk = dt["key"]
                update_job(job_id, current_step=f"Generating {dt['label']}... ({idx+1}/{total})",
                          steps_completed=idx, progress_pct=int((idx / total) * 100) if total > 0 else 0)
                try:
                    result = generate_document(dk, domain, niche, brand_data, discovery)
                    documents[dk] = result
                    generated_count += 1
                    business_box["documents"] = documents
                    business_box["updated_at"] = datetime.datetime.utcnow().isoformat()
                    pkg.business_box = business_box
                    from sqlalchemy.orm.attributes import flag_modified
                    flag_modified(pkg, "business_box")
                    db.commit()
                except Exception as e:
                    logger.error(f"Failed to generate {dk}: {e}")
        elif priority_pack:
            pack_keys = [k for k in PRIORITY_PACK_DOCS if k not in documents and k in DOC_REGISTRY]
            total = len(pack_keys)
            if total == 0:
                update_job(job_id, status="completed", current_step="Priority pack already complete!", progress_pct=100, steps_completed=0, result={"score": calculate_maximization_score(business_box)})
                return
            update_job(job_id, total_steps=total)

            for idx, dk in enumerate(pack_keys):
                meta = DOC_REGISTRY[dk]
                update_job(job_id, current_step=f"Generating {meta['label']}... ({idx+1}/{total})",
                          steps_completed=idx, progress_pct=int((idx / total) * 100) if total > 0 else 0)
                try:
                    result = generate_document(dk, domain, niche, brand_data, discovery)
                    documents[dk] = result
                    generated_count += 1
                    business_box["documents"] = documents
                    business_box["updated_at"] = datetime.datetime.utcnow().isoformat()
                    pkg.business_box = business_box
                    from sqlalchemy.orm.attributes import flag_modified
                    flag_modified(pkg, "business_box")
                    db.commit()
                except Exception as e:
                    logger.error(f"Failed to generate {dk}: {e}")
        elif all_missing:
            all_doc_keys = [k for k in DOC_REGISTRY.keys() if k not in documents]
            total = len(all_doc_keys)
            if total == 0:
                update_job(job_id, status="completed", current_step="All documents already generated!", progress_pct=100, steps_completed=0, result={"score": calculate_maximization_score(business_box)})
                return
            update_job(job_id, total_steps=total)

            for idx, dk in enumerate(all_doc_keys):
                meta = DOC_REGISTRY[dk]
                update_job(job_id, current_step=f"Generating {meta['label']}... ({idx+1}/{total})",
                          steps_completed=idx, progress_pct=int((idx / total) * 100) if total > 0 else 0)
                try:
                    result = generate_document(dk, domain, niche, brand_data, discovery)
                    documents[dk] = result
                    generated_count += 1
                    business_box["documents"] = documents
                    business_box["updated_at"] = datetime.datetime.utcnow().isoformat()
                    pkg.business_box = business_box
                    from sqlalchemy.orm.attributes import flag_modified
                    flag_modified(pkg, "business_box")
                    db.commit()
                except Exception as e:
                    logger.error(f"Failed to generate {dk}: {e}")

        business_box["documents"] = documents
        business_box["updated_at"] = datetime.datetime.utcnow().isoformat()
        pkg.business_box = business_box
        from sqlalchemy.orm.attributes import flag_modified
        flag_modified(pkg, "business_box")
        db.commit()

        score = calculate_maximization_score(business_box)
        update_job(job_id, status="completed", current_step="Generation complete!",
                  progress_pct=100, steps_completed=generated_count if generated_count > 0 else total,
                  result={"score": score})
    except Exception as e:
        logger.error(f"Business doc generation failed: {e}")
        update_job(job_id, status="failed", error=str(e), current_step=f"Error: {str(e)}")
    finally:
        db.close()


@app.post("/api/business-box/{domain}/generate")
async def api_generate_business_doc(domain: str, request: Request, db: Session = Depends(get_db)):
    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid JSON body")

    doc_type = body.get("doc_type")
    tier = body.get("tier")
    all_missing = body.get("all_missing", False)
    priority_pack = body.get("priority_pack", False)

    if not doc_type and not tier and not all_missing and not priority_pack:
        raise HTTPException(status_code=400, detail="Must provide doc_type, tier, all_missing, or priority_pack")

    if doc_type and doc_type not in DOC_REGISTRY:
        raise HTTPException(status_code=400, detail=f"Unknown doc_type: {doc_type}")

    if tier and tier not in TIERS:
        raise HTTPException(status_code=400, detail=f"Unknown tier: {tier}")

    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found for this domain")

    job_id = str(uuid.uuid4())
    now = datetime.datetime.utcnow()
    job = Job(
        job_id=job_id,
        job_type="business_doc",
        domain=domain,
        status="queued",
        current_step="Queued...",
        current_step_key="init",
        steps_completed=0,
        total_steps=1,
        progress_pct=0,
        steps_detail=[],
        started_at=now,
        created_at=now,
        updated_at=now,
    )
    db.add(job)
    db.commit()

    threading.Thread(
        target=_run_business_doc_generation,
        args=(job_id, domain),
        kwargs={"doc_type": doc_type, "tier": tier, "all_missing": all_missing, "priority_pack": priority_pack},
        daemon=True,
    ).start()

    return JSONResponse({"job_id": job_id, "status": "queued"})


@app.get("/api/business-box/{domain}/score")
async def api_business_box_score(domain: str, db: Session = Depends(get_db)):
    db.expire_all()
    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found for this domain")

    score_data = calculate_maximization_score(pkg.business_box)
    return JSONResponse(score_data)


@app.get("/api/business-box/{domain}/document/{doc_type}")
async def api_get_business_doc(domain: str, doc_type: str, db: Session = Depends(get_db)):
    import asyncio
    for attempt in range(3):
        db.expire_all()
        pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
        if not pkg:
            raise HTTPException(status_code=404, detail="Package not found for this domain")

        business_box = pkg.business_box or {}
        documents = business_box.get("documents", {})

        if doc_type in documents:
            return JSONResponse(documents[doc_type])

        if attempt < 2:
            await asyncio.sleep(0.5)

    raise HTTPException(status_code=404, detail=f"Document '{doc_type}' not found")


@app.delete("/api/business-box/{domain}/document/{doc_type}")
async def api_delete_business_doc(domain: str, doc_type: str, db: Session = Depends(get_db)):
    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found for this domain")

    business_box = pkg.business_box or {}
    documents = business_box.get("documents", {})

    if doc_type not in documents:
        raise HTTPException(status_code=404, detail=f"Document '{doc_type}' not found")

    del documents[doc_type]
    business_box["documents"] = documents
    business_box["updated_at"] = datetime.datetime.utcnow().isoformat()
    pkg.business_box = business_box
    from sqlalchemy.orm.attributes import flag_modified
    flag_modified(pkg, "business_box")
    db.commit()

    score = calculate_maximization_score(business_box)
    return JSONResponse({"success": True, "deleted": doc_type, "score": score})


# ═══════════════════════════════════════════════════════════════════════════════
# SUPERADMIN LLM CONSOLE — Protected by hardcoded dev password
# ═══════════════════════════════════════════════════════════════════════════════

import hashlib
import hmac
import time as _time

SUPERADMIN_PASSWORD_HASH = hashlib.sha256(b"AuraEngine2026!SuperAdmin").hexdigest()
_superadmin_tokens = {}


def _verify_superadmin(request: Request):
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Unauthorized")
    token = auth[7:]
    if token not in _superadmin_tokens:
        raise HTTPException(status_code=401, detail="Invalid or expired token")
    if _time.time() - _superadmin_tokens[token] > 86400:
        del _superadmin_tokens[token]
        raise HTTPException(status_code=401, detail="Session expired")
    return True


@app.get("/superadmin/llm", response_class=HTMLResponse)
@app.get("/_dev_admin/{token}/superadmin-llm", response_class=HTMLResponse)
async def superadmin_llm_page(request: Request, token: str = None):
    if token and token != "N5G4K8fWLY9MrapEkZnw_g":
        raise HTTPException(status_code=403, detail="Forbidden")
    return templates.TemplateResponse("superadmin_llm.html", {"request": request})


@app.post("/api/superadmin/auth")
async def superadmin_auth(request: Request):
    data = await request.json()
    pw = data.get("password", "")
    pw_hash = hashlib.sha256(pw.encode("utf-8")).hexdigest()
    if not hmac.compare_digest(pw_hash, SUPERADMIN_PASSWORD_HASH):
        raise HTTPException(status_code=401, detail="Invalid password")
    token = uuid.uuid4().hex
    _superadmin_tokens[token] = _time.time()
    return JSONResponse({"token": token, "message": "Authenticated"})


@app.get("/api/superadmin/llm/providers")
async def superadmin_list_providers(request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    providers = db.query(LlmProvider).order_by(LlmProvider.display_order, LlmProvider.name).all()
    return JSONResponse([p.to_dict(include_creds=True) for p in providers])


@app.post("/api/superadmin/llm/providers")
async def superadmin_create_provider(request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    data = await request.json()
    existing = db.query(LlmProvider).filter(LlmProvider.slug == data.get("slug")).first()
    if existing:
        raise HTTPException(status_code=409, detail=f"Provider with slug '{data['slug']}' already exists")
    provider = LlmProvider(
        slug=data["slug"],
        name=data["name"],
        provider_type=data.get("provider_type", "cloud"),
        base_url=data.get("base_url"),
        sdk_type=data.get("sdk_type", "openai"),
        supports_json=data.get("supports_json", True),
        supports_vision=data.get("supports_vision", False),
        supports_image_gen=data.get("supports_image_gen", False),
        supports_streaming=data.get("supports_streaming", True),
        is_active=data.get("is_active", True),
        color=data.get("color", "#6366F1"),
        icon=data.get("icon"),
        display_order=data.get("display_order", 0),
        meta_json=data.get("meta_json"),
    )
    db.add(provider)
    db.commit()
    db.refresh(provider)
    return JSONResponse(provider.to_dict())


@app.put("/api/superadmin/llm/providers/{provider_id}")
async def superadmin_update_provider(provider_id: int, request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    provider = db.query(LlmProvider).filter(LlmProvider.id == provider_id).first()
    if not provider:
        raise HTTPException(status_code=404, detail="Provider not found")
    data = await request.json()
    for field in ["name", "provider_type", "base_url", "sdk_type", "supports_json", "supports_vision",
                  "supports_image_gen", "supports_streaming", "is_active", "color", "icon", "display_order", "meta_json"]:
        if field in data:
            setattr(provider, field, data[field])
    db.commit()
    db.refresh(provider)
    return JSONResponse(provider.to_dict(include_creds=True))


@app.delete("/api/superadmin/llm/providers/{provider_id}")
async def superadmin_delete_provider(provider_id: int, request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    provider = db.query(LlmProvider).filter(LlmProvider.id == provider_id).first()
    if not provider:
        raise HTTPException(status_code=404, detail="Provider not found")
    db.delete(provider)
    db.commit()
    return JSONResponse({"success": True, "deleted": provider_id})


@app.post("/api/superadmin/llm/credentials")
async def superadmin_create_credential(request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    data = await request.json()
    if "provider_id" not in data or "api_key" not in data:
        return JSONResponse({"error": "Missing required fields: provider_id, api_key"}, status_code=400)
    provider = db.query(LlmProvider).filter(LlmProvider.id == data["provider_id"]).first()
    if not provider:
        raise HTTPException(status_code=404, detail="Provider not found")
    encrypted = encrypt_password(data["api_key"])
    if data.get("is_default", True):
        db.query(LlmCredential).filter(
            LlmCredential.provider_id == data["provider_id"],
            LlmCredential.is_default == True
        ).update({"is_default": False})
    cred = LlmCredential(
        provider_id=data["provider_id"],
        label=data.get("label", "Default"),
        encrypted_api_key=encrypted,
        is_default=data.get("is_default", True),
    )
    db.add(cred)
    db.commit()
    db.refresh(cred)
    return JSONResponse(cred.to_dict())


@app.delete("/api/superadmin/llm/credentials/{cred_id}")
async def superadmin_delete_credential(cred_id: int, request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    cred = db.query(LlmCredential).filter(LlmCredential.id == cred_id).first()
    if not cred:
        raise HTTPException(status_code=404, detail="Credential not found")
    db.delete(cred)
    db.commit()
    return JSONResponse({"success": True, "deleted": cred_id})


@app.post("/api/superadmin/llm/credentials/{cred_id}/test")
async def superadmin_test_credential(cred_id: int, request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    cred = db.query(LlmCredential).filter(LlmCredential.id == cred_id).first()
    if not cred:
        raise HTTPException(status_code=404, detail="Credential not found")
    provider = cred.provider
    api_key = decrypt_password(cred.encrypted_api_key)
    start = _time.time()
    try:
        if provider.sdk_type == "google_genai":
            from google import genai
            http_opts = {}
            if provider.base_url:
                http_opts = {'api_version': '', 'base_url': provider.base_url}
            gc = genai.Client(api_key=api_key, http_options=http_opts)
            response = gc.models.generate_content(
                model="gemini-2.5-flash",
                contents="Say 'OK' in one word.",
            )
            result_text = response.text[:100] if response.text else "OK"
        else:
            base_url = provider.base_url
            if not base_url and provider.slug == "perplexity":
                base_url = "https://api.perplexity.ai"
            elif not base_url and provider.slug == "huggingface":
                base_url = "https://api-inference.huggingface.co/v1"
            elif not base_url and provider.slug == "mistral":
                base_url = "https://api.mistral.ai/v1"
            elif not base_url and provider.slug == "openrouter":
                base_url = "https://openrouter.ai/api/v1"
            test_client = OpenAI(api_key=api_key, base_url=base_url)
            test_model = "gpt-4o-mini"
            active_models = [m for m in provider.models if m.is_active]
            if active_models:
                cheapest = sorted(active_models, key=lambda m: m.input_cost_per_mtok or 999)
                test_model = cheapest[0].model_id
            resp = test_client.chat.completions.create(
                model=test_model,
                messages=[{"role": "user", "content": "Say 'OK' in one word."}],
                max_tokens=5,
            )
            result_text = resp.choices[0].message.content[:100] if resp.choices else "OK"
        latency = int((_time.time() - start) * 1000)
        cred.last_tested_at = datetime.datetime.utcnow()
        cred.last_test_status = "success"
        cred.last_test_latency_ms = latency
        db.commit()
        return JSONResponse({"success": True, "status": "success", "latency_ms": latency, "response": result_text})
    except Exception as e:
        latency = int((_time.time() - start) * 1000)
        cred.last_tested_at = datetime.datetime.utcnow()
        cred.last_test_status = "error"
        cred.last_test_latency_ms = latency
        db.commit()
        return JSONResponse({"success": False, "status": "error", "latency_ms": latency, "error": str(e)[:500]})


@app.post("/api/superadmin/llm/models")
async def superadmin_create_model(request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    data = await request.json()
    provider = db.query(LlmProvider).filter(LlmProvider.id == data["provider_id"]).first()
    if not provider:
        raise HTTPException(status_code=404, detail="Provider not found")
    model = LlmModel(
        provider_id=data["provider_id"],
        model_id=data["model_id"],
        display_name=data.get("display_name", data["model_id"]),
        mode=data.get("mode", "text"),
        quality_tier=data.get("quality_tier", "premium"),
        context_window=data.get("context_window"),
        input_cost_per_mtok=data.get("input_cost_per_mtok"),
        output_cost_per_mtok=data.get("output_cost_per_mtok"),
        is_active=data.get("is_active", True),
        meta_json=data.get("meta_json"),
    )
    db.add(model)
    db.commit()
    db.refresh(model)
    return JSONResponse(model.to_dict())


@app.delete("/api/superadmin/llm/models/{model_db_id}")
async def superadmin_delete_model(model_db_id: int, request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    model = db.query(LlmModel).filter(LlmModel.id == model_db_id).first()
    if not model:
        raise HTTPException(status_code=404, detail="Model not found")
    db.delete(model)
    db.commit()
    return JSONResponse({"success": True, "deleted": model_db_id})


@app.get("/api/superadmin/llm/routes")
async def superadmin_list_routes(request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    routes = db.query(LlmRoute).order_by(LlmRoute.stage_key, LlmRoute.priority).all()
    stages_info = {}
    for sk, sv in PIPELINE_STAGES.items():
        stages_info[sk] = {
            "name": sv["name"],
            "description": sv["description"],
            "mode": sv["mode"],
            "quality_tier": sv["quality_tier"],
            "default_provider": sv["default_provider"],
            "default_model": sv["default_model"],
        }
    return JSONResponse({
        "routes": [r.to_dict() for r in routes],
        "stages": stages_info,
    })


@app.post("/api/superadmin/llm/routes")
async def superadmin_create_route(request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    data = await request.json()
    for field in ("stage_key", "provider_id", "model_id"):
        if field not in data or data[field] is None:
            return JSONResponse({"error": f"Missing required field: {field}"}, status_code=400)
    existing = db.query(LlmRoute).filter(
        LlmRoute.stage_key == data["stage_key"],
        LlmRoute.provider_id == data["provider_id"],
        LlmRoute.model_id == data["model_id"],
    ).first()
    if existing:
        existing.priority = data.get("priority", 1)
        existing.is_active = data.get("is_active", True)
        db.commit()
        db.refresh(existing)
        return JSONResponse(existing.to_dict())
    route = LlmRoute(
        stage_key=data["stage_key"],
        provider_id=data["provider_id"],
        model_id=data["model_id"],
        priority=data.get("priority", 1),
        is_active=data.get("is_active", True),
    )
    db.add(route)
    db.commit()
    db.refresh(route)
    return JSONResponse(route.to_dict())


@app.delete("/api/superadmin/llm/routes/{route_id}")
async def superadmin_delete_route(route_id: int, request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    route = db.query(LlmRoute).filter(LlmRoute.id == route_id).first()
    if not route:
        raise HTTPException(status_code=404, detail="Route not found")
    db.delete(route)
    db.commit()
    return JSONResponse({"success": True, "deleted": route_id})


@app.post("/api/superadmin/llm/test-all")
async def superadmin_test_all(request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    creds = db.query(LlmCredential).filter(LlmCredential.is_active == True).all()
    results = []
    for cred in creds:
        provider = cred.provider
        api_key = decrypt_password(cred.encrypted_api_key)
        start = _time.time()
        try:
            if provider.sdk_type == "google_genai":
                from google import genai
                http_opts = {}
                if provider.base_url:
                    http_opts = {'api_version': '', 'base_url': provider.base_url}
                gc = genai.Client(api_key=api_key, http_options=http_opts)
                gc.models.generate_content(model="gemini-2.5-flash", contents="Say OK.")
            else:
                base_url = provider.base_url
                if not base_url and provider.slug == "perplexity":
                    base_url = "https://api.perplexity.ai"
                elif not base_url and provider.slug == "mistral":
                    base_url = "https://api.mistral.ai/v1"
                elif not base_url and provider.slug == "openrouter":
                    base_url = "https://openrouter.ai/api/v1"
                tc = OpenAI(api_key=api_key, base_url=base_url)
                active_models = [m for m in provider.models if m.is_active]
                tm = active_models[0].model_id if active_models else "gpt-4o-mini"
                tc.chat.completions.create(model=tm, messages=[{"role": "user", "content": "OK"}], max_tokens=3)
            latency = int((_time.time() - start) * 1000)
            cred.last_tested_at = datetime.datetime.utcnow()
            cred.last_test_status = "success"
            cred.last_test_latency_ms = latency
            results.append({"provider": provider.name, "credential": cred.label, "status": "success", "latency_ms": latency})
        except Exception as e:
            latency = int((_time.time() - start) * 1000)
            cred.last_tested_at = datetime.datetime.utcnow()
            cred.last_test_status = "error"
            cred.last_test_latency_ms = latency
            results.append({"provider": provider.name, "credential": cred.label, "status": "error", "error": str(e)[:300], "latency_ms": latency})
    db.commit()
    return JSONResponse({"results": results})


SEED_PROVIDERS = [
    {
        "slug": "openai", "name": "OpenAI", "provider_type": "cloud",
        "sdk_type": "openai", "color": "#10B981", "icon": "🟢",
        "supports_json": True, "supports_vision": True, "supports_image_gen": True, "supports_streaming": True,
        "display_order": 1,
        "models": [
            {"model_id": "gpt-5", "display_name": "GPT-5", "mode": "json,text,stream", "quality_tier": "legendary", "context_window": 128000, "input_cost_per_mtok": 10.0, "output_cost_per_mtok": 30.0},
            {"model_id": "gpt-4o", "display_name": "GPT-4o", "mode": "json,text,stream,vision", "quality_tier": "premium", "context_window": 128000, "input_cost_per_mtok": 2.5, "output_cost_per_mtok": 10.0},
            {"model_id": "gpt-4o-mini", "display_name": "GPT-4o Mini", "mode": "json,text,stream,vision", "quality_tier": "economy", "context_window": 128000, "input_cost_per_mtok": 0.15, "output_cost_per_mtok": 0.60},
            {"model_id": "gpt-image-1", "display_name": "GPT Image 1", "mode": "image", "quality_tier": "legendary"},
            {"model_id": "o3", "display_name": "o3 (Reasoning)", "mode": "json,text", "quality_tier": "legendary", "context_window": 200000, "input_cost_per_mtok": 10.0, "output_cost_per_mtok": 40.0},
        ],
    },
    {
        "slug": "gemini", "name": "Google Gemini", "provider_type": "cloud",
        "sdk_type": "google_genai", "color": "#3B82F6", "icon": "🔵",
        "supports_json": True, "supports_vision": True, "supports_image_gen": True, "supports_streaming": True,
        "display_order": 2,
        "models": [
            {"model_id": "gemini-2.5-pro", "display_name": "Gemini 2.5 Pro", "mode": "json,text,stream,vision", "quality_tier": "legendary", "context_window": 1000000, "input_cost_per_mtok": 1.25, "output_cost_per_mtok": 10.0},
            {"model_id": "gemini-2.5-flash", "display_name": "Gemini 2.5 Flash", "mode": "json,text,stream,vision", "quality_tier": "premium", "context_window": 1000000, "input_cost_per_mtok": 0.15, "output_cost_per_mtok": 0.60},
            {"model_id": "imagen-3.0-generate-002", "display_name": "Imagen 3", "mode": "image", "quality_tier": "premium"},
        ],
    },
    {
        "slug": "perplexity", "name": "Perplexity", "provider_type": "cloud",
        "sdk_type": "openai", "base_url": "https://api.perplexity.ai", "color": "#22D3EE", "icon": "🌐",
        "supports_json": False, "supports_vision": False, "supports_image_gen": False, "supports_streaming": True,
        "display_order": 3,
        "models": [
            {"model_id": "sonar-pro", "display_name": "Sonar Pro", "mode": "text,stream", "quality_tier": "premium", "input_cost_per_mtok": 3.0, "output_cost_per_mtok": 15.0},
            {"model_id": "sonar", "display_name": "Sonar", "mode": "text,stream", "quality_tier": "economy", "input_cost_per_mtok": 1.0, "output_cost_per_mtok": 1.0},
        ],
    },
    {
        "slug": "mistral", "name": "Mistral AI", "provider_type": "cloud",
        "sdk_type": "openai", "base_url": "https://api.mistral.ai/v1", "color": "#FF7000", "icon": "🟠",
        "supports_json": True, "supports_vision": True, "supports_image_gen": False, "supports_streaming": True,
        "display_order": 4,
        "models": [
            {"model_id": "mistral-large-latest", "display_name": "Mistral Large", "mode": "json,text,stream,vision", "quality_tier": "legendary", "context_window": 128000, "input_cost_per_mtok": 2.0, "output_cost_per_mtok": 6.0},
            {"model_id": "mistral-medium-latest", "display_name": "Mistral Medium", "mode": "json,text,stream", "quality_tier": "premium", "context_window": 128000, "input_cost_per_mtok": 0.4, "output_cost_per_mtok": 2.0},
            {"model_id": "mistral-small-latest", "display_name": "Mistral Small", "mode": "json,text,stream", "quality_tier": "economy", "context_window": 128000, "input_cost_per_mtok": 0.1, "output_cost_per_mtok": 0.3},
            {"model_id": "codestral-latest", "display_name": "Codestral", "mode": "text,stream", "quality_tier": "premium", "context_window": 256000, "input_cost_per_mtok": 0.3, "output_cost_per_mtok": 0.9},
        ],
    },
    {
        "slug": "openrouter", "name": "OpenRouter", "provider_type": "cloud",
        "sdk_type": "openai", "base_url": "https://openrouter.ai/api/v1", "color": "#8B5CF6", "icon": "🟣",
        "supports_json": True, "supports_vision": True, "supports_image_gen": False, "supports_streaming": True,
        "display_order": 5,
        "models": [
            {"model_id": "anthropic/claude-sonnet-4", "display_name": "Claude Sonnet 4", "mode": "json,text,stream,vision", "quality_tier": "legendary", "context_window": 200000, "input_cost_per_mtok": 3.0, "output_cost_per_mtok": 15.0},
            {"model_id": "anthropic/claude-haiku-3.5", "display_name": "Claude Haiku 3.5", "mode": "json,text,stream,vision", "quality_tier": "economy", "context_window": 200000, "input_cost_per_mtok": 0.80, "output_cost_per_mtok": 4.0},
            {"model_id": "meta-llama/llama-4-maverick", "display_name": "Llama 4 Maverick", "mode": "json,text,stream", "quality_tier": "premium", "context_window": 128000, "input_cost_per_mtok": 0.20, "output_cost_per_mtok": 0.60},
            {"model_id": "deepseek/deepseek-r1", "display_name": "DeepSeek R1", "mode": "json,text,stream", "quality_tier": "legendary", "context_window": 128000, "input_cost_per_mtok": 0.55, "output_cost_per_mtok": 2.19},
        ],
    },
    {
        "slug": "huggingface", "name": "HuggingFace", "provider_type": "cloud",
        "sdk_type": "openai", "base_url": "https://api-inference.huggingface.co/v1", "color": "#F59E0B", "icon": "🤗",
        "supports_json": False, "supports_vision": False, "supports_image_gen": False, "supports_streaming": True,
        "display_order": 6,
        "models": [
            {"model_id": "meta-llama/Meta-Llama-3-70B-Instruct", "display_name": "Llama 3 70B", "mode": "text", "quality_tier": "economy", "input_cost_per_mtok": 0.0, "output_cost_per_mtok": 0.0},
        ],
    },
    {
        "slug": "custom", "name": "Self-Hosted / Custom", "provider_type": "self-hosted",
        "sdk_type": "openai", "color": "#6B7280", "icon": "🖥️",
        "supports_json": True, "supports_vision": False, "supports_image_gen": False, "supports_streaming": True,
        "display_order": 99,
        "models": [],
    },
]


@app.post("/api/superadmin/llm/seed-defaults")
async def superadmin_seed_defaults(request: Request, db: Session = Depends(get_db)):
    _verify_superadmin(request)
    created_providers = 0
    created_models = 0
    skipped = 0
    for sp in SEED_PROVIDERS:
        existing = db.query(LlmProvider).filter(LlmProvider.slug == sp["slug"]).first()
        if existing:
            skipped += 1
            for sm in sp.get("models", []):
                existing_model = db.query(LlmModel).filter(
                    LlmModel.provider_id == existing.id,
                    LlmModel.model_id == sm["model_id"]
                ).first()
                if not existing_model:
                    model = LlmModel(
                        provider_id=existing.id,
                        model_id=sm["model_id"],
                        display_name=sm["display_name"],
                        mode=sm.get("mode", "text"),
                        quality_tier=sm.get("quality_tier", "premium"),
                        context_window=sm.get("context_window"),
                        input_cost_per_mtok=sm.get("input_cost_per_mtok"),
                        output_cost_per_mtok=sm.get("output_cost_per_mtok"),
                    )
                    db.add(model)
                    created_models += 1
            continue
        provider = LlmProvider(
            slug=sp["slug"],
            name=sp["name"],
            provider_type=sp.get("provider_type", "cloud"),
            base_url=sp.get("base_url"),
            sdk_type=sp.get("sdk_type", "openai"),
            supports_json=sp.get("supports_json", True),
            supports_vision=sp.get("supports_vision", False),
            supports_image_gen=sp.get("supports_image_gen", False),
            supports_streaming=sp.get("supports_streaming", True),
            is_active=True,
            color=sp.get("color", "#6366F1"),
            icon=sp.get("icon"),
            display_order=sp.get("display_order", 0),
        )
        db.add(provider)
        db.flush()
        created_providers += 1
        for sm in sp.get("models", []):
            model = LlmModel(
                provider_id=provider.id,
                model_id=sm["model_id"],
                display_name=sm["display_name"],
                mode=sm.get("mode", "text"),
                quality_tier=sm.get("quality_tier", "premium"),
                context_window=sm.get("context_window"),
                input_cost_per_mtok=sm.get("input_cost_per_mtok"),
                output_cost_per_mtok=sm.get("output_cost_per_mtok"),
            )
            db.add(model)
            created_models += 1
    db.commit()
    return JSONResponse({
        "success": True,
        "created_providers": created_providers,
        "created_models": created_models,
        "skipped_providers": skipped,
    })


@app.get("/storefront", response_class=HTMLResponse)
async def storefront_page(request: Request, db: Session = Depends(get_db)):
    from app.services.quality_scorer import calculate_quality_score, quality_badge_tier
    min_score = 30
    packages_query = db.query(Package).filter(Package.site_copy.isnot(None)).order_by(Package.created_at.desc()).all()
    pkg_list = []
    seen_domains = set()
    for pkg in packages_query:
        if pkg.domain_name in seen_domains:
            continue
        seen_domains.add(pkg.domain_name)
        brand = pkg.brand or {}
        options = brand.get("options", [])
        recommended = brand.get("recommended", 0)
        chosen = options[recommended] if options and recommended < len(options) else {"name": pkg.domain_name, "tagline": ""}
        site_copy = pkg.site_copy or {}
        augment_count = db.query(Augment).filter(Augment.domain_name == pkg.domain_name).count()
        score = calculate_quality_score(site_copy, brand, pkg.hero_image_url, augment_count)
        if score < min_score:
            continue
        tier = quality_badge_tier(score)
        features = site_copy.get("features", [])
        pricing = site_copy.get("pricing_tiers", site_copy.get("pricing_plans", []))
        faq = site_copy.get("faq_items", site_copy.get("faq", []))
        testimonials = site_copy.get("testimonials", [])
        listing_price = 497 if score >= 90 else 397 if score >= 65 else 297
        hero_image = None
        if pkg.hero_image_url and pkg.hero_image_url.startswith("/"):
            hero_image = pkg.hero_image_url
        palette = chosen.get("palette", {})
        pkg_list.append({
            "domain": pkg.domain_name,
            "brand_name": chosen.get("name", pkg.domain_name),
            "tagline": chosen.get("tagline", ""),
            "niche": pkg.chosen_niche or "",
            "hero_image": hero_image,
            "quality_score": round(score),
            "quality_tier": tier,
            "listing_price": listing_price,
            "color_primary": palette.get("primary", brand.get("color_primary", "#6366f1")),
            "color_secondary": palette.get("secondary", brand.get("color_secondary", "#8b5cf6")),
            "has_features": isinstance(features, list) and len(features) > 0,
            "has_pricing": isinstance(pricing, list) and len(pricing) > 0,
            "has_faq": isinstance(faq, list) and len(faq) > 0,
            "has_testimonials": isinstance(testimonials, list) and len(testimonials) > 0,
            "augment_count": augment_count,
        })
    pkg_list.sort(key=lambda x: x["quality_score"], reverse=True)
    legendary = [p for p in pkg_list if p["quality_tier"] == "legendary"]
    premium = [p for p in pkg_list if p["quality_tier"] == "premium"]
    standard = [p for p in pkg_list if p["quality_tier"] == "standard"]
    total_value = sum(p["listing_price"] for p in pkg_list)
    avg_score = round(sum(p["quality_score"] for p in pkg_list) / max(len(pkg_list), 1))
    niches = sorted(set(p["niche"] for p in pkg_list if p["niche"]))
    stats = {
        "total": len(pkg_list),
        "legendary_count": len(legendary),
        "premium_count": len(premium),
        "standard_count": len(standard),
        "total_value": total_value,
        "avg_score": avg_score,
    }
    return templates.TemplateResponse("storefront.html", {
        "request": request,
        "packages": pkg_list,
        "legendary_packages": legendary,
        "stats": stats,
        "niches": niches,
    })


@app.get("/api/storefront/packages")
async def storefront_api_packages(db: Session = Depends(get_db)):
    from app.services.quality_scorer import calculate_quality_score, quality_badge_tier
    packages_query = db.query(Package).filter(Package.site_copy.isnot(None)).order_by(Package.created_at.desc()).all()
    pkg_list = []
    for pkg in packages_query:
        brand = pkg.brand or {}
        options = brand.get("options", [])
        recommended = brand.get("recommended", 0)
        chosen = options[recommended] if options and recommended < len(options) else {"name": pkg.domain_name, "tagline": ""}
        site_copy = pkg.site_copy or {}
        augment_count = db.query(Augment).filter(Augment.domain_name == pkg.domain_name).count()
        score = calculate_quality_score(site_copy, brand, pkg.hero_image_url, augment_count)
        tier = quality_badge_tier(score)
        listing_price = 497 if score >= 90 else 397 if score >= 65 else 297
        pkg_list.append({
            "domain": pkg.domain_name,
            "brand_name": chosen.get("name", pkg.domain_name),
            "quality_score": round(score),
            "quality_tier": tier,
            "listing_price": listing_price,
            "price": listing_price,
            "niche": pkg.chosen_niche or "",
        })
    return JSONResponse({"packages": pkg_list})


@app.get("/api/storefront/package/{domain}")
async def storefront_api_package_detail(domain: str, db: Session = Depends(get_db)):
    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")
    from app.services.quality_scorer import calculate_quality_score, quality_badge_tier
    brand = pkg.brand or {}
    site_copy = pkg.site_copy or {}
    options = brand.get("options", [])
    recommended = brand.get("recommended", 0)
    chosen = options[recommended] if options and recommended < len(options) else {"name": domain, "tagline": ""}
    augment_count = db.query(Augment).filter(Augment.domain_name == domain).count()
    score = calculate_quality_score(site_copy, brand, pkg.hero_image_url, augment_count)
    return JSONResponse({
        "domain": domain,
        "brand_name": chosen.get("name", domain),
        "tagline": chosen.get("tagline", ""),
        "niche": pkg.chosen_niche or "",
        "quality_score": round(score),
        "quality_tier": quality_badge_tier(score),
    })


@app.post("/api/storefront/checkout")
async def storefront_checkout(request: Request, db: Session = Depends(get_db)):
    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid JSON")
    domain = body.get("domain")
    tier = body.get("tier", "professional")
    if not domain:
        raise HTTPException(status_code=400, detail="domain is required")
    pkg = db.query(Package).filter(Package.domain_name == domain).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")
    try:
        from app.services.stripe_service import create_checkout_session
        base_url = str(request.base_url).rstrip("/")
        result = create_checkout_session(
            domain=domain,
            tier=tier,
            success_url=f"{base_url}/storefront?purchased={domain}",
            cancel_url=f"{base_url}/storefront",
        )
        return JSONResponse(result)
    except Exception as e:
        return JSONResponse({"error": str(e)}, status_code=500)


@app.post("/api/storefront/webhook")
async def storefront_webhook(request: Request, db: Session = Depends(get_db)):
    payload = await request.body()
    sig = request.headers.get("stripe-signature", "")
    try:
        from app.services.stripe_service import verify_webhook_signature, generate_download_token, store_download_token
        event = verify_webhook_signature(payload, sig)
        if event.get("type") == "checkout.session.completed":
            meta = event.get("data", {}).get("object", {}).get("metadata", {})
            domain = meta.get("domain")
            if domain:
                token = generate_download_token(domain)
                store_download_token(token, domain)
                logger.info(f"Payment completed for {domain}, download token generated")
        return JSONResponse({"status": "ok"})
    except Exception as e:
        logger.error(f"Webhook error: {e}")
        return JSONResponse({"error": str(e)}, status_code=400)


@app.get("/api/storefront/download/{token}")
async def storefront_download(token: str, db: Session = Depends(get_db)):
    from app.services.stripe_service import validate_download_token
    result = validate_download_token(token)
    if not result.get("valid"):
        raise HTTPException(status_code=403, detail="Invalid or expired download token")
    domain = result["domain"]
    pkg = db.query(Package).filter(Package.domain_name == domain).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")
    return JSONResponse({"status": "ready", "domain": domain, "message": "Download would be served here"})


@app.post("/api/admin/dedup-augments")
async def admin_dedup_augments(request: Request, db: Session = Depends(get_db)):
    username = request.session.get("username")
    if not username:
        raise HTTPException(status_code=401)
    try:
        body = await request.json()
    except Exception:
        body = {}
    dry_run = body.get("dry_run", True)
    from app.services.augment_dedup import remove_duplicate_augments
    result = remove_duplicate_augments(db, dry_run=dry_run)
    return JSONResponse(result)


@app.get("/api/listing-copy/{domain}")
async def api_listing_copy(domain: str, request: Request, db: Session = Depends(get_db)):
    pkg = db.query(Package).filter(Package.domain_name == domain).order_by(Package.created_at.desc()).first()
    if not pkg:
        raise HTTPException(status_code=404, detail="Package not found")
    from app.services.quality_scorer import calculate_quality_score, quality_badge_tier
    from app.services.listing_generator import generate_listing_copy
    brand = pkg.brand or {}
    site_copy = pkg.site_copy or {}
    options = brand.get("options", [])
    rec = brand.get("recommended", 0)
    chosen = options[rec] if options and rec < len(options) else {"name": domain, "tagline": ""}
    augment_count = db.query(Augment).filter(Augment.domain_name == domain).count()
    score = calculate_quality_score(site_copy, brand, pkg.hero_image_url, augment_count)
    tier = quality_badge_tier(score)
    features = site_copy.get("features", [])
    pricing = site_copy.get("pricing_tiers", site_copy.get("pricing_plans", []))
    faq = site_copy.get("faq_items", site_copy.get("faq", []))
    testimonials = site_copy.get("testimonials", [])
    section_count = sum(1 for k in ["features", "pricing_tiers", "faq_items", "testimonials", "how_it_works_steps", "comparison_table", "stats"]
                        if site_copy.get(k) and ((isinstance(site_copy[k], list) and len(site_copy[k]) > 0) or (isinstance(site_copy[k], dict) and site_copy[k])))
    listing_price = 497 if score >= 90 else 397 if score >= 65 else 297
    result = generate_listing_copy(
        domain=domain, brand_name=chosen.get("name", domain), tagline=chosen.get("tagline", ""),
        niche=pkg.chosen_niche or "", quality_score=round(score), quality_tier=tier,
        listing_price=listing_price, feature_count=len(features) if isinstance(features, list) else 0,
        augment_count=augment_count, section_count=section_count,
        has_pricing=isinstance(pricing, list) and len(pricing) > 0,
        has_faq=isinstance(faq, list) and len(faq) > 0,
        has_testimonials=isinstance(testimonials, list) and len(testimonials) > 0,
        headline=site_copy.get("headline", ""), subheadline=site_copy.get("subheadline", ""),
    )
    return JSONResponse(result)


@app.get("/api/storefront/export")
async def api_storefront_export(request: Request, db: Session = Depends(get_db)):
    username = request.session.get("username")
    if not username:
        raise HTTPException(status_code=401)
    from app.services.storefront_generator import generate_static_storefront
    base_url = str(request.base_url).rstrip("/")
    html = generate_static_storefront(db, base_url=base_url, contact_email="")
    return HTMLResponse(content=html)


@app.post("/api/storefront/deploy-ftp")
async def api_storefront_deploy_ftp(request: Request, db: Session = Depends(get_db)):
    username = request.session.get("username")
    if not username:
        raise HTTPException(status_code=401)

    data = await request.json() if request.headers.get("content-type") == "application/json" else {}
    profile_id = data.get("profile_id")
    target_directory = data.get("target_directory", "/")
    contact_email = data.get("contact_email", "")

    if not profile_id:
        raise HTTPException(status_code=400, detail="profile_id is required")

    profile = db.query(FtpProfile).filter(FtpProfile.id == profile_id).first()
    if not profile:
        raise HTTPException(status_code=404, detail="FTP profile not found")

    from app.services.storefront_generator import generate_static_storefront
    base_url = str(request.base_url).rstrip("/")
    html = generate_static_storefront(db, base_url=base_url, contact_email=contact_email)

    files = {"index.html": html}

    import os
    for pkg in db.query(Package).filter(Package.site_copy.isnot(None)).all():
        if pkg.hero_image_url and pkg.hero_image_url.startswith("/"):
            local_path = pkg.hero_image_url.lstrip("/")
            if os.path.exists(local_path):
                with open(local_path, "rb") as f:
                    files[pkg.hero_image_url.lstrip("/")] = f.read()

    job_id = str(uuid.uuid4())[:8]
    create_job(job_id, "storefront_deploy", "storefront", 3,
               [{"key": "init", "label": "Preparing"}, {"key": "upload", "label": "Uploading"}, {"key": "complete", "label": "Done"}])

    def _run_deploy():
        try:
            from app.services.ftp_deploy import deploy_via_sftp, deploy_via_ftp, decrypt_password
            update_job(job_id, status="running", current_step="Connecting...", progress_pct=20)
            password = decrypt_password(profile.encrypted_password)
            remote_path = os.path.join(profile.base_path, target_directory.strip("/")).replace("\\", "/")
            def progress_cb(uploaded, total, filename):
                pct = 20 + int(75 * uploaded / max(total, 1))
                update_job(job_id, current_step=f"Uploading {filename}", progress_pct=pct)
            if profile.protocol == "sftp":
                uploaded = deploy_via_sftp(profile.host, profile.port, profile.username, password, remote_path, files, progress_cb)
            else:
                uploaded = deploy_via_ftp(profile.host, profile.port, profile.username, password, profile.protocol, remote_path, files, progress_cb)
            update_job(job_id, status="completed", current_step="Storefront deployed!", progress_pct=100,
                       result={"files_uploaded": uploaded})
        except Exception as e:
            logger.error(f"Storefront FTP deploy failed: {e}")
            update_job(job_id, status="failed", error=str(e))

    job_executor.submit(_run_deploy)
    return JSONResponse({"job_id": job_id, "status": "started"})


@app.post("/api/storefront/regen-deploy")
async def api_storefront_regen_deploy(request: Request, db: Session = Depends(get_db)):
    """One-shot: regenerate static storefront HTML and FTP-deploy it. Uses default profile if none specified."""
    username = request.session.get("username")
    if not username:
        raise HTTPException(status_code=401)

    data = {}
    try:
        data = await request.json()
    except Exception:
        pass

    profile_id = data.get("profile_id")
    target_directory = data.get("target_directory", "/storefront")
    contact_email = data.get("contact_email", "")

    if profile_id:
        profile = db.query(FtpProfile).filter(FtpProfile.id == profile_id).first()
    else:
        profile = db.query(FtpProfile).filter(FtpProfile.is_default == True).first()

    if not profile:
        raise HTTPException(status_code=400, detail="No FTP profile found. Configure a default profile first.")

    from app.services.storefront_generator import generate_static_storefront
    import os

    base_url = str(request.base_url).rstrip("/")
    html = generate_static_storefront(db, base_url=base_url, contact_email=contact_email)

    files = {"index.html": html}
    for pkg in db.query(Package).filter(Package.site_copy.isnot(None)).all():
        if pkg.hero_image_url and pkg.hero_image_url.startswith("/"):
            local_path = pkg.hero_image_url.lstrip("/")
            if os.path.exists(local_path):
                with open(local_path, "rb") as f:
                    files[pkg.hero_image_url.lstrip("/")] = f.read()

    job_id = str(uuid.uuid4())[:8]
    create_job(job_id, "storefront_deploy", "storefront", 3,
               [{"key": "init", "label": "Regenerating"}, {"key": "upload", "label": "Uploading"}, {"key": "complete", "label": "Done"}])

    profile_snapshot = {"host": profile.host, "port": profile.port, "username": profile.username,
                        "protocol": profile.protocol, "base_path": profile.base_path,
                        "encrypted_password": profile.encrypted_password}
    files_snapshot = dict(files)

    def _run():
        try:
            from app.services.ftp_deploy import deploy_via_sftp, deploy_via_ftp, decrypt_password
            update_job(job_id, status="running", current_step="Connecting to server...", progress_pct=15)
            password = decrypt_password(profile_snapshot["encrypted_password"])
            remote_path = os.path.join(profile_snapshot["base_path"], target_directory.strip("/")).replace("\\", "/")

            def progress_cb(uploaded, total, filename):
                pct = 15 + int(80 * uploaded / max(total, 1))
                update_job(job_id, current_step=f"Uploading {filename}", progress_pct=pct)

            if profile_snapshot["protocol"] == "sftp":
                uploaded = deploy_via_sftp(profile_snapshot["host"], profile_snapshot["port"],
                                           profile_snapshot["username"], password, remote_path, files_snapshot, progress_cb)
            else:
                uploaded = deploy_via_ftp(profile_snapshot["host"], profile_snapshot["port"],
                                          profile_snapshot["username"], password, profile_snapshot["protocol"],
                                          remote_path, files_snapshot, progress_cb)

            update_job(job_id, status="completed", current_step="Storefront live!", progress_pct=100,
                       result={"files_uploaded": uploaded, "profile": profile_snapshot["host"], "path": remote_path})
        except Exception as e:
            logger.error(f"Storefront regen-deploy failed: {e}")
            update_job(job_id, status="failed", error=str(e))

    job_executor.submit(_run)
    return JSONResponse({"job_id": job_id, "profile": profile.label, "target": target_directory, "status": "started"})
