"""Intent Service — 의도 분류 + 날짜 파싱 + Claude API fallback (port 8099)

n8n에서 호출하는 통합 서비스.
- POST /classify  — 의도 분류 (Ollama → Claude fallback)
- POST /parse-date — 한국어 날짜/시간 파싱
- POST /chat — 자유 대화 (Ollama → Claude fallback)
- GET  /api-usage — API 사용량 조회
- GET  /health
"""

import json
import logging
import os
import re
from datetime import datetime, timedelta, date
from pathlib import Path
from zoneinfo import ZoneInfo

import httpx
from dotenv import load_dotenv
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

load_dotenv()

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("intent_service")

KST = ZoneInfo("Asia/Seoul")

# Ollama (GPU 서버)
GPU_OLLAMA_URL = os.getenv("GPU_OLLAMA_URL", "http://192.168.1.186:11434")
OLLAMA_CLASSIFY_MODEL = os.getenv("OLLAMA_CLASSIFY_MODEL", "id-9b:latest")
OLLAMA_CHAT_MODEL = os.getenv("OLLAMA_CHAT_MODEL", "id-9b:latest")
OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "15"))

# Claude API
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-haiku-4-5-20251001")
API_MONTHLY_LIMIT = float(os.getenv("API_MONTHLY_LIMIT", "10.0"))  # USD

# API 사용량 파일
USAGE_DIR = Path.home() / ".syn-chat-bot"
USAGE_FILE = USAGE_DIR / "api_usage.json"

# 이드 시스템 프롬프트 (자유 대화용)
ID_SYSTEM_PROMPT = """너는 '이드'라는 이름의 AI 비서야. 한국어로 대화해.
간결하고 실용적으로 답변하되, 친근한 톤을 유지해.
불필요한 인사나 꾸밈말은 생략하고 핵심만 전달해."""

# 의도 분류 프롬프트
CLASSIFY_PROMPT = """사용자 메시지를 분석하여 JSON으로 응답하라.

분류 기준:
- calendar: 일정/약속/회의 등 시간이 정해진 이벤트
- todo: 작업/할일/과제 등 기한이 있는 태스크
- note: 메모/기록/저장 요청
- chat: 일반 대화, 질문, 인사

반드시 아래 JSON 형식만 출력:
{"intent": "calendar|todo|note|chat", "confidence": 0.0~1.0, "title": "추출된 제목", "raw_datetime": "원문의 날짜/시간 표현"}

예시:
- "내일 3시 회의" → {"intent": "calendar", "confidence": 0.95, "title": "회의", "raw_datetime": "내일 3시"}
- "이번주까지 보고서 작성" → {"intent": "todo", "confidence": 0.9, "title": "보고서 작성", "raw_datetime": "이번주까지"}
- "메모해둬: 부품 발주 필요" → {"intent": "note", "confidence": 0.95, "title": "부품 발주 필요", "raw_datetime": ""}
- "안녕" → {"intent": "chat", "confidence": 0.99, "title": "", "raw_datetime": ""}
- "내일 자료 정리" → {"intent": "todo", "confidence": 0.6, "title": "자료 정리", "raw_datetime": "내일"}

사용자 메시지: """

app = FastAPI(title="Intent Service")


# ==================== 날짜 파싱 ====================

# 요일 매핑
WEEKDAY_MAP = {
    "월요일": 0, "화요일": 1, "수요일": 2, "목요일": 3,
    "금요일": 4, "토요일": 5, "일요일": 6,
    "월": 0, "화": 1, "수": 2, "목": 3, "금": 4, "토": 5, "일": 6,
}

# 상대 날짜 매핑
RELATIVE_DATE_MAP = {
    "오늘": 0, "금일": 0,
    "내일": 1, "명일": 1,
    "모레": 2, "내일모레": 2,
    "글피": 3,
}


def _next_weekday(base: date, weekday: int) -> date:
    """base 이후 가장 가까운 특정 요일 날짜."""
    days_ahead = weekday - base.weekday()
    if days_ahead <= 0:
        days_ahead += 7
    return base + timedelta(days=days_ahead)


def _this_weekday(base: date, weekday: int) -> date:
    """이번주의 특정 요일 (지났더라도 이번주)."""
    days_diff = weekday - base.weekday()
    return base + timedelta(days=days_diff)


