"""
나라장터(G2B) 당일 입찰정보 크롤링 + 참가 의사결정 워크플로우 + 문서함
- g2b_opportunity.py(적합성 분석 도구)와 달리, "당일 신규 공고 수집 → 참가/보류/삭제
  의사결정 + RFP 등 첨부문서 다운로드"를 담당하는 운영 모니터링 도구다.
- SI/SM 프로젝트(SW개발용역·유지보수·시스템구축·시스템고도화 등)에 한정해서만 수집한다.
- 개방망(GUARDIA_NETWORK_MODE=open)에서만 외부 공공데이터포털 API를 호출하고,
  폐쇄망에서는 샘플 데이터로 동작한다 (g2b_opportunity.py의 _OPEN 분기 패턴 재사용).
"""
from __future__ import annotations

import os
from datetime import datetime, date, timedelta
from pathlib import Path
from typing import Optional, List
from urllib.parse import quote

import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select, func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession

from core.auth import get_current_user
from database import get_db
from models import User, BidWatch, BidWatchAssignee, AuditLog

router = APIRouter(prefix="/api/bid-watcher", tags=["나라장터 입찰워처"])

_OPEN = os.environ.get("GUARDIA_NETWORK_MODE") == "open"

# 공공데이터포털 나라장터 입찰공고정보서비스 (개방망 전용)
BID_API_BASE = "https://apis.data.go.kr/1230000/ad/BidPublicInfoService"
BID_API_KEY = os.environ.get("G2B_API_KEY", "")

DOC_ROOT = Path(__file__).parent.parent / "uploads" / "bid_documents"


def _tenant(user: User) -> str:
    return user.inst_code or str(user.id)


# ── SI/SM 한정 필터 (가장 중요한 제약 — 통과 못하면 절대 저장하지 않는다) ──────────

SI_SM_KEYWORDS = [
    "소프트웨어개발", "SW개발", "소프트웨어 유지보수", "유지보수", "유지관리",
    "시스템 구축", "시스템구축", "시스템 고도화", "시스템고도화",
    "정보시스템 운영", "정보시스템운영", "ISP", "정보화전략계획",
    "플랫폼 구축", "플랫폼구축", "솔루션 도입", "SI", "SM",
]
EXCLUDE_KEYWORDS = ["공사", "용역(청소)", "장비구매", "차량", "건축"]


def is_si_sm_project(title: str, category: str) -> bool:
    """제목/분류명에 화이트리스트 키워드가 있고 제외 키워드가 없을 때만 True."""
    text = f"{title or ''} {category or ''}"
    if any(ex in text for ex in EXCLUDE_KEYWORDS):
        return False
    return any(kw in text for kw in SI_SM_KEYWORDS)


# ── 폐쇄망 샘플 데이터 (당일 신규 공고 시뮬레이션) ─────────────────────────────

