"""
자율 패치 관리 API 라우터

엔드포인트:
  GET  /api/patch/pending              — 패치 대기 목록 (pending|approved 상태)
  POST /api/patch/scan                 — CVE 스캔 + 패치 계획 자동 생성
  GET  /api/patch/plans                — 전체 패치 계획 목록
  POST /api/patch/plans/{id}/approve   — 패치 승인 (admin 전용)
  POST /api/patch/plans/{id}/execute   — 패치 실행 (SSH, 승인 후만 가능)
  POST /api/patch/plans/{id}/rollback  — 패치 롤백
  GET  /api/patch/history              — 패치 이력 (done|failed|rolled_back)

원칙:
- 반드시 approved 상태에서만 실행 가능
- paramiko SSH 실행
- 실패 시 자동 롤백 시도
- 서버 IP/자격증명 절대 응답에 노출 금지
"""
from __future__ import annotations

import asyncio
import json
import logging
import re
from datetime import datetime, timezone
from typing import Dict, List, Optional, Any

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession

from core.auth import get_current_user, require_admin_role as require_admin
from database import get_db, SessionLocal
from models import PatchPlan, Server, User

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/patch", tags=["patch_management"])

# ── 위험 명령어 패턴 (보안 불변 규칙) ─────────────────────────────────────────
_DANGEROUS_PATTERN = re.compile(
    r"rm\s+-rf\s+/|mkfs|dd\s+if=|shutdown|reboot|halt|poweroff|"
    r":(){ :|:& };:|chmod\s+777\s+/|wget\s+.*\|\s*sh|curl\s+.*\|\s*bash",
    re.IGNORECASE,
)


def _validate_cmd(cmd: str) -> None:
    """SSH 실행 전 위험 패턴 차단."""
    if _DANGEROUS_PATTERN.search(cmd):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="위험한 명령어 패턴이 감지되었습니다.",
        )


# ── Pydantic 스키마 ──────────────────────────────────────────────────────────

class PatchScanIn(BaseModel):
    server_ids: List[int] = Field(..., description="스캔 대상 서버 ID 목록")
    cve_ids: Optional[List[str]] = Field(None, description="특정 CVE ID 목록 (없으면 전체 스캔)")
    auto_plan: bool = Field(True, description="패치 계획 자동 생성 여부")


class PatchPlanOut(BaseModel):
    id: int
    cve_id: Optional[str]
    severity: str
    affected_servers: Optional[str]   # JSON
    patch_cmd: Optional[str]
    rollback_cmd: Optional[str]
    status: str
    approved_by: Optional[str]
    approved_at: Optional[datetime]
    executed_at: Optional[datetime]
    executed_by: Optional[str]
    result_log: Optional[str]
    created_by: Optional[str]
    created_at: datetime
    updated_at: datetime

    class Config:
        from_attributes = True


class PatchApproveIn(BaseModel):
    note: Optional[str] = None


class PatchExecuteIn(BaseModel):
    confirm: bool = Field(..., description="실행 확인 플래그 — True 필수")


# ── SSH 실행 유틸리티 ──────────────────────────────────────────────────────────

async def _ssh_execute(server: Server, cmd: str) -> Dict[str, Any]:
    """
    paramiko를 사용하여 SSH 명령을 실행한다.
    서버 자격증명은 응답에 절대 포함하지 않는다.
    """
    try:
        import paramiko
        from cryptography.hazmat.primitives.ciphers.aead import AESGCM
        import base64, os

        # AES-256-GCM 복호화
        enc_key = os.environ.get("GUARDIA_ENC_KEY", "guardia-default-enc-key-32bytes!!").encode()
        enc_key = enc_key[:32].ljust(32, b"0")

        password = None
        if server.os_pw_enc:
            try:
                raw = base64.b64decode(server.os_pw_enc)
                nonce, ct = raw[:12], raw[12:]
                aesgcm = AESGCM(enc_key)
                password = aesgcm.decrypt(nonce, ct, None).decode()
            except Exception:
                password = None

        loop = asyncio.get_event_loop()

        def _run_sync():
            client = paramiko.SSHClient()
            client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
            connect_kwargs: Dict[str, Any] = {
                "hostname": server.ip_addr,
                "port": server.port or 22,
                "username": server.ssh_user,
                "timeout": 30,
            }
            if server.ssh_method == "KEY" and server.ssh_key_path:
                connect_kwargs["key_filename"] = server.ssh_key_path
            elif password:
                connect_kwargs["password"] = password

            client.connect(**connect_kwargs)
            try:
                _, stdout, stderr = client.exec_command(cmd, timeout=120)
                out = stdout.read().decode("utf-8", errors="replace")
                err = stderr.read().decode("utf-8", errors="replace")
                rc = stdout.channel.recv_exit_status()
                return {"stdout": out[:2000], "stderr": err[:500], "rc": rc}
            finally:
                client.close()

        result = await loop.run_in_executor(None, _run_sync)
        return result

    except ImportError:
        # paramiko 미설치 환경 — 시뮬레이션
        logger.warning("paramiko 미설치: SSH 시뮬레이션 모드")
        await asyncio.sleep(0.5)
        return {"stdout": "[SIMULATED] 패치 명령 실행 완료", "stderr": "", "rc": 0}
    except Exception as e:
        logger.error("SSH 실행 오류 (server_id=%s): %s", server.id, str(e)[:100])
        return {"stdout": "", "stderr": str(e)[:200], "rc": 1}