def parse_korean_datetime(text: str, now: datetime | None = None) -> dict:
    """한국어 날짜/시간 표현을 파싱.

    Returns:
        {
            "success": True/False,
            "datetime": "2026-03-20T15:00:00+09:00" (ISO format),
            "date_only": True/False (시간 없이 날짜만 파싱된 경우),
            "original": "내일 3시"
        }
    """
    if not text or not text.strip():
        return {"success": False, "error": "empty input", "original": text}

    if now is None:
        now = datetime.now(KST)
    today = now.date()

    text = text.strip()
    parsed_date = None
    parsed_time = None
    date_only = True

    # 1. 절대 날짜: YYYY-MM-DD, YYYY/MM/DD
    m = re.search(r'(\d{4})[/-](\d{1,2})[/-](\d{1,2})', text)
    if m:
        try:
            parsed_date = date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
        except ValueError:
            pass

    # 2. M/D 또는 M월D일
    if parsed_date is None:
        m = re.search(r'(\d{1,2})[/월]\s*(\d{1,2})[일]?', text)
        if m:
            month, day = int(m.group(1)), int(m.group(2))
            if 1 <= month <= 12 and 1 <= day <= 31:
                try:
                    candidate = date(today.year, month, day)
                    # 이미 지난 날짜면 내년
                    if candidate < today:
                        candidate = date(today.year + 1, month, day)
                    parsed_date = candidate
                except ValueError:
                    pass

    # 3. 상대 날짜: 오늘, 내일, 모레, 글피
    if parsed_date is None:
        for keyword, delta in RELATIVE_DATE_MAP.items():
            if keyword in text:
                parsed_date = today + timedelta(days=delta)
                break

    # 4. 다음주/이번주 + 요일
    if parsed_date is None:
        m = re.search(r'(다음\s*주|차주|다다음\s*주)\s*(월|화|수|목|금|토|일)(?:요일)?', text)
        if m:
            prefix = m.group(1).replace(" ", "")
            weekday = WEEKDAY_MAP[m.group(2)]
            next_monday = _next_weekday(today, 0)  # 다음주 월요일
            if "다다음" in prefix:
                next_monday += timedelta(weeks=1)
            parsed_date = next_monday + timedelta(days=weekday)
        else:
            m = re.search(r'이번\s*주\s*(월|화|수|목|금|토|일)(?:요일)?', text)
            if m:
                weekday = WEEKDAY_MAP[m.group(1)]
                parsed_date = _this_weekday(today, weekday)

    # 5. "X요일" (단독) — 이번주에 아직 안 지났으면 이번주, 지났으면 다음주
    if parsed_date is None:
        m = re.search(r'(월|화|수|목|금|토|일)요일', text)
        if m:
            weekday = WEEKDAY_MAP[m.group(1)]
            candidate = _this_weekday(today, weekday)
            if candidate < today:
                candidate = _next_weekday(today, weekday)
            parsed_date = candidate

    # 6. 기한 표현: "이번주까지", "이번 주 내로"
    if parsed_date is None:
        if re.search(r'이번\s*주\s*(까지|내로|안에|중)', text):
            # 이번주 금요일
            parsed_date = _this_weekday(today, 4)  # 금요일
            if parsed_date < today:
                parsed_date = today

    # 시간 파싱
    # 오후/오전 X시 (Y분)
    m = re.search(r'(오전|오후|아침|저녁|밤)?\s*(\d{1,2})\s*시\s*(?:(\d{1,2})\s*분)?', text)
    if m:
        period = m.group(1) or ""
        hour = int(m.group(2))
        minute = int(m.group(3)) if m.group(3) else 0

        if period in ("오후", "저녁", "밤") and hour < 12:
            hour += 12
        elif period == "오전" and hour == 12:
            hour = 0
        elif not period and hour < 8:
            # 시간대 미지정 + 8시 미만 → 오후로 추정
            hour += 12

        parsed_time = (hour, minute)
        date_only = False

    # HH:MM 패턴
    if parsed_time is None:
        m = re.search(r'(\d{1,2}):(\d{2})', text)
        if m:
            hour, minute = int(m.group(1)), int(m.group(2))
            if 0 <= hour <= 23 and 0 <= minute <= 59:
                parsed_time = (hour, minute)
                date_only = False

    # 날짜도 시간도 없으면 실패
    if parsed_date is None and parsed_time is None:
        return {"success": False, "error": "날짜/시간을 인식할 수 없습니다", "original": text}

    # 날짜 없이 시간만 → 오늘 (이미 지났으면 내일)
    if parsed_date is None and parsed_time is not None:
        parsed_date = today
        check_dt = datetime(today.year, today.month, today.day,
                            parsed_time[0], parsed_time[1], tzinfo=KST)
        if check_dt <= now:
            parsed_date = today + timedelta(days=1)

    # 시간 없이 날짜만 → 날짜만 반환
    if parsed_time is None:
        result_dt = datetime(parsed_date.year, parsed_date.month, parsed_date.day,
                             0, 0, 0, tzinfo=KST)
    else:
        result_dt = datetime(parsed_date.year, parsed_date.month, parsed_date.day,
                             parsed_time[0], parsed_time[1], 0, tzinfo=KST)

    return {
        "success": True,
        "datetime": result_dt.isoformat(),
        "date_only": date_only,
        "original": text,
    }