def _sample_bids_today() -> List[dict]:
    today = date.today()
    base = [
        {
            "bid_no": f"{today.strftime('%Y%m%d')}001",
            "title": "2026년 OO시 통합 행정정보시스템 고도화 사업",
            "category": "시스템 고도화",
            "institution": "OO광역시청",
            "budget": 850_000_000,
            "deadline_days": 14,
            "attachments": [
                {"name": "제안요청서(RFP).hwp", "doc_type": "RFP"},
                {"name": "과업지시서.pdf", "doc_type": "SOW"},
            ],
        },
        {
            "bid_no": f"{today.strftime('%Y%m%d')}002",
            "title": "공공데이터 플랫폼 구축 및 SW개발 용역",
            "category": "소프트웨어개발",
            "institution": "한국정보화진흥원",
            "budget": 1_200_000_000,
            "deadline_days": 21,
            "attachments": [
                {"name": "제안요청서.hwp", "doc_type": "RFP"},
                {"name": "입찰공고문.pdf", "doc_type": "NOTICE"},
            ],
        },
        {
            "bid_no": f"{today.strftime('%Y%m%d')}003",
            "title": "차세대 정보시스템 운영 및 유지보수 사업",
            "category": "정보시스템운영",
            "institution": "OO도교육청",
            "budget": 430_000_000,
            "deadline_days": 10,
            "attachments": [
                {"name": "제안요청서(RFP).hwp", "doc_type": "RFP"},
            ],
        },
        {
            "bid_no": f"{today.strftime('%Y%m%d')}004",
            "title": "청사 시설 유지보수(전기·소방설비) 용역",
            "category": "시설관리",
            "institution": "OO군청",
            "budget": 95_000_000,
            "deadline_days": 7,
            "attachments": [],
        },
        {
            "bid_no": f"{today.strftime('%Y%m%d')}005",
            "title": "스마트시티 통합플랫폼 구축 ISP 수립 용역",
            "category": "ISP",
            "institution": "국토교통부",
            "budget": 280_000_000,
            "deadline_days": 18,
            "attachments": [
                {"name": "ISP수립_제안요청서.hwp", "doc_type": "RFP"},
                {"name": "참고자료.zip", "doc_type": "REFERENCE"},
            ],
        },
        {
            "bid_no": f"{today.strftime('%Y%m%d')}006",
            "title": "청사 신축 건축 공사 (1공구)",
            "category": "건축공사",
            "institution": "OO구청",
            "budget": 5_400_000_000,
            "deadline_days": 30,
            "attachments": [],
        },
    ]
    out = []
    for b in base:
        out.append({
            "bid_no": b["bid_no"],
            "title": b["title"],
            "category": b["category"],
            "institution": b["institution"],
            "budget": b["budget"],
            "announce_date": today,
            "deadline_date": today + timedelta(days=b["deadline_days"]),
            "source_url": f"https://www.g2b.go.kr/pt/menu/selectSubFrame.do?bidNo={b['bid_no']}",
            "attachments": [
                {**a, "name": a["name"], "url": f"https://www.g2b.go.kr/docs/{b['bid_no']}/{quote(a['name'])}"}
                for a in b["attachments"]
            ],
        })
    return out


async def _fetch_bid_api_today() -> List[dict]:
    """개방망: 공공데이터포털 나라장터 입찰공고정보서비스에서 당일 등록 공고 조회."""
    if not BID_API_KEY:
        return _sample_bids_today()
    today_str = date.today().strftime("%Y%m%d")
    params = {
        "serviceKey": BID_API_KEY,
        "type": "json",
        "numOfRows": "100",
        "pageNo": "1",
        "inqryDiv": "1",
        "inqryBgnDt": f"{today_str}0000",
        "inqryEndDt": f"{today_str}2359",
    }
    try:
        async with httpx.AsyncClient(timeout=15.0) as client:
            resp = await client.get(f"{BID_API_BASE}/getBidPblancListInfoServc", params=params)
            resp.raise_for_status()
            data = resp.json()
            items = (
                data.get("response", {})
                .get("body", {})
                .get("items", [])
            )
            if isinstance(items, dict):
                items = items.get("item", [])
            out = []
            for it in items if isinstance(items, list) else []:
                bid_no = it.get("bidNtceNo") or it.get("bidNo")
                if not bid_no:
                    continue
                title = it.get("bidNtceNm", "")
                category = it.get("ntceKindNm", "")
                out.append({
                    "bid_no": bid_no,
                    "title": title,
                    "category": category,
                    "institution": it.get("ntceInsttNm", ""),
                    "budget": _safe_int(it.get("presmptPrce")),
                    "announce_date": _parse_date(it.get("bidNtceDt")) or date.today(),
                    "deadline_date": _parse_date(it.get("bidClseDt")),
                    "source_url": it.get("bidNtceUrl") or f"https://www.g2b.go.kr/pt/menu/selectSubFrame.do?bidNo={bid_no}",
                    "attachments": [],
                })
            return out
    except Exception:
        return _sample_bids_today()


def _safe_int(v) -> Optional[int]:
    try:
        return int(float(v)) if v not in (None, "") else None
    except (ValueError, TypeError):
        return None


def _parse_date(v: Optional[str]) -> Optional[date]:
    if not v:
        return None
    for fmt in ("%Y%m%d%H%M", "%Y%m%d", "%Y-%m-%d"):
        try:
            return datetime.strptime(v[:len(fmt.replace("%", ""))] if False else v[:10].replace("-", "")[:8], "%Y%m%d").date()
        except ValueError:
            continue
    return None


