"""
GUARDiA ITSM — 로컬 LLM 클라이언트 (Ollama 래퍼)

온프레미스 보안 정책: 외부 AI/LLM API 완전 금지.
모든 추론은 localhost:11434 (Ollama) 에서 처리한다.

사용 모델:
  - guardia-agent    : GUARDiA 전용 파인튜닝 (권장)
  - llama3.1:8b      : 일반 에이전트 (fallback)
  - codellama:7b     : 코드 생성 에이전트

외부 호출 방지 확인:
  get_llm_client() 는 항상 OllamaClient 반환.
  Claude/OpenAI 등 외부 Provider는 개발·테스트 환경에서만 --dev-mode 플래그로 활성화.
"""
from __future__ import annotations

import json
import logging
import os
from dataclasses import dataclass, field
from typing import Optional

import httpx

logger = logging.getLogger(__name__)

# ── 설정 ─────────────────────────────────────────────────────────────────────

OLLAMA_BASE_URL: str = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
DEFAULT_MODEL:   str = os.getenv("GUARDIA_LLM_MODEL", "guardia-agent")
FALLBACK_MODEL:  str = "llama3.1:8b"
REQUEST_TIMEOUT: float = float(os.getenv("LLM_TIMEOUT_SEC", "120"))

# 개발 환경에서만 외부 API 활성화 (온프레미스 배포 시 반드시 False)
_DEV_MODE: bool = os.getenv("GUARDIA_DEV_MODE", "false").lower() == "true"


# ── 데이터 클래스 ────────────────────────────────────────────────────────────

@dataclass
class LLMResponse:
    content: str
    model: str
    tokens_prompt: int = 0
    tokens_completion: int = 0

    @property
    def tokens_total(self) -> int:
        return self.tokens_prompt + self.tokens_completion


@dataclass
class ModelInfo:
    name: str
    size: int = 0
    modified_at: str = ""


# ── Ollama 클라이언트 ────────────────────────────────────────────────────────