# ==================== API 사용량 추적 ====================

def _load_usage() -> dict:
    """API 사용량 JSON 로드."""
    if USAGE_FILE.exists():
        try:
            return json.loads(USAGE_FILE.read_text())
        except (json.JSONDecodeError, OSError):
            pass
    return {}


def _save_usage(data: dict):
    """API 사용량 JSON 저장."""
    USAGE_DIR.mkdir(parents=True, exist_ok=True)
    USAGE_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))


def _record_api_call(input_tokens: int = 0, output_tokens: int = 0):
    """Claude API 호출 기록 (월별 누적)."""
    month_key = datetime.now(KST).strftime("%Y-%m")
    usage = _load_usage()

    if month_key not in usage:
        usage[month_key] = {"calls": 0, "input_tokens": 0, "output_tokens": 0, "estimated_cost_usd": 0.0}

    entry = usage[month_key]
    entry["calls"] += 1
    entry["input_tokens"] += input_tokens
    entry["output_tokens"] += output_tokens

    # Haiku 4.5 가격: input $0.80/MTok, output $4.00/MTok
    cost = (input_tokens * 0.8 + output_tokens * 4.0) / 1_000_000
    entry["estimated_cost_usd"] = round(entry["estimated_cost_usd"] + cost, 4)

    _save_usage(usage)


def _get_monthly_cost() -> float:
    """이번 달 API 비용."""
    month_key = datetime.now(KST).strftime("%Y-%m")
    usage = _load_usage()
    return usage.get(month_key, {}).get("estimated_cost_usd", 0.0)


# ==================== Ollama / Claude 호출 ====================

async def _call_ollama(prompt: str, system: str = "/no_think",
                       model: str | None = None, timeout: int | None = None) -> str | None:
    """GPU Ollama 호출. 실패 시 None 반환."""
    model = model or OLLAMA_CLASSIFY_MODEL
    timeout = timeout or OLLAMA_TIMEOUT
    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            resp = await client.post(f"{GPU_OLLAMA_URL}/api/generate", json={
                "model": model,
                "prompt": prompt,
                "system": system,
                "stream": False,
                "options": {"num_ctx": 4096, "temperature": 0.1},
            })
            data = resp.json()
            return data.get("response", "").strip()
    except Exception as e:
        logger.warning(f"Ollama call failed: {e}")
        return None


async def _call_claude(prompt: str, system: str | None = None,
                       max_tokens: int = 1024) -> tuple[str | None, int, int]:
    """Claude API Haiku 호출. Returns (response, input_tokens, output_tokens)."""
    if not ANTHROPIC_API_KEY or ANTHROPIC_API_KEY.startswith("sk-ant-xxxxx"):
        logger.warning("Claude API key not configured")
        return None, 0, 0

    # 예산 체크
    if _get_monthly_cost() >= API_MONTHLY_LIMIT:
        logger.warning(f"API monthly limit reached: ${_get_monthly_cost():.2f} >= ${API_MONTHLY_LIMIT}")
        return None, 0, 0

    messages = [{"role": "user", "content": prompt}]
    body = {
        "model": CLAUDE_MODEL,
        "max_tokens": max_tokens,
        "messages": messages,
    }
    if system:
        body["system"] = system

    try:
        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.post(
                "https://api.anthropic.com/v1/messages",
                json=body,
                headers={
                    "x-api-key": ANTHROPIC_API_KEY,
                    "anthropic-version": "2023-06-01",
                    "content-type": "application/json",
                },
            )
            data = resp.json()
            if resp.status_code != 200:
                logger.error(f"Claude API error: {resp.status_code} {data}")
                return None, 0, 0

            text = ""
            for block in data.get("content", []):
                if block.get("type") == "text":
                    text += block["text"]

            in_tok = data.get("usage", {}).get("input_tokens", 0)
            out_tok = data.get("usage", {}).get("output_tokens", 0)
            _record_api_call(in_tok, out_tok)

            return text.strip(), in_tok, out_tok
    except Exception as e:
        logger.error(f"Claude API call failed: {e}")
        return None, 0, 0


# ==================== 엔드포인트 ====================

