"""IDS Phase 6 — natural language routing engine.

Rule-based intent classifier + size/style extractors + routing matrix.
Returns a deterministic `RoutingDecision` for natural-language prompts so
that callers (dispatch / chairman UI) can dispatch the correct IDS skill
without the engine itself reaching out to external LLM APIs.

Design notes (see memory/plans/tasks/task-2394/context-notes.md):
- Rule-based: no Haiku call. Sub-2s SLA + deterministic for regression tests.
- External API gate: explicit `block_direct_api_call(intent, caller)` — design-team only.
- Dual-version cardnews routing (ThreadAuto render vs. satori) decided by body length / source.
- Confidence-based confirmation: ≥0.85 auto, ≥0.55 confirm, <0.55 ambiguous fallback.
"""

from __future__ import annotations

import argparse
import dataclasses
import hashlib
import json
import logging
import time
from typing import Iterable, Optional, Sequence, Tuple

LOG = logging.getLogger("ids_natural_routing")

# ---------- Constants -----------------------------------------------------------------

INTENTS: Tuple[str, ...] = (
    "cardnews",
    "ppt",
    "mobile",
    "motion",
    "image",
    "code",
    "ambiguous",
)

# Skills that are owned exclusively by the design team (IDS §0.5 gate).
DESIGN_TEAM_INTENTS: frozenset = frozenset({"cardnews", "ppt", "mobile", "motion", "image"})

# Confidence thresholds — keep these as constants so tests can pin them.
AUTO_CONFIDENCE = 0.85
CONFIRM_CONFIDENCE = 0.55

# SLA budgets (milliseconds). Routing should be ≪ 100ms; budget is 2s for safety.
SLA_ROUTE_MS = 2_000
SLA_OUTPUT_MS = 60_000


# ---------- Data classes --------------------------------------------------------------


@dataclasses.dataclass(frozen=True)
class RoutingDecision:
    """Deterministic routing output. All fields are JSON-serializable."""

    intent: str
    skill: str
    fallback_skill: Optional[str]
    style: Optional[str]
    size: Optional[str]
    confidence: float
    needs_confirmation: bool
    confirm_message: Optional[str]
    elapsed_ms: float
    sla_ok: bool
    diagnostic: dict  # {"phase": "route", "model_quality": ..., "cli_constraint": ...}
    prompt_hash: str  # PII-safe identifier (sha256 first 16 chars)

    def to_json(self) -> str:
        return json.dumps(dataclasses.asdict(self), ensure_ascii=False, sort_keys=True)


class RoutingError(RuntimeError):
    """Raised when an external API call is attempted from a non-design-team caller."""


# ---------- Keyword tables ------------------------------------------------------------

# Intent keyword scores: each match adds the listed weight. Scored case-insensitively
# against a normalized prompt. Codex G1 suggestion #2 (machine-judgable rule table).
INTENT_KEYWORDS: dict = {
    "cardnews": [
        ("카드뉴스", 3.0),
        ("카드 뉴스", 3.0),
        ("cardnews", 3.0),
        ("card news", 3.0),
        ("인포그래픽", 2.0),
        ("infographic", 2.0),
        ("배너", 1.5),
        ("banner", 1.5),
        ("instagram", 0.6),
        ("인스타", 0.6),
    ],
    "ppt": [
        ("ppt", 3.0),
        ("덱", 2.5),
        ("발표", 2.5),
        ("발표자료", 3.0),
        ("슬라이드", 2.0),
        ("슬라이드 덱", 3.0),
        ("pitch deck", 3.0),
        ("매거진", 1.5),
        ("프레젠테이션", 3.0),
        ("presentation", 2.5),
        ("키노트", 2.0),
    ],
    "mobile": [
        ("모바일 프로토타입", 3.5),
        ("mobile prototype", 3.5),
        ("iphone", 2.5),
        ("아이폰", 2.5),
        ("pixel", 2.5),
        ("픽셀", 2.0),
        ("앱 시연", 3.0),
        ("앱 화면", 2.5),
        ("프로토타입", 1.5),
        ("프로토 타입", 1.5),
        ("베타테스트 시연", 3.0),
    ],
    "motion": [
        ("모션 카드뉴스", 3.5),
        ("동영상 카드뉴스", 3.5),
        ("mp4", 2.5),
        ("동영상", 2.0),
        ("리얼스", 2.0),
        ("reels", 2.0),
        ("쇼츠", 2.0),
        ("shorts", 2.0),
        ("애니메이션", 2.5),
        ("animation", 2.0),
        ("video", 2.0),
        ("html to video", 3.0),
    ],
    "image": [
        ("광고 이미지", 3.0),
        ("광고 사진", 3.0),
        ("광고 포토", 3.0),
        ("ad image", 3.0),
        ("ad photo", 3.0),
        ("포토리얼", 2.5),
        ("photoreal", 2.5),
        ("사진 느낌", 2.0),
        ("실사", 2.0),
        ("배경 이미지", 2.0),
        ("이미지 만들", 1.0),
    ],
    "code": [
        ("react", 3.0),
        ("리액트", 3.0),
        ("next.js", 3.0),
        ("랜딩 페이지", 2.5),
        ("랜딩페이지", 2.5),
        ("landing page", 2.5),
        ("웹 컴포넌트", 2.5),
        ("ui 컴포넌트", 2.0),
        ("프론트엔드", 1.5),
        ("frontend", 1.5),
        ("코드", 1.0),
        ("component", 1.5),
    ],
}