async def crawl_today_bids() -> List[dict]:
    """
    당일(오늘) 등록 공고만 조회 → SI/SM 필터(is_si_sm_project) 통과분만 반환.
    개방망: 공공데이터포털 나라장터 API. 폐쇄망: 샘플 데이터.
    cron/APScheduler 등에서 직접 호출 가능하도록 의존성 없이 독립 작성.
    """
    raw = await _fetch_bid_api_today() if _OPEN else _sample_bids_today()
    return [b for b in raw if is_si_sm_project(b.get("title", ""), b.get("category", ""))]


async def fetch_bid_attachments(bid_no: str, fallback: Optional[List[dict]] = None) -> List[dict]:
    """공고 상세에서 첨부 문서(RFP·제안요청서·과업지시서 등) 목록 추출."""
    if fallback is not None:
        return fallback
    return []


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

class BidWatchOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    bid_no: str
    title: str
    category: Optional[str]
    institution: Optional[str]
    budget: Optional[int]
    announce_date: Optional[date]
    deadline_date: Optional[date]
    source_url: Optional[str]
    attachments: Optional[list]
    status: str
    memo: Optional[str]
    decided_by: Optional[int]
    decided_at: Optional[datetime]
    collected_at: Optional[datetime]


class StatusUpdateIn(BaseModel):
    status: str  # NEW|JOIN|HOLD|DELETED
    memo: Optional[str] = None


class CrawlRunOut(BaseModel):
    collected: int
    skipped_non_si_sm: int
    new_saved: int
    updated: int
    network_mode: str


_VALID_STATUSES = {"NEW", "JOIN", "HOLD", "DELETED"}


class AssigneeOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    name: str
    email: str
    active: bool
    created_at: Optional[datetime]


class AssigneeCreateIn(BaseModel):
    name: str
    email: str


class AssigneeUpdateIn(BaseModel):
    name: Optional[str] = None
    email: Optional[str] = None
    active: Optional[bool] = None


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

@router.post("/crawl/run", response_model=CrawlRunOut)
async def run_crawl(
    db: AsyncSession = Depends(get_db),
    user: User = Depends(get_current_user),
):
    """당일 입찰정보 크롤링 실행 — SI/SM 필터 적용, 신규분만 등록(중복은 upsert)."""
    raw_total = await (_fetch_bid_api_today() if _OPEN else _sample_bids_today())
    filtered = [b for b in raw_total if is_si_sm_project(b.get("title", ""), b.get("category", ""))]
    skipped = len(raw_total) - len(filtered)

    new_saved = 0
    updated = 0
    newly_collected: List[BidWatch] = []
    for b in filtered:
        r = await db.execute(select(BidWatch).where(BidWatch.bid_no == b["bid_no"]))
        existing = r.scalars().first()
        atts = b.get("attachments") or await fetch_bid_attachments(b["bid_no"])
        if existing:
            existing.title = b["title"]
            existing.category = b.get("category")
            existing.institution = b.get("institution")
            existing.budget = b.get("budget")
            existing.announce_date = b.get("announce_date")
            existing.deadline_date = b.get("deadline_date")
            existing.source_url = b.get("source_url")
            existing.attachments = atts
            # status/memo/decided_* 는 보존 (운영자 의사결정 유지)
            updated += 1
        else:
            new_bid = BidWatch(
                bid_no=b["bid_no"],
                title=b["title"],
                category=b.get("category"),
                institution=b.get("institution"),
                budget=b.get("budget"),
                announce_date=b.get("announce_date"),
                deadline_date=b.get("deadline_date"),
                source_url=b.get("source_url"),
                attachments=atts,
                status="NEW",
                collected_at=datetime.utcnow(),
            )
            db.add(new_bid)
            newly_collected.append(new_bid)
            new_saved += 1

    db.add(AuditLog(
        actor=user.username if hasattr(user, "username") else str(user.id),
        action="BID_WATCH_CRAWL_RUN",
        detail=f"수집 {len(raw_total)}건 / SI·SM 필터 통과 {len(filtered)}건 / 신규 {new_saved} / 갱신 {updated}",
        entity_type="BID_WATCH",
        severity="INFO",
    ))
    await db.commit()

    if newly_collected:
        await _notify_new_bids(db, user, newly_collected)

    return CrawlRunOut(
        collected=len(raw_total),
        skipped_non_si_sm=skipped,
        new_saved=new_saved,
        updated=updated,
        network_mode="open" if _OPEN else "closed",
    )