class OllamaClient:
    """
    Ollama 로컬 LLM 클라이언트.
    외부 API 호출 없음 — 모든 추론은 localhost:11434.
    """

    def __init__(self, base_url: str = OLLAMA_BASE_URL) -> None:
        self.base_url = base_url.rstrip("/")

    # ── 헬스체크 ─────────────────────────────────────────────────────────────

    async def health_check(self) -> bool:
        """Ollama 서버 응답 여부 확인."""
        try:
            async with httpx.AsyncClient(timeout=5.0) as client:
                r = await client.get(f"{self.base_url}/api/tags")
                return r.status_code == 200
        except Exception:
            return False

    async def list_models(self) -> list[ModelInfo]:
        """설치된 모델 목록 반환."""
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                r = await client.get(f"{self.base_url}/api/tags")
                r.raise_for_status()
                return [
                    ModelInfo(
                        name=m.get("name", ""),
                        size=m.get("size", 0),
                        modified_at=m.get("modified_at", ""),
                    )
                    for m in r.json().get("models", [])
                ]
        except Exception as exc:
            logger.error("Ollama 모델 목록 조회 실패: %s", exc)
            return []

    async def resolve_model(self, preferred: str) -> str:
        """
        요청 모델이 설치 여부 확인 → 없으면 fallback 모델 반환.
        """
        available = [m.name for m in await self.list_models()]
        if not available:
            logger.warning("Ollama 모델 없음 — fallback: %s", FALLBACK_MODEL)
            return FALLBACK_MODEL
        if preferred in available:
            return preferred
        # 접두사 매칭 (e.g. "llama3.1" → "llama3.1:8b")
        base = preferred.split(":")[0]
        matched = next((m for m in available if m.startswith(base)), None)
        if matched:
            logger.info("모델 '%s' → '%s' 으로 대체", preferred, matched)
            return matched
        logger.warning("모델 '%s' 미설치 — fallback: %s", preferred, FALLBACK_MODEL)
        return FALLBACK_MODEL

    # ── 추론 메서드 ───────────────────────────────────────────────────────────

    async def chat(
        self,
        messages: list[dict],
        model: str = DEFAULT_MODEL,
        temperature: float = 0.2,
        timeout: float = REQUEST_TIMEOUT,
    ) -> LLMResponse:
        """
        채팅 완성 (messages 리스트 형식).
        messages: [{"role": "system"|"user"|"assistant", "content": "..."}]
        """
        resolved = await self.resolve_model(model)
        payload = {
            "model": resolved,
            "messages": messages,
            "stream": False,
            "options": {
                "temperature": temperature,
                "num_predict": 2048,
            },
        }
        async with httpx.AsyncClient(timeout=timeout) as client:
            resp = await client.post(
                f"{self.base_url}/api/chat",
                json=payload,
            )
            resp.raise_for_status()
            data = resp.json()

        content = data.get("message", {}).get("content", "")
        return LLMResponse(
            content=content,
            model=resolved,
            tokens_prompt=data.get("prompt_eval_count", 0),
            tokens_completion=data.get("eval_count", 0),
        )

    async def generate(
        self,
        prompt: str,
        model: str = DEFAULT_MODEL,
        system: Optional[str] = None,
        temperature: float = 0.2,
        timeout: float = REQUEST_TIMEOUT,
    ) -> LLMResponse:
        """단일 프롬프트 → 응답."""
        messages: list[dict] = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": prompt})
        return await self.chat(messages=messages, model=model, temperature=temperature, timeout=timeout)

    async def json_generate(
        self,
        prompt: str,
        model: str = DEFAULT_MODEL,
        system: Optional[str] = None,
        timeout: float = REQUEST_TIMEOUT,
    ) -> dict:
        """
        JSON 응답 전용 메서드.
        모델 응답에서 JSON 블록을 추출하여 dict로 반환.
        실패 시 빈 dict 반환 (예외 미발생).
        """
        full_system = (system or "") + (
            "\n\n반드시 순수 JSON만 응답하세요. 코드블록(```)이나 설명 없이 JSON 객체만 출력하세요."
        )
        resp = await self.generate(
            prompt=prompt,
            model=model,
            system=full_system,
            temperature=0.1,
            timeout=timeout,
        )
        raw = resp.content.strip()

        # 코드블록 제거
        if raw.startswith("```"):
            lines = raw.split("\n")
            raw = "\n".join(
                l for l in lines
                if not l.strip().startswith("```")
            ).strip()

        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            # 첫 번째 { ... } 블록 추출 시도
            start = raw.find("{")
            end = raw.rfind("}")
            if start != -1 and end != -1:
                try:
                    return json.loads(raw[start : end + 1])
                except json.JSONDecodeError:
                    pass
            logger.warning("LLM JSON 파싱 실패 (model=%s): %s", model, raw[:200])
            return {}

    async def pull_model(self, model: str) -> bool:
        """모델 다운로드 (비동기 스트리밍)."""
        try:
            async with httpx.AsyncClient(timeout=600.0) as client:
                async with client.stream(
                    "POST", f"{self.base_url}/api/pull",
                    json={"name": model},
                ) as resp:
                    async for line in resp.aiter_lines():
                        if line:
                            data = json.loads(line)
                            status = data.get("status", "")
                            logger.info("모델 다운로드 [%s]: %s", model, status)
                            if status == "success":
                                return True
            return False
        except Exception as exc:
            logger.error("모델 다운로드 실패 [%s]: %s", model, exc)
            return False

    async def fine_tune(self, dataset_path: str, model_name: str) -> bool:
        """
        Ollama 커스텀 모델 파인튜닝 실행.

        tb_agent_task(COMPLETED) 데이터를 JSONL로 내보내고
        Modelfile을 생성하여 ollama create 명령으로 새 모델을 생성한다.

        보안: 외부 API 호출 없음 — Ollama localhost 전용.

        Args:
            dataset_path: JSONL 파인튜닝 데이터셋 경로
                          (예: /opt/guardia/finetune/guardia-agent-v2.jsonl)
            model_name:   생성할 모델 이름 (예: guardia-agent-v2)

        Returns:
            True: 파인튜닝 성공, False: 실패
        """
        import asyncio
        import os
        from pathlib import Path

        dataset = Path(dataset_path)
        if not dataset.exists():
            logger.error("[fine_tune] 데이터셋 파일 없음: %s", dataset_path)
            return False

        # Modelfile 생성
        modelfile_path = dataset.parent / f"Modelfile.{model_name}"
        base_model = DEFAULT_MODEL or "llama3.1:8b"
        modelfile_content = (
            f"FROM {base_model}\n"
            f"TRAIN {dataset_path}\n"
            f"PARAMETER temperature 0.1\n"
            f'SYSTEM "당신은 GUARDiA ITSM 전문 AI 에이전트입니다. 보안 규칙을 항상 준수하세요."\n'
        )
        modelfile_path.write_text(modelfile_content, encoding="utf-8")

        logger.info("[fine_tune] 파인튜닝 시작: model=%s dataset=%s", model_name, dataset_path)

        try:
            proc = await asyncio.create_subprocess_exec(
                "ollama", "create", model_name,
                "-f", str(modelfile_path),
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            stdout, stderr = await asyncio.wait_for(
                proc.communicate(),
                timeout=3600,  # 1시간
            )

            if proc.returncode == 0:
                logger.info("[fine_tune] 파인튜닝 완료: model=%s", model_name)
                return True
            else:
                err = stderr.decode("utf-8", errors="replace")[:500]
                logger.error("[fine_tune] 파인튜닝 실패: model=%s err=%s", model_name, err)
                return False

        except asyncio.TimeoutError:
            logger.error("[fine_tune] 파인튜닝 타임아웃 (1시간 초과): model=%s", model_name)
            if "proc" in dir():
                proc.kill()
            return False
        except Exception as exc:
            logger.error("[fine_tune] 파인튜닝 예외: model=%s err=%s", model_name, exc)
            return False

    async def export_finetune_dataset(self, output_path: str, limit: int = 1000) -> int:
        """
        tb_agent_task(COMPLETED) 데이터를 Ollama 파인튜닝용 JSONL로 내보낸다.

        Args:
            output_path: 저장할 JSONL 파일 경로
            limit:       최대 레코드 수

        Returns:
            내보낸 레코드 수
        """
        import json
        from pathlib import Path

        try:
            from database import SessionLocal
            from models import AgentTask, AgentTaskStatus
            from sqlalchemy import select

            async with SessionLocal() as db:
                tasks = (await db.execute(
                    select(AgentTask).where(
                        AgentTask.status == AgentTaskStatus.COMPLETED,
                        AgentTask.tokens_used > 0,
                    ).limit(limit)
                )).scalars().all()

            out_path = Path(output_path)
            out_path.parent.mkdir(parents=True, exist_ok=True)

            count = 0
            with out_path.open("w", encoding="utf-8") as f:
                for task in tasks:
                    if not task.input_data or not task.output_data:
                        continue
                    record = {
                        "prompt": json.dumps(task.input_data, ensure_ascii=False),
                        "response": json.dumps(task.output_data, ensure_ascii=False),
                    }
                    f.write(json.dumps(record, ensure_ascii=False) + "\n")
                    count += 1

            logger.info("[export_finetune] %d건 내보내기 완료: %s", count, output_path)
            return count

        except Exception as exc:
            logger.error("[export_finetune] 내보내기 실패: %s", exc)
            return 0


# ── 싱글턴 ──────────────────────────────────────────────────────────────────

_client: Optional[OllamaClient] = None


def get_llm_client() -> OllamaClient:
    """
    LLM 클라이언트 싱글턴 반환.
    항상 OllamaClient (로컬) 반환 — 외부 API 없음.
    """
    global _client
    if _client is None:
        _client = OllamaClient(base_url=OLLAMA_BASE_URL)
        logger.info("LLM 클라이언트 초기화: %s (모델: %s)", OLLAMA_BASE_URL, DEFAULT_MODEL)
    return _client