SIZE_KEYWORDS: dict = {
    "instagram_square": ["인스타", "instagram", "ig", "인스타그램"],
    "instagram_story": ["스토리", "story", "stories"],
    "facebook": ["페이스북", "페북", "facebook", "fb"],
    "x_twitter": [" x ", "트위터", "twitter", "엑스"],
    "threads": ["스레드", "threads", "thread"],
    "naver_blog": ["네이버", "naver", "블로그", "blog"],
    "youtube_shorts": ["쇼츠", "shorts", "유튜브", "youtube"],
    "reels": ["릴스", "reels"],
}

STYLE_BRANDS: Tuple[str, ...] = (
    "supabase",
    "stripe",
    "linear",
    "notion",
    "vercel",
    "figma",
    "apple",
    "토스",
    "토스뱅크",
    "카카오",
    "네이버",
    "라인",
)

STYLE_TONES: Tuple[Tuple[str, ...], ...] = (
    ("미니멀", "minimal"),
    ("럭셔리", "luxury"),
    ("뉴트로", "retro", "vintage"),
    ("브루탈리즘", "brutalist", "brutalism"),
    ("뉴모피즘", "neumorphism"),
    ("글래스모피즘", "glass", "glassmorphism"),
    ("다크", "dark"),
    ("라이트", "light"),
)


# ---------- Routing matrix ------------------------------------------------------------


def _route_cardnews(prompt: str, body_chars: int) -> Tuple[str, Optional[str]]:
    """Dual-version cardnews routing (memory feedback: alternate satori / threadauto).

    Rule:
      - Long body (> 80 chars) OR explicit "스레드" keyword → threadauto_render (primary), satori-cardnews (fallback)
      - Otherwise → satori-cardnews (primary), threadauto_render (fallback)
    """

    long_or_thread = body_chars > 80 or any(k in prompt for k in ("스레드", "thread"))
    if long_or_thread:
        return "threadauto_render", "satori-cardnews"
    return "satori-cardnews", "threadauto_render"


def _route_image(prompt: str) -> Tuple[str, Optional[str]]:
    """Ad photo: gemini-image primary, hybrid-image fallback for heavy text.

    GPT image (Codex path) is referenced as 2순위 in task md but not used at this layer.
    """

    has_heavy_text = any(k in prompt for k in ("텍스트", "한글 텍스트", "카피", "문구"))
    if has_heavy_text:
        return "hybrid-image", "gemini-image"
    return "gemini-image", "hybrid-image"


_FIXED_ROUTES: dict = {
    "ppt": ("magazine-ppt-ko", None),
    "mobile": ("mobile-prototype-ko", None),
    "motion": ("motion-cardnews-ko", None),
    "code": ("frontend-design", None),
}


# ---------- External API gate ---------------------------------------------------------


def block_direct_api_call(intent: str, caller: str) -> None:
    """Enforce IDS §0.5: design-team intents must not be invoked by external API callers.

    Raises `RoutingError` when a non-design-team caller targets a design-team intent.
    """

    if intent in DESIGN_TEAM_INTENTS and caller != "design_team":
        raise RoutingError(
            f"intent={intent} requires caller=design_team (got caller={caller!r}); "
            "외부 API 직접 호출 차단 게이트 (IDS §0.5)"
        )


# ---------- Classifier --------------------------------------------------------------


def _normalize(prompt: str) -> str:
    return prompt.strip().lower()


def _score_intent(text: str) -> Tuple[str, float, dict]:
    """Score every intent against the prompt and return the winner + score map."""

    scores: dict = {intent: 0.0 for intent in INTENTS if intent != "ambiguous"}
    for intent, kws in INTENT_KEYWORDS.items():
        for kw, weight in kws:
            if kw in text:
                scores[intent] += weight

    # Pick top + runner-up.
    ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    top_intent, top_score = ranked[0]
    runner_up_score = ranked[1][1] if len(ranked) > 1 else 0.0

    # Confidence model: a strong winner gives high confidence; a close runner-up suppresses it.
    if top_score == 0:
        return "ambiguous", 0.0, scores
    margin = top_score - runner_up_score
    confidence = min(0.99, (top_score / (top_score + 2.0)) * (0.6 + 0.4 * (margin / max(top_score, 1.0))))
    if confidence < CONFIRM_CONFIDENCE and margin < 0.5:
        return "ambiguous", confidence, scores
    return top_intent, confidence, scores


def _extract_size(text: str) -> Optional[str]:
    for size_label, kws in SIZE_KEYWORDS.items():
        if any(kw.strip().lower() in text for kw in kws):
            return size_label
    return None


def _extract_style(text: str) -> Optional[str]:
    for brand in STYLE_BRANDS:
        if brand in text:
            return brand
    for tones in STYLE_TONES:
        for tone in tones:
            if tone in text:
                return tones[0]  # canonical Korean form
    return None