@router.get("/bids", response_model=List[BidWatchOut])
async def list_bids(
    status: Optional[str] = Query(None, description="NEW|JOIN|HOLD|DELETED"),
    q: Optional[str] = Query(None, description="제목/발주기관 검색"),
    limit: int = Query(50, ge=1, le=200),
    offset: int = Query(0, ge=0),
    db: AsyncSession = Depends(get_db),
    _u: User = Depends(get_current_user),
):
    """입찰 목록 조회 — 그리드 (상태별 필터 NEW/JOIN/HOLD/DELETED)."""
    stmt = select(BidWatch)
    if status:
        if status.upper() not in _VALID_STATUSES:
            raise HTTPException(400, "status는 NEW|JOIN|HOLD|DELETED 중 하나여야 합니다.")
        stmt = stmt.where(BidWatch.status == status.upper())
    if q:
        like = f"%{q}%"
        stmt = stmt.where((BidWatch.title.ilike(like)) | (BidWatch.institution.ilike(like)))
    stmt = stmt.order_by(BidWatch.collected_at.desc()).offset(offset).limit(limit)
    r = await db.execute(stmt)
    return r.scalars().all()


@router.get("/bids/{bid_id}", response_model=BidWatchOut)
async def get_bid(
    bid_id: int,
    db: AsyncSession = Depends(get_db),
    _u: User = Depends(get_current_user),
):
    """상세 — 공고 원문 링크·발주기관·예산·마감일·첨부문서 목록."""
    bid = await db.get(BidWatch, bid_id)
    if not bid:
        raise HTTPException(404, "입찰 정보를 찾을 수 없습니다.")
    return bid


@router.patch("/bids/{bid_id}/status", response_model=BidWatchOut)
async def update_bid_status(
    bid_id: int,
    body: StatusUpdateIn,
    db: AsyncSession = Depends(get_db),
    user: User = Depends(get_current_user),
):
    """상태 변경 — 참가(JOIN)/보류(HOLD)/삭제(DELETED)."""
    bid = await db.get(BidWatch, bid_id)
    if not bid:
        raise HTTPException(404, "입찰 정보를 찾을 수 없습니다.")
    new_status = body.status.upper()
    if new_status not in _VALID_STATUSES:
        raise HTTPException(400, "status는 NEW|JOIN|HOLD|DELETED 중 하나여야 합니다.")

    prev = bid.status
    bid.status = new_status
    if body.memo is not None:
        bid.memo = body.memo
    bid.decided_by = user.id
    bid.decided_at = datetime.utcnow()

    db.add(AuditLog(
        actor=user.username if hasattr(user, "username") else str(user.id),
        action="BID_WATCH_STATUS_CHANGE",
        detail=f"[{bid.bid_no}] {bid.title[:60]} : {prev} → {new_status}" + (f" (메모: {body.memo[:80]})" if body.memo else ""),
        entity_type="BID_WATCH",
        entity_id=str(bid.id),
        severity="INFO",
    ))
    await db.commit()
    await db.refresh(bid)
    return bid


@router.get("/bids/{bid_id}/documents")
async def list_bid_documents(
    bid_id: int,
    db: AsyncSession = Depends(get_db),
    _u: User = Depends(get_current_user),
):
    """첨부 문서(RFP 등) 목록."""
    bid = await db.get(BidWatch, bid_id)
    if not bid:
        raise HTTPException(404, "입찰 정보를 찾을 수 없습니다.")
    docs = bid.attachments or []
    return [
        {"doc_id": idx, "name": d.get("name"), "doc_type": d.get("doc_type", "ETC")}
        for idx, d in enumerate(docs)
    ]