@app.post("/classify")
async def classify_intent(request: Request):
    """의도 분류. body: {message: str}

    Returns: {intent, confidence, title, raw_datetime, source: "ollama"|"claude"}
    """
    body = await request.json()
    message = body.get("message", "").strip()
    if not message:
        return JSONResponse({"success": False, "error": "message required"}, status_code=400)

    prompt = CLASSIFY_PROMPT + message

    # 1차: Ollama
    result_text = await _call_ollama(prompt, system="/no_think")
    source = "ollama"

    # 2차: Claude fallback
    if result_text is None:
        logger.info("Classification fallback to Claude API")
        result_text, _, _ = await _call_claude(prompt, system="JSON만 출력하라. 다른 텍스트 없이.")
        source = "claude"

    # 완전 실패
    if not result_text:
        return JSONResponse({"success": False,
                             "error": "AI 서비스 일시 중단. 잠시 후 다시 시도해주세요."})

    # JSON 파싱
    try:
        # Ollama가 JSON 외 텍스트를 붙일 수 있으므로 추출
        json_match = re.search(r'\{[^}]+\}', result_text)
        if json_match:
            parsed = json.loads(json_match.group())
        else:
            parsed = json.loads(result_text)
    except json.JSONDecodeError:
        logger.warning(f"JSON parse failed: {result_text[:200]}")
        # 파싱 실패 → chat으로 폴백
        parsed = {"intent": "chat", "confidence": 0.5, "title": "", "raw_datetime": ""}

    intent = parsed.get("intent", "chat")
    confidence = float(parsed.get("confidence", 0.5))
    title = parsed.get("title", "")
    raw_datetime = parsed.get("raw_datetime", "")

    # confidence 낮으면 재질문 신호
    needs_clarification = confidence < 0.7

    return {
        "success": True,
        "intent": intent,
        "confidence": confidence,
        "title": title,
        "raw_datetime": raw_datetime,
        "needs_clarification": needs_clarification,
        "source": source,
    }


@app.post("/parse-date")
async def parse_date(request: Request):
    """한국어 날짜/시간 파싱. body: {text: str}"""
    body = await request.json()
    text = body.get("text", "")
    return parse_korean_datetime(text)


@app.post("/chat")
async def chat(request: Request):
    """자유 대화. body: {message: str, system?: str}

    1차 Ollama → 실패 시 Claude API (응답에 source 표시).
    """
    body = await request.json()
    message = body.get("message", "").strip()
    system = body.get("system", ID_SYSTEM_PROMPT)

    if not message:
        return JSONResponse({"success": False, "error": "message required"}, status_code=400)

    # 1차: Ollama (id-9b, 대화 모델)
    response = await _call_ollama(message, system=system, model=OLLAMA_CHAT_MODEL, timeout=30)
    source = "ollama"

    # 2차: Claude fallback
    if response is None:
        logger.info("Chat fallback to Claude API")
        response, _, _ = await _call_claude(message, system=system)
        source = "claude"

    # 완전 실패
    if not response:
        return {
            "success": False,
            "response": "AI 서비스 일시 중단. 잠시 후 다시 시도해주세요.",
            "source": "error",
        }

    # Claude API 응답이면 표시 추가
    if source == "claude":
        response = response.rstrip() + " ☁️"

    return {
        "success": True,
        "response": response,
        "source": source,
    }


@app.get("/api-usage")
async def api_usage():
    """API 사용량 조회."""
    usage = _load_usage()
    month_key = datetime.now(KST).strftime("%Y-%m")
    current = usage.get(month_key, {"calls": 0, "input_tokens": 0, "output_tokens": 0, "estimated_cost_usd": 0.0})
    return {
        "current_month": month_key,
        "calls": current["calls"],
        "input_tokens": current["input_tokens"],
        "output_tokens": current["output_tokens"],
        "estimated_cost_usd": current["estimated_cost_usd"],
        "monthly_limit_usd": API_MONTHLY_LIMIT,
        "remaining_usd": round(API_MONTHLY_LIMIT - current["estimated_cost_usd"], 4),
        "all_months": usage,
    }


@app.get("/health")
async def health():
    """헬스체크."""
    gpu_ok = False
    try:
        async with httpx.AsyncClient(timeout=5) as client:
            resp = await client.get(f"{GPU_OLLAMA_URL}/api/tags")
            gpu_ok = resp.status_code == 200
    except Exception:
        pass

    claude_configured = bool(ANTHROPIC_API_KEY and not ANTHROPIC_API_KEY.startswith("sk-ant-xxxxx"))

    return {
        "status": "ok",
        "gpu_ollama": "ok" if gpu_ok else "unreachable",
        "claude_api": "configured" if claude_configured else "not_configured",
        "monthly_cost_usd": _get_monthly_cost(),
        "monthly_limit_usd": API_MONTHLY_LIMIT,
    }