def _hash_prompt(prompt: str) -> str:
    return hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:16]


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


def route(prompt: str, caller: str = "design_team") -> RoutingDecision:
    """Route a natural-language prompt to the matching IDS skill.

    Args:
        prompt: User natural-language input (Korean/English).
        caller: Caller identity. Must be ``design_team`` for design intents (gate).

    Returns:
        RoutingDecision with intent, skill, style/size, confidence, SLA telemetry.

    Raises:
        RoutingError: when a non-design-team caller targets a design-team intent.
    """

    if not prompt or not prompt.strip():
        raise RoutingError("prompt must be non-empty")

    started = time.perf_counter()
    text = _normalize(prompt)

    intent, confidence, score_map = _score_intent(text)
    size = _extract_size(text)
    style = _extract_style(text)
    body_chars = len(prompt)

    if intent == "cardnews":
        skill, fallback = _route_cardnews(text, body_chars)
    elif intent == "image":
        skill, fallback = _route_image(text)
    elif intent in _FIXED_ROUTES:
        skill, fallback = _FIXED_ROUTES[intent]
    else:
        skill, fallback = "ids-router::clarify", None

    # External API gate (raises if violated).
    if intent in DESIGN_TEAM_INTENTS:
        block_direct_api_call(intent, caller)

    needs_confirmation = (intent == "ambiguous") or (confidence < AUTO_CONFIDENCE)
    confirm_message: Optional[str] = None
    if needs_confirmation:
        if intent == "ambiguous":
            confirm_message = "어떤 산출물을 원하시나요? (카드뉴스/PPT/모바일/모션/광고 이미지/코드)"
        else:
            label = {
                "cardnews": "카드뉴스",
                "ppt": "PPT/덱",
                "mobile": "모바일 프로토타입",
                "motion": "모션 카드뉴스",
                "image": "광고 이미지",
                "code": "프론트엔드 코드",
            }.get(intent, intent)
            style_part = f" ({style} 스타일)" if style else ""
            confirm_message = f"{label}{style_part}로 진행할까요? (Y/n)"

    elapsed_ms = (time.perf_counter() - started) * 1000.0
    sla_ok = elapsed_ms <= SLA_ROUTE_MS
    diagnostic = {
        "phase": "route",
        "model_quality": "rule_based_no_model",  # n/a — kept for downstream log compat
        "cli_constraint": "none",
        "scores": score_map,
        "body_chars": body_chars,
    }

    decision = RoutingDecision(
        intent=intent,
        skill=skill,
        fallback_skill=fallback,
        style=style,
        size=size,
        confidence=round(confidence, 3),
        needs_confirmation=needs_confirmation,
        confirm_message=confirm_message,
        elapsed_ms=round(elapsed_ms, 3),
        sla_ok=sla_ok,
        diagnostic=diagnostic,
        prompt_hash=_hash_prompt(prompt),
    )

    LOG.info(
        "ids_route hash=%s intent=%s skill=%s conf=%.2f confirm=%s elapsed=%.2fms",
        decision.prompt_hash,
        decision.intent,
        decision.skill,
        decision.confidence,
        decision.needs_confirmation,
        decision.elapsed_ms,
    )
    return decision


# ---------- CLI -----------------------------------------------------------------------


_DEFAULT_DEMO_PROMPTS: Tuple[str, ...] = (
    "supabase 스타일 보험 PPT 5장",
    "인스타그램 카드뉴스 만들어줘",
    "iPhone 15 Pro 모바일 프로토타입 시연",
    "광고 포토 이미지 — 한글 카피 가득",
    "토스 스타일 랜딩 페이지 React 코드",
)


def _format_decision(decision: RoutingDecision) -> str:
    return (
        f"[{decision.intent}] skill={decision.skill}"
        f"{' / ' + decision.fallback_skill if decision.fallback_skill else ''}"
        f" | style={decision.style} size={decision.size}"
        f" | conf={decision.confidence:.2f}"
        f"{' [confirm]' if decision.needs_confirmation else ''}"
        f" | {decision.elapsed_ms:.2f}ms (sla_ok={decision.sla_ok})"
    )


def main(argv: Optional[Sequence[str]] = None) -> int:
    parser = argparse.ArgumentParser(description="IDS Phase 6 natural-language router (smoke).")
    parser.add_argument("prompt", nargs="*", help="Prompt to route. Omit to run demo set.")
    parser.add_argument("--caller", default="design_team")
    parser.add_argument("--json", action="store_true", help="Emit JSON instead of formatted text.")
    args = parser.parse_args(argv)

    logging.basicConfig(level=logging.INFO, format="%(message)s")

    prompts: Iterable[str]
    if args.prompt:
        prompts = [" ".join(args.prompt)]
    else:
        prompts = _DEFAULT_DEMO_PROMPTS

    for p in prompts:
        decision = route(p, caller=args.caller)
        if args.json:
            print(decision.to_json())
        else:
            print(f"> {p}")
            print(f"  {_format_decision(decision)}")
    return 0


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())