@router.get("/bids/{bid_id}/documents/{doc_id}/download")
async def download_bid_document(
    bid_id: int,
    doc_id: int,
    db: AsyncSession = Depends(get_db),
    _u: User = Depends(get_current_user),
):
    """RFP 등 문서 다운로드 — 외부 URL을 클라이언트에 직접 노출하지 않는 프록시 방식.
    로컬 캐시 파일이 있으면 경로 검증 후 스트리밍, 없으면 안내 placeholder를 반환한다."""
    bid = await db.get(BidWatch, bid_id)
    if not bid:
        raise HTTPException(404, "입찰 정보를 찾을 수 없습니다.")
    docs = bid.attachments or []
    if doc_id < 0 or doc_id >= len(docs):
        raise HTTPException(404, "문서를 찾을 수 없습니다.")
    doc = docs[doc_id]
    name = doc.get("name", f"document_{doc_id}")

    DOC_ROOT.mkdir(parents=True, exist_ok=True)
    cache_path = DOC_ROOT / f"{bid.bid_no}_{doc_id}_{name}"

    if cache_path.exists():
        try:
            cache_path.resolve().relative_to(DOC_ROOT.resolve())
        except ValueError:
            raise HTTPException(403, "접근이 거부되었습니다.")

        encoded = quote(name, safe="")

        def _iter():
            with open(cache_path, "rb") as fh:
                while chunk := fh.read(65536):
                    yield chunk

        return StreamingResponse(
            _iter(),
            media_type="application/octet-stream",
            headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"},
        )

    # 캐시가 없는 경우: 외부 URL을 직접 노출하지 않고 안내만 제공
    raise HTTPException(
        503,
        "문서가 아직 로컬에 캐시되지 않았습니다. 나라장터 원본 공고에서 직접 내려받아 주세요 "
        "(보안 정책상 외부 다운로드 링크는 직접 노출하지 않습니다).",
    )


@router.get("/bids/{bid_id}/link")
async def get_bid_link(
    bid_id: int,
    db: AsyncSession = Depends(get_db),
    _u: User = Depends(get_current_user),
):
    """나라장터 원본 공고 링크 정보."""
    bid = await db.get(BidWatch, bid_id)
    if not bid:
        raise HTTPException(404, "입찰 정보를 찾을 수 없습니다.")
    return {
        "bid_no": bid.bid_no,
        "title": bid.title,
        "source_url": bid.source_url,
        "institution": bid.institution,
    }


@router.get("/stats")
async def get_stats(
    db: AsyncSession = Depends(get_db),
    _u: User = Depends(get_current_user),
):
    """일자별/상태별 집계 (대시보드 카드용)."""
    r = await db.execute(
        select(BidWatch.status, sa_func.count(BidWatch.id)).group_by(BidWatch.status)
    )
    by_status = {row[0]: row[1] for row in r.all()}

    today = date.today()
    r2 = await db.execute(
        select(sa_func.count(BidWatch.id)).where(BidWatch.collected_at >= datetime(today.year, today.month, today.day))
    )
    today_count = r2.scalar() or 0

    return {
        "today_collected": today_count,
        "by_status": {
            "NEW": by_status.get("NEW", 0),
            "JOIN": by_status.get("JOIN", 0),
            "HOLD": by_status.get("HOLD", 0),
            "DELETED": by_status.get("DELETED", 0),
        },
        "total": sum(by_status.values()),
        "network_mode": "open" if _OPEN else "closed",
    }


# ── 알림 담당자 관리 (관리시스템에서 지정) ──────────────────────────────────────

@router.get("/assignees", response_model=List[AssigneeOut])
async def list_assignees(
    db: AsyncSession = Depends(get_db),
    user: User = Depends(get_current_user),
):
    """신규 SI/SM 입찰 수집 시 메일을 받을 담당자 목록 (현재 기관 소속만)."""
    r = await db.execute(
        select(BidWatchAssignee)
        .where(BidWatchAssignee.tenant_code == _tenant(user))
        .order_by(BidWatchAssignee.id)
    )
    return r.scalars().all()


@router.post("/assignees", response_model=AssigneeOut)
async def create_assignee(
    body: AssigneeCreateIn,
    db: AsyncSession = Depends(get_db),
    user: User = Depends(get_current_user),
):
    """알림 담당자 등록 — 관리시스템(Manager)에서 호출."""
    assignee = BidWatchAssignee(
        tenant_code=_tenant(user),
        name=body.name,
        email=body.email,
        active=True,
        created_by=user.id,
    )
    db.add(assignee)
    db.add(AuditLog(
        actor=user.username if hasattr(user, "username") else str(user.id),
        action="BID_WATCH_ASSIGNEE_CREATE",
        detail=f"입찰워처 알림 담당자 등록: {body.name} <{body.email}>",
        entity_type="BID_WATCH",
        severity="INFO",
    ))
    await db.commit()
    await db.refresh(assignee)
    return assignee