# ── 백그라운드 패치 실행기 ─────────────────────────────────────────────────────

async def _execute_patch_bg(plan_id: int, executor: str):
    """백그라운드에서 패치 계획을 실행한다."""
    async with SessionLocal() as db:
        plan = await db.get(PatchPlan, plan_id)
        if not plan or plan.status != "approved":
            return

        plan.status = "executing"
        plan.executed_at = datetime.now(timezone.utc)
        plan.executed_by = executor
        await db.commit()
        await db.refresh(plan)

        try:
            server_ids = json.loads(plan.affected_servers or "[]")
            results = []
            all_success = True

            for sid in server_ids:
                server = await db.get(Server, sid)
                if not server:
                    results.append({"server_id": sid, "status": "not_found"})
                    all_success = False
                    continue

                _validate_cmd(plan.patch_cmd or "")
                res = await _ssh_execute(server, plan.patch_cmd)
                success = res["rc"] == 0
                results.append({
                    "server_id": sid,
                    "server_name": server.server_name,
                    "status": "success" if success else "failed",
                    "rc": res["rc"],
                    "stdout": res["stdout"][:500],
                    "stderr": res["stderr"][:200],
                })
                if not success:
                    all_success = False

            plan.result_log = json.dumps(results, ensure_ascii=False)

            if all_success:
                plan.status = "done"
                logger.info("패치 완료: plan_id=%d", plan_id)
            else:
                # 실패 시 자동 롤백
                logger.warning("패치 실패 — 자동 롤백 시작: plan_id=%d", plan_id)
                plan.status = "rolling_back"
                await db.commit()

                if plan.rollback_cmd:
                    rollback_results = []
                    for sid in server_ids:
                        server = await db.get(Server, sid)
                        if not server:
                            continue
                        try:
                            _validate_cmd(plan.rollback_cmd)
                            rb_res = await _ssh_execute(server, plan.rollback_cmd)
                            rollback_results.append({
                                "server_id": sid,
                                "server_name": server.server_name,
                                "rollback_rc": rb_res["rc"],
                            })
                        except Exception as ex:
                            rollback_results.append({
                                "server_id": sid,
                                "rollback_error": str(ex)[:100],
                            })
                    # 롤백 결과 병합
                    existing = json.loads(plan.result_log or "[]")
                    plan.result_log = json.dumps(
                        {"patch": existing, "rollback": rollback_results},
                        ensure_ascii=False,
                    )

                plan.status = "rolled_back"
                logger.info("자동 롤백 완료: plan_id=%d", plan_id)

            await db.commit()

        except Exception as e:
            logger.error("패치 실행 오류: plan_id=%d — %s", plan_id, str(e)[:100])
            plan.status = "failed"
            plan.result_log = json.dumps({"error": str(e)[:200]}, ensure_ascii=False)
            await db.commit()


# ── 엔드포인트 ────────────────────────────────────────────────────────────────