async def _get_assignee_for_user(db: AsyncSession, assignee_id: int, user: User) -> BidWatchAssignee:
    assignee = await db.get(BidWatchAssignee, assignee_id)
    if not assignee or assignee.tenant_code != _tenant(user):
        raise HTTPException(404, "담당자를 찾을 수 없습니다.")
    return assignee


@router.patch("/assignees/{assignee_id}", response_model=AssigneeOut)
async def update_assignee(
    assignee_id: int,
    body: AssigneeUpdateIn,
    db: AsyncSession = Depends(get_db),
    user: User = Depends(get_current_user),
):
    """알림 담당자 수정 (이름/이메일/활성 여부)."""
    assignee = await _get_assignee_for_user(db, assignee_id, user)
    if body.name is not None:
        assignee.name = body.name
    if body.email is not None:
        assignee.email = body.email
    if body.active is not None:
        assignee.active = body.active

    db.add(AuditLog(
        actor=user.username if hasattr(user, "username") else str(user.id),
        action="BID_WATCH_ASSIGNEE_UPDATE",
        detail=f"입찰워처 알림 담당자 수정: {assignee.name} <{assignee.email}> (활성={assignee.active})",
        entity_type="BID_WATCH",
        severity="INFO",
    ))
    await db.commit()
    await db.refresh(assignee)
    return assignee


@router.delete("/assignees/{assignee_id}")
async def delete_assignee(
    assignee_id: int,
    db: AsyncSession = Depends(get_db),
    user: User = Depends(get_current_user),
):
    """알림 담당자 삭제."""
    assignee = await _get_assignee_for_user(db, assignee_id, user)
    name, email = assignee.name, assignee.email
    await db.delete(assignee)
    db.add(AuditLog(
        actor=user.username if hasattr(user, "username") else str(user.id),
        action="BID_WATCH_ASSIGNEE_DELETE",
        detail=f"입찰워처 알림 담당자 삭제: {name} <{email}>",
        entity_type="BID_WATCH",
        severity="WARNING",
    ))
    await db.commit()
    return {"deleted": True, "id": assignee_id}


async def _notify_new_bids(db: AsyncSession, user: User, new_bids: List[BidWatch]) -> None:
    """신규 SI/SM 입찰 수집 시 활성 담당자 전원에게 메일 발송 (실패해도 크롤링 결과에는 영향 없음)."""
    tenant = _tenant(user)
    r = await db.execute(
        select(BidWatchAssignee).where(
            BidWatchAssignee.tenant_code == tenant,
            BidWatchAssignee.active == True,  # noqa: E712
        )
    )
    assignees = r.scalars().all()
    to_addrs = [a.email for a in assignees if a.email]
    if not to_addrs:
        return

    from core.notify import send_email

    rows_html = "".join(
        f"<tr><td>{b.bid_no}</td><td>{b.title}</td><td>{b.institution or '-'}</td>"
        f"<td>{b.budget or '-'}</td>"
        f"<td>{b.deadline_date.isoformat() if b.deadline_date else '-'}</td></tr>"
        for b in new_bids
    )
    html_body = (
        f"<p>오늘 신규로 수집된 SI/SM 입찰공고 {len(new_bids)}건이 있습니다.</p>"
        f"<table border='1' cellpadding='6' cellspacing='0' style='border-collapse:collapse'>"
        f"<tr><th>공고번호</th><th>공고명</th><th>발주기관</th><th>예산</th><th>마감일</th></tr>"
        f"{rows_html}"
        f"</table>"
        f"<p>GUARDiA 관리시스템 &gt; 입찰워처에서 참가/보류/삭제 의사결정과 RFP 등 첨부문서 확인을 진행해주세요.</p>"
    )
    ok, err = await send_email(
        to_addrs=to_addrs,
        subject=f"[GUARDiA 입찰워처] 신규 SI/SM 입찰공고 {len(new_bids)}건",
        html_body=html_body,
    )
    db.add(AuditLog(
        actor="system",
        action="BID_WATCH_NOTIFY_EMAIL",
        detail=(
            f"신규 입찰 {len(new_bids)}건 알림 메일 — 수신 {len(to_addrs)}명"
            + ("" if ok else f" (발송 실패: {err})")
        ),
        entity_type="BID_WATCH",
        severity="INFO" if ok else "WARNING",
    ))
    await db.commit()