@router.get("/pending", response_model=List[PatchPlanOut])
async def get_pending_patches(
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """패치 대기 목록 — pending 또는 approved 상태."""
    result = await db.execute(
        select(PatchPlan)
        .where(or_(PatchPlan.status == "pending", PatchPlan.status == "approved"))
        .order_by(PatchPlan.created_at.desc())
    )
    return result.scalars().all()


@router.post("/scan", status_code=status.HTTP_201_CREATED)
async def scan_and_create_plans(
    body: PatchScanIn,
    background_tasks: BackgroundTasks,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """CVE 스캔 후 패치 계획 자동 생성. Ollama를 활용해 패치 명령어를 추천한다."""
    if not body.server_ids:
        raise HTTPException(status_code=400, detail="server_ids가 비어 있습니다.")

    # 대상 서버 검증
    servers_found = []
    for sid in body.server_ids:
        srv = await db.get(Server, sid)
        if srv:
            servers_found.append(srv)

    if not servers_found:
        raise HTTPException(status_code=404, detail="유효한 서버를 찾을 수 없습니다.")

    created_plans = []
    cve_list = body.cve_ids or ["CVE-SCAN-AUTO"]

    for cve_id in cve_list:
        # Ollama로 패치 명령어 생성 시도
        patch_cmd, rollback_cmd = await _generate_patch_commands(cve_id, servers_found)
        severity = _estimate_severity(cve_id)

        plan = PatchPlan(
            cve_id=cve_id,
            severity=severity,
            affected_servers=json.dumps([s.id for s in servers_found]),
            patch_cmd=patch_cmd,
            rollback_cmd=rollback_cmd,
            status="pending",
            created_by=current_user.username,
        )
        db.add(plan)
        created_plans.append(cve_id)

    await db.commit()

    return {
        "message": f"{len(created_plans)}개 패치 계획이 생성되었습니다.",
        "plans_created": len(created_plans),
        "cve_ids": created_plans,
        "server_count": len(servers_found),
        "note": "패치 실행 전 반드시 관리자 승인이 필요합니다.",
    }


@router.get("/plans", response_model=List[PatchPlanOut])
async def list_patch_plans(
    status_filter: Optional[str] = Query(None, alias="status"),
    limit: int = Query(50, ge=1, le=200),
    offset: int = Query(0, ge=0),
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """전체 패치 계획 목록."""
    q = select(PatchPlan).order_by(PatchPlan.created_at.desc()).limit(limit).offset(offset)
    if status_filter:
        q = q.where(PatchPlan.status == status_filter)
    result = await db.execute(q)
    return result.scalars().all()


@router.post("/plans/{plan_id}/approve")
async def approve_patch_plan(
    plan_id: int,
    body: PatchApproveIn,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(require_admin),
):
    """패치 승인 — admin 전용. 승인 후에만 execute 가능."""
    plan = await db.get(PatchPlan, plan_id)
    if not plan:
        raise HTTPException(status_code=404, detail=f"패치 계획 {plan_id}를 찾을 수 없습니다.")
    if plan.status != "pending":
        raise HTTPException(
            status_code=400,
            detail=f"pending 상태에서만 승인 가능합니다. 현재: {plan.status}",
        )

    plan.status = "approved"
    plan.approved_by = current_user.username
    plan.approved_at = datetime.now(timezone.utc)
    await db.commit()

    return {
        "message": "패치 계획이 승인되었습니다.",
        "plan_id": plan_id,
        "approved_by": current_user.username,
        "note": body.note,
    }


@router.post("/plans/{plan_id}/execute")
async def execute_patch_plan(
    plan_id: int,
    body: PatchExecuteIn,
    background_tasks: BackgroundTasks,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """패치 실행 — approved 상태에서만 가능. 백그라운드 SSH 실행."""
    if not body.confirm:
        raise HTTPException(status_code=400, detail="confirm=true 확인이 필요합니다.")

    plan = await db.get(PatchPlan, plan_id)
    if not plan:
        raise HTTPException(status_code=404, detail=f"패치 계획 {plan_id}를 찾을 수 없습니다.")
    if plan.status != "approved":
        raise HTTPException(
            status_code=400,
            detail=f"approved 상태에서만 실행 가능합니다. 현재: {plan.status}",
        )
    if not plan.patch_cmd:
        raise HTTPException(status_code=400, detail="patch_cmd가 없습니다.")

    _validate_cmd(plan.patch_cmd)

    background_tasks.add_task(_execute_patch_bg, plan_id, current_user.username)

    return {
        "message": "패치 실행이 시작되었습니다.",
        "plan_id": plan_id,
        "status": "executing",
        "note": "실패 시 자동 롤백이 시도됩니다. /api/patch/plans?status=done 으로 결과를 확인하세요.",
    }


@router.post("/plans/{plan_id}/rollback")
async def rollback_patch_plan(
    plan_id: int,
    background_tasks: BackgroundTasks,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(require_admin),
):
    """수동 롤백 — admin 전용. done|failed 상태에서 수동 롤백."""
    plan = await db.get(PatchPlan, plan_id)
    if not plan:
        raise HTTPException(status_code=404, detail=f"패치 계획 {plan_id}를 찾을 수 없습니다.")
    if plan.status not in ("done", "failed"):
        raise HTTPException(
            status_code=400,
            detail=f"done 또는 failed 상태에서만 수동 롤백 가능합니다. 현재: {plan.status}",
        )
    if not plan.rollback_cmd:
        raise HTTPException(status_code=400, detail="rollback_cmd가 없습니다.")

    _validate_cmd(plan.rollback_cmd)
    plan.status = "approved"  # 롤백을 위해 임시 approved로 전환
    await db.commit()

    # 롤백 전용 실행 (rollback_cmd를 patch_cmd로 치환하여 재실행)
    async def _do_rollback(pid: int, user: str):
        async with SessionLocal() as _db:
            p = await _db.get(PatchPlan, pid)
            if not p:
                return
            # patch_cmd와 rollback_cmd를 교환하여 재실행
            original_patch = p.patch_cmd
            p.patch_cmd = p.rollback_cmd
            p.rollback_cmd = original_patch
            await _db.commit()
        await _execute_patch_bg(pid, user)

    background_tasks.add_task(_do_rollback, plan_id, current_user.username)

    return {
        "message": "수동 롤백이 시작되었습니다.",
        "plan_id": plan_id,
    }


@router.get("/history", response_model=List[PatchPlanOut])
async def get_patch_history(
    limit: int = Query(100, ge=1, le=500),
    offset: int = Query(0, ge=0),
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """패치 이력 — done|failed|rolled_back 상태."""
    result = await db.execute(
        select(PatchPlan)
        .where(PatchPlan.status.in_(["done", "failed", "rolled_back"]))
        .order_by(PatchPlan.executed_at.desc())
        .limit(limit)
        .offset(offset)
    )
    return result.scalars().all()


# ── 헬퍼 함수 ─────────────────────────────────────────────────────────────────

def _estimate_severity(cve_id: str) -> str:
    """CVE ID 접미사 패턴으로 심각도를 추정 (실제 NVD 조회 없이 휴리스틱)."""
    cve_upper = cve_id.upper()
    if any(k in cve_upper for k in ["CRITICAL", "CRIT"]):
        return "CRITICAL"
    if any(k in cve_upper for k in ["HIGH"]):
        return "HIGH"
    if any(k in cve_upper for k in ["LOW"]):
        return "LOW"
    return "MEDIUM"


async def _generate_patch_commands(cve_id: str, servers: List[Server]):
    """
    Ollama를 통해 CVE에 적합한 패치 명령어를 생성한다.
    Ollama 불가 시 OS별 기본 패키지 업데이트 명령을 반환한다.
    """
    # 대표 서버 OS 타입 결정
    os_types = list({s.os_type for s in servers if s.os_type})
    os_hint = os_types[0] if os_types else "linux"

    # 기본 패치 명령어 (OS별)
    os_lower = os_hint.lower()
    if "ubuntu" in os_lower or "debian" in os_lower:
        patch_cmd = f"apt-get update && apt-get upgrade -y --no-install-recommends"
        rollback_cmd = "apt-get autoremove -y"
    elif "centos" in os_lower or "rhel" in os_lower or "rocky" in os_lower:
        patch_cmd = f"yum update -y"
        rollback_cmd = "yum history undo last -y"
    else:
        patch_cmd = f"yum update -y || apt-get upgrade -y"
        rollback_cmd = "echo 'manual rollback required'"

    # Ollama로 더 정밀한 명령어 생성 시도
    try:
        import httpx
        prompt = (
            f"CVE ID: {cve_id}, OS: {os_hint}\n"
            f"리눅스 서버에서 이 CVE를 패치하는 단일 쉘 명령어와 롤백 명령어를 "
            f"JSON 형식으로 반환하세요: "
            f'{{\"patch\": \"명령어\", \"rollback\": \"롤백명령어\"}} '
            f"위험한 명령어(rm -rf /, mkfs 등)는 절대 포함하지 마세요."
        )
        async with httpx.AsyncClient(timeout=10.0) as client:
            resp = await client.post(
                "http://localhost:11434/api/generate",
                json={"model": "llama3", "prompt": prompt, "stream": False},
            )
            if resp.status_code == 200:
                text = resp.json().get("response", "")
                # JSON 파싱 시도
                import re as _re
                m = _re.search(r'\{[^{}]+\}', text)
                if m:
                    data = json.loads(m.group())
                    candidate_patch = data.get("patch", "")
                    candidate_rollback = data.get("rollback", "")
                    if candidate_patch and not _DANGEROUS_PATTERN.search(candidate_patch):
                        patch_cmd = candidate_patch
                    if candidate_rollback and not _DANGEROUS_PATTERN.search(candidate_rollback):
                        rollback_cmd = candidate_rollback
    except Exception:
        # Ollama 불가 — 기본값 사용
        pass

    return patch_cmd, rollback_cmd
