# backend/app/routers/mail.py
import uuid
from datetime import UTC, datetime
from urllib.parse import quote

import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlmodel import Session, select

from ..auth.deps import current_user
from ..connectors.mail import get_mail_connector
from ..connectors.mail.normalize import mail_account_id_for, sender_parts
from ..connectors.registry import ConnectorRegistry
from ..db import get_session
from ..llm.provider import LLMProvider, get_provider
from ..models import (
    AccountFolder,
    Approval,
    AppSetting,
    ConnectorAccount,
    ConnectorDomain,
    ConnectorMode,
    ConnState,
    Email,
    ExternalLink,
    MailAccount,
    OutboundMail,
    Person,
    Prio,
    Project,
    Task,
    TaskStatus,
    now,
)
from ..schemas import (
    AccountFolderOut,
    BulkMailRequest,
    ConversationOut,
    EmailDetailOut,
    EmailRowOut,
    ExtractAllResponse,
    ExtractRequest,
    ExtractResponse,
    MailAccountOut,
    MailLabelRequest,
    MailReadRequest,
    MailStarRequest,
    ReplyDraftOut,
    ReplyDraftRequest,
    SendRequest,
    SendResponse,
)
from .calendar import create_event_from_extract  # phase-8 일정 federation
from .tasks import to_node

router = APIRouter()

# 프로젝트명(라벨) → project_id 매핑
PROJECT_LABEL_MAP = {
    "온보딩 리디자인 · 와이어프레임": "onb-wire",
    "온보딩 리디자인 · 음성 인터페이스": "onb-voice",
    "온보딩 리디자인": "onb",
    "경영 전략 · 분기 리포트": "biz-report",
    "경영 전략 · 예산 관리": "biz-budget",
    "경영 전략": "biz",
    "팀 운영 · 채용 & 온보딩": "team-hr",
    "팀 운영": "team",
    "개인 · 성장": "me",
    "개인": "me",
}


# ── 표시용 상대 시각(수신 시각 received_at 기준, 응답 시 산출 → 항상 최신) ──
def _ko_hm(dt: datetime) -> str:
    """'오전 9:12' / '오후 1:30' 형식(로컬)."""
    h = dt.hour
    ampm = "오전" if h < 12 else "오후"
    h12 = h % 12 or 12
    return f"{ampm} {h12}:{dt.minute:02d}"


def _local(dt: datetime) -> datetime:
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=UTC)
    return dt.astimezone()


def _list_time(received: datetime | None, fallback: str) -> str:
    """목록 행 시각: 오늘→시:분, 어제→'어제', 올해→'M월 D일', 그 외→'YY. M. D.'."""
    if not received:
        return fallback
    dt = _local(received)
    now = datetime.now(UTC).astimezone()
    secs = int((now - dt).total_seconds())
    if 0 <= secs < 60:
        return "방금"
    if 0 <= secs < 3600:
        return f"{secs // 60}분 전"
    today = now.date()
    d = dt.date()
    if d == today:
        return _ko_hm(dt)
    if (today - d).days == 1:
        return "어제"
    if d.year == today.year:
        return f"{dt.month}월 {dt.day}일"
    return f"{dt.year % 100:02d}. {dt.month}. {dt.day}."


def _detail_date(received: datetime | None, fallback: str) -> str:
    """리더 표시 날짜: '오늘 오전 9:12' / '어제 오후 6:30' / '6월 17일 오후 9:12' / '2025년 …'."""
    if not received:
        return fallback
    dt = _local(received)
    now = datetime.now(UTC).astimezone()
    today = now.date()
    d = dt.date()
    hm = _ko_hm(dt)
    if d == today:
        return f"오늘 {hm}"
    if (today - d).days == 1:
        return f"어제 {hm}"
    if d.year == today.year:
        return f"{dt.month}월 {dt.day}일 {hm}"
    return f"{dt.year}년 {dt.month}월 {dt.day}일 {hm}"


# Gmail CATEGORY_* 정규화 라벨 → 메일 목록 카테고리 탭. 그 외(Outlook 포함)는 기본(집중).
_CATEGORY_BY_LABEL = {
    "프로모션": "promotions",
    "소셜": "social",
    "업데이트": "updates",
    "포럼": "updates",
}


def _category_for(labels: list[str]) -> str:
    for lab in labels or []:
        cat = _CATEGORY_BY_LABEL.get(lab)
        if cat:
            return cat
    return "primary"


# ── 아리 정리(비서) 기준 시각 ──
# 아리 정리는 '이 시각 이후 도착한 새 메일'만 다룬다(과거 백로그는 제외).
# 기준 시각은 app_setting 에 영속하고, 처음 보는 순간(첫 GET) now() 로 lazy-init 한다.
_TRIAGE_SINCE_KEY = "mail.triage_since"


def _as_utc(dt: datetime | None) -> datetime | None:
    if dt is None:
        return None
    return dt if dt.tzinfo else dt.replace(tzinfo=UTC)


def _get_triage_since(s: Session) -> datetime | None:
    row = s.get(AppSetting, _TRIAGE_SINCE_KEY)
    if not row or not row.value:
        return None
    try:
        return _as_utc(datetime.fromisoformat(row.value))
    except ValueError:
        return None


def _set_triage_since(s: Session, dt: datetime) -> datetime:
    dt = _as_utc(dt)
    row = s.get(AppSetting, _TRIAGE_SINCE_KEY)
    if row:
        row.value = dt.isoformat()
        row.updated_at = now()
        s.add(row)
    else:
        s.add(AppSetting(key=_TRIAGE_SINCE_KEY, value=dt.isoformat()))
    s.commit()
    return dt


def _ensure_triage_since(s: Session) -> datetime:
    """기준 시각을 반환 — 없으면 지금(now)으로 초기화·영속(첫 조회 = 기준점)."""
    cur = _get_triage_since(s)
    return cur if cur is not None else _set_triage_since(s, now())


def _is_new_mail(e: Email, since: datetime) -> bool:
    """기준 시각 이후 도착한 메일인가(수신 시각 없으면 과거로 취급)."""
    rcv = _as_utc(e.received_at)
    return rcv is not None and rcv >= since


def _email_in_folder(e: Email, slug: str) -> bool:
    """메일이 사이드바 폴더(slug)에 속하는지. 폴더 카운트·목록 필터의 단일 기준.
    집중=Gmail 기본(primary)+Outlook focused 만, 기타=Gmail 프로모션/소셜/업데이트+Outlook other."""
    folder = e.folder or "inbox"
    if slug in ("inbox", "smart"):
        # 집중(Focused): Gmail 기본(primary) 카테고리 + Outlook focused. (프로모션·기타는 제외)
        return (
            folder == "inbox"
            and not e.archived
            and _category_for(e.labels) == "primary"
            and (e.inference or "") != "other"
        )
    if slug == "others":
        # 기타(Others): Gmail 비기본(프로모션/소셜/업데이트) 또는 Outlook other.
        return (
            folder == "inbox"
            and not e.archived
            and (_category_for(e.labels) != "primary" or (e.inference or "") == "other")
        )
    if slug.startswith("cat:"):  # Gmail 카테고리(프로모션/소셜/업데이트) — 받은편지함 내 분류
        return folder == "inbox" and not e.archived and _category_for(e.labels) == slug[4:]
    if slug == "starred":
        return e.starred and folder not in ("trash", "spam")
    if slug == "archive":
        return e.archived or folder == "archive"
    if slug == "spam":
        return folder == "spam"
    if slug == "trash":
        return folder == "trash"
    if slug.startswith("label:"):  # Gmail 사용자 라벨
        return slug[6:] in (e.labels or [])
    if slug.startswith("of:"):  # Outlook 커스텀 폴더
        return folder == slug
    if slug in ("sent", "drafts"):  # 발송/임시는 OutboundMail 경로(Email 아님)
        return False
    return folder == "inbox" and not e.archived


def _email_sort_key(e: Email):
    """목록 정렬: 최신 수신순(received_at) → sort_order → created_at."""
    rcv = e.received_at
    if rcv is not None and rcv.tzinfo is None:
        rcv = rcv.replace(tzinfo=UTC)
    return (
        rcv is not None,
        rcv or datetime.min.replace(tzinfo=UTC),
        -e.sort_order,
        e.created_at,
    )


def _row_out(e: Email) -> EmailRowOut:
    # 보낸사람: 표시이름(이름 없으면 도메인 추정) + 실제 메일주소 분리 — 목록은 이름만 노출.
    from_name, from_email = sender_parts(e.from_key)
    return EmailRowOut.model_validate(
        {
            "id": e.id,
            "account": e.account,
            "from": e.from_key,
            "from_name": from_name,
            "from_email": from_email,
            "to": e.to,
            "cc": e.cc or "",
            "thread_id": e.thread_id or "",
            "subject": e.subject,
            "time": _list_time(e.received_at, e.time),
            "read": e.read,
            "starred": e.starred,
            "has_attach": e.has_attach,
            "labels": e.labels,
            "preview": e.preview,
            "category": _category_for(e.labels),
            "ai": e.ai_json or None,
        }
    )


@router.get("/mail/accounts", response_model=list[MailAccountOut])
def accounts(s: Session = Depends(get_session)):
    accts = s.exec(select(MailAccount).order_by(MailAccount.sort_order)).all()
    # 계정별 받은편지함 안읽음 수 — 집중(unread)/기타(others) 각각. 사이드바 배지·기타 탭 카운트.
    unread_rows = s.exec(select(Email).where(Email.read == False)).all()  # noqa: E712
    counts: dict[str, int] = {}
    others: dict[str, int] = {}
    for e in unread_rows:
        if _email_in_folder(e, "inbox"):
            counts[e.account] = counts.get(e.account, 0) + 1
        elif _email_in_folder(e, "others"):
            others[e.account] = others.get(e.account, 0) + 1
    return [
        MailAccountOut(
            id=a.id,
            name=a.name,
            email=a.email,
            tone=a.tone,
            kind=a.kind,
            unread=counts.get(a.id, 0),
            others=others.get(a.id, 0),
        )
        for a in accts
    ]


@router.get("/mail/folders", response_model=list[AccountFolderOut])
def folders(
    s: Session = Depends(get_session),
    user: Person = Depends(current_user),
):
    """계정별 실제 폴더/라벨(sync 가 미러링한 AccountFolder) + 동기화분 안읽음/총계.
    빈 사용자 라벨 폴더(동기화된 메일 0)는 숨긴다."""
    rows = s.exec(select(AccountFolder)).all()
    emails = [e for e in s.exec(select(Email)).all() if e.user_id == user.id]
    by_acct: dict[str, list[Email]] = {}
    for e in emails:
        by_acct.setdefault(e.account, []).append(e)
    out: list[AccountFolderOut] = []
    for f in sorted(rows, key=lambda r: (r.mail_account, r.sort_order, r.name)):
        acc_emails = by_acct.get(f.mail_account, [])
        unread = sum(1 for e in acc_emails if not e.read and _email_in_folder(e, f.slug))
        synced = sum(1 for e in acc_emails if _email_in_folder(e, f.slug))
        if f.kind == "label" and synced == 0:
            continue  # 동기화된 메일이 없는 사용자 라벨은 숨김
        out.append(
            AccountFolderOut(
                account=f.mail_account,
                slug=f.slug,
                name=f.name,
                kind=f.kind,
                icon=f.icon,
                unread=unread,
                total=f.total or synced,
                sort_order=f.sort_order,
            )
        )
    return out


@router.post("/mail/sync")
def sync_mail(s: Session = Depends(get_session)):
    """연결된 real 메일 계정을 증분 동기화하고 새로 들어온 메일 수를 반환('새 메일 가져오기')."""
    accts = s.exec(
        select(ConnectorAccount).where(
            ConnectorAccount.domain == ConnectorDomain.mail,
            ConnectorAccount.mode == ConnectorMode.real,
            ConnectorAccount.state == ConnState.connected,
        )
    ).all()
    upserted = 0
    for a in accts:
        upserted += ConnectorRegistry.get(s, a).sync(s).upserted
    return {"accounts": len(accts), "upserted": upserted}


def _matches_query(e: Email, q: str) -> bool:
    """제목/보낸이/받는이/미리보기/본문 전반에 대한 대소문자 무시 부분일치."""
    hay = " ".join(
        [e.subject or "", e.from_key or "", e.to or "", e.cc or "", e.preview or ""]
        + (e.body or [])
        + (e.labels or [])
    ).lower()
    return all(term in hay for term in q.lower().split())


@router.get("/mail", response_model=list[EmailRowOut])
def list_mail(
    account: str | None = Query(None),
    folder: str = Query("inbox"),  # 폴더 슬러그: inbox/spam/trash/starred/cat:/label:/of:
    q: str | None = Query(None),  # 서버측 검색어(공백=AND)
    s: Session = Depends(get_session),
    user: Person = Depends(current_user),
):
    rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id]
    if account:
        rows = [e for e in rows if e.account == account]
    rows = [e for e in rows if _email_in_folder(e, folder)]
    if folder == "smart":
        # 아리 정리는 기준 시각 이후 도착한 새 메일만(과거 백로그 제외).
        since = _ensure_triage_since(s)
        rows = [e for e in rows if _is_new_mail(e, since)]
    if q and q.strip():
        rows = [e for e in rows if _matches_query(e, q.strip())]
    rows.sort(key=_email_sort_key, reverse=True)
    return [_row_out(e) for e in rows]


def _outbound_time(dt: datetime | None) -> str:
    """보낸/대기 메일 표시용 상대 시각('방금'/'N분 전'/'M월 D일')."""
    if not dt:
        return "방금"
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=UTC)
    delta = datetime.now(UTC) - dt
    secs = int(delta.total_seconds())
    if secs < 60:
        return "방금"
    if secs < 3600:
        return f"{secs // 60}분 전"
    if secs < 86400:
        return f"{secs // 3600}시간 전"
    loc = dt.astimezone()
    return f"{loc.month}월 {loc.day}일"


def _outbound_row_out(ob: OutboundMail, acct: MailAccount | None) -> EmailRowOut:
    """OutboundMail(발송/대기) → 목록 행. 보낸편지함이므로 read=True, from=내 계정 이메일."""
    sender = acct.email if acct else ob.mail_account
    preview = " ".join((ob.body or "").split())[:140]
    when = ob.sent_at or ob.created_at
    return EmailRowOut.model_validate(
        {
            "id": ob.id,
            "account": ob.mail_account,
            "from": sender,
            "from_name": acct.name if acct else sender,
            "from_email": sender,
            "to": ob.to,
            "cc": ob.cc or "",
            "subject": ob.subject,
            "time": _outbound_time(when),
            "read": True,
            "starred": False,
            "has_attach": bool(ob.attachments),
            "labels": [],
            "preview": preview,
            "ai": None,
        }
    )


def _outbound_list(s: Session, status: str, account: str | None) -> list[EmailRowOut]:
    stmt = select(OutboundMail).where(OutboundMail.status == status)
    if account:
        stmt = stmt.where(OutboundMail.mail_account == account)
    rows = s.exec(stmt).all()
    # 최신순: sent_at(없으면 created_at) 기준 내림차순
    rows.sort(key=lambda ob: (ob.sent_at or ob.created_at), reverse=True)
    accts = {a.id: a for a in s.exec(select(MailAccount)).all()}
    return [_outbound_row_out(ob, accts.get(ob.mail_account)) for ob in rows]


@router.get("/mail/sent", response_model=list[EmailRowOut])
def sent_mail(
    account: str | None = Query(None),
    s: Session = Depends(get_session),
):
    """보낸편지함 — 실제 발송된(status='sent') 메일을 최신순으로."""
    return _outbound_list(s, "sent", account)


@router.get("/mail/drafts", response_model=list[EmailRowOut])
def draft_mail(
    account: str | None = Query(None),
    s: Session = Depends(get_session),
):
    """임시보관함 — 승인 대기(status='pending') 발송 메일을 최신순으로."""
    return _outbound_list(s, "pending", account)


def _rewrite_cids(html: str, eid: str, attachments: list) -> str:
    """본문 HTML 의 cid: 인라인 이미지 참조를 첨부 스트리밍 URL(inline)로 치환."""
    if not html or "cid:" not in html:
        return html
    for a in attachments or []:
        cid = (a.get("content_id") or "").strip("<>").strip()
        if cid and a.get("id"):
            html = html.replace(
                f"cid:{cid}", f"/api/mail/{eid}/attachments/{a['id']}?inline=1"
            )
    return html


@router.get("/mail/{eid}", response_model=EmailDetailOut)
def get_mail(eid: str, s: Session = Depends(get_session)):
    e = get_mail_connector().get(s, eid)
    if not e:
        raise HTTPException(404, "email not found")
    if not e.read:  # 열람 = 읽음 처리
        e.read = True
        s.add(e)
        s.commit()
    d = _row_out(e).model_dump(by_alias=True)
    # 인라인(cid) 이미지 칩은 본문에서만 쓰이므로 첨부 목록에서는 숨긴다.
    visible_attach = [a for a in (e.attachments or []) if not a.get("inline")]
    d.update(
        {
            "date": _detail_date(e.received_at, e.date),
            "body": e.body,
            "body_html": _rewrite_cids(e.body_html or "", e.id, e.attachments or []),
            "attachments": visible_attach,
        }
    )
    return EmailDetailOut.model_validate(d)


# ── 메일 상태 변경(별표/읽음/보관/삭제) — 연결된 Gmail/Outlook 에 실제 반영 + 로컬 동기화 ──
def _email_link(s: Session, eid: str) -> ExternalLink | None:
    return s.exec(
        select(ExternalLink).where(
            ExternalLink.entity_id == eid, ExternalLink.entity_type == "email"
        )
    ).first()


def _apply_remote(
    s: Session,
    eid: str,
    *,
    star: bool | None = None,
    read: bool | None = None,
    archived: bool | None = None,
    trash: bool = False,
) -> None:
    """변경을 연결된 메일 제공자(Gmail/Outlook)에 실제 반영. 매핑/계정 없으면 로컬 전용."""
    link = _email_link(s, eid)
    if not link:
        return
    acct = s.get(ConnectorAccount, link.account_id)
    if not acct or acct.state != ConnState.connected:
        return
    conn = ConnectorRegistry.get(s, acct)
    ext = link.external_id
    try:
        if acct.provider == "gmail":
            if trash:
                conn.trash(s, ext)
                return
            add, remove = [], []
            if star is True:
                add.append("STARRED")
            elif star is False:
                remove.append("STARRED")
            if read is True:
                remove.append("UNREAD")
            elif read is False:
                add.append("UNREAD")
            if archived is True:
                remove.append("INBOX")
            elif archived is False:
                add.append("INBOX")
            if add or remove:
                conn.modify_labels(s, ext, add=add, remove=remove)
        elif acct.provider == "outlook":
            if trash:
                conn.delete_message(s, ext)
                return
            patch: dict = {}
            if read is not None:
                patch["isRead"] = read
            if star is not None:
                patch["flag"] = {"flagStatus": "flagged" if star else "notFlagged"}
            if patch:
                conn.patch_message(s, ext, patch)
            if archived is True:
                conn.move_message(s, ext, "archive")
            elif archived is False:
                conn.move_message(s, ext, "inbox")
    except httpx.HTTPStatusError as e:
        # 권한 부족(읽기 전용 토큰 등)·인증 만료 → 500 대신 안내 가능한 4xx 로.
        code = e.response.status_code
        if code in (401, 403):
            raise HTTPException(
                403,
                "메일 계정 권한이 부족해요. 설정 → 연동에서 이 계정을 다시 연결하면 "
                "삭제·정리(읽음/별표/보관)를 사용할 수 있어요.",
            ) from e
        raise HTTPException(502, "메일 제공자 요청이 실패했어요. 잠시 후 다시 시도해주세요.") from e


@router.patch("/mail/{eid}/star", response_model=EmailRowOut)
def star_mail(eid: str, body: MailStarRequest, s: Session = Depends(get_session)):
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    _apply_remote(s, eid, star=body.starred)
    e.starred = body.starred
    s.add(e)
    s.commit()
    return _row_out(e)


@router.patch("/mail/{eid}/read", response_model=EmailRowOut)
def read_mail(eid: str, body: MailReadRequest, s: Session = Depends(get_session)):
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    _apply_remote(s, eid, read=body.read)
    e.read = body.read
    s.add(e)
    s.commit()
    return _row_out(e)


@router.post("/mail/{eid}/archive", response_model=EmailRowOut)
def archive_mail(eid: str, s: Session = Depends(get_session)):
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    _apply_remote(s, eid, archived=True)
    e.archived = True
    s.add(e)
    s.commit()
    return _row_out(e)


@router.post("/mail/{eid}/unarchive", response_model=EmailRowOut)
def unarchive_mail(eid: str, s: Session = Depends(get_session)):
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    _apply_remote(s, eid, archived=False)
    e.archived = False
    s.add(e)
    s.commit()
    return _row_out(e)


@router.delete("/mail/{eid}")
def delete_mail(eid: str, s: Session = Depends(get_session)):
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    _apply_remote(s, eid, trash=True)
    link = _email_link(s, eid)
    if link:
        s.delete(link)
    s.delete(e)
    s.commit()
    return {"ok": True, "id": eid}


# ── 라벨/카테고리(폴더 정리) ──
@router.patch("/mail/{eid}/labels", response_model=EmailRowOut)
def label_mail(eid: str, body: MailLabelRequest, s: Session = Depends(get_session)):
    """라벨 추가/제거(로컬 태깅). Outlook 은 임의 카테고리라 원격에도 반영."""
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    labels = list(e.labels or [])
    remove = set(body.remove)
    for lab in body.add:
        if lab and lab not in labels:
            labels.append(lab)
    labels = [lab for lab in labels if lab not in remove]
    e.labels = labels
    link = _email_link(s, eid)
    if link:
        acct = s.get(ConnectorAccount, link.account_id)
        if acct and acct.state == ConnState.connected and acct.provider == "outlook":
            ConnectorRegistry.get(s, acct).patch_message(
                s, link.external_id, {"categories": labels}
            )
    s.add(e)
    s.commit()
    return _row_out(e)


# ── 첨부 다운로드(연결된 Gmail/Outlook 에서 실시간 스트리밍) ──
# inline=1 → 본문 HTML 의 인라인 이미지(cid)용. 첨부 다운로드 대신 브라우저 내 표시.
@router.get("/mail/{eid}/attachments/{att_id}")
def download_attachment(
    eid: str, att_id: str, inline: int = 0, s: Session = Depends(get_session)
):
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    meta = next((a for a in (e.attachments or []) if a.get("id") == att_id), None)
    if not meta:
        raise HTTPException(404, "attachment not found")
    link = _email_link(s, eid)
    if not link:
        raise HTTPException(400, "원격 메일 첨부만 내려받을 수 있어요")
    acct = s.get(ConnectorAccount, link.account_id)
    if not acct or acct.state != ConnState.connected:
        raise HTTPException(400, "연결된 메일 계정이 필요해요")
    conn = ConnectorRegistry.get(s, acct)
    if not hasattr(conn, "get_attachment"):
        raise HTTPException(400, "이 계정은 첨부 다운로드를 지원하지 않아요")
    data = conn.get_attachment(s, link.external_id, att_id)
    name = meta.get("name", "attachment")
    disp = "inline" if inline else "attachment"
    return Response(
        content=data,
        media_type=meta.get("mime") or "application/octet-stream",
        headers={"Content-Disposition": f"{disp}; filename*=UTF-8''{quote(name)}"},
    )


# ── 대화(스레드) 보기 ──
@router.get("/mail/{eid}/thread", response_model=ConversationOut)
def mail_thread(
    eid: str,
    s: Session = Depends(get_session),
    user: Person = Depends(current_user),
):
    """같은 thread_id 메일을 시간순으로 묶어 반환."""
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    if not e.thread_id:
        return ConversationOut(
            thread_id="", subject=e.subject, count=1, messages=[_row_out(e)]
        )
    rows = s.exec(
        select(Email).where(
            Email.thread_id == e.thread_id, Email.user_id == user.id
        )
    ).all()
    rows.sort(key=lambda x: x.created_at)
    return ConversationOut(
        thread_id=e.thread_id,
        subject=e.subject,
        count=len(rows),
        messages=[_row_out(x) for x in rows],
    )


# ── 여러 메일 일괄 처리 ──
def _apply_single(s: Session, e: Email, action: str) -> bool:
    """한 메일에 액션 적용(원격+로컬). trash 면 삭제하고 False 반환(행 사라짐)."""
    if action in ("read", "unread"):
        val = action == "read"
        _apply_remote(s, e.id, read=val)
        e.read = val
    elif action in ("star", "unstar"):
        val = action == "star"
        _apply_remote(s, e.id, star=val)
        e.starred = val
    elif action in ("archive", "unarchive"):
        val = action == "archive"
        _apply_remote(s, e.id, archived=val)
        e.archived = val
    elif action == "trash":
        _apply_remote(s, e.id, trash=True)
        link = _email_link(s, e.id)
        if link:
            s.delete(link)
        s.delete(e)
        return False
    s.add(e)
    return True


_BULK_ACTIONS = {"read", "unread", "archive", "unarchive", "trash", "star", "unstar"}


@router.post("/mail/bulk")
def bulk_mail(body: BulkMailRequest, s: Session = Depends(get_session)):
    """선택한 여러 메일에 같은 액션(읽음/보관/삭제/별표)을 한 번에 적용."""
    if body.action not in _BULK_ACTIONS:
        raise HTTPException(400, f"action must be one of {sorted(_BULK_ACTIONS)}")
    n = 0
    for eid in body.ids:
        e = s.get(Email, eid)
        if not e:
            continue
        _apply_single(s, e, body.action)
        n += 1
    s.commit()
    return {"ok": True, "count": n, "action": body.action}


def _make_task(s: Session, e: Email, t: dict) -> Task:
    pid = PROJECT_LABEL_MAP.get(t["project"], "me")
    if not s.get(Project, pid):
        pid = "me"
    tid = "t-" + uuid.uuid4().hex[:8]
    task = Task(
        id=tid,
        title=t["text"],
        project_id=pid,
        status=TaskStatus.todo,
        assignee_id="jiwoo",
        prio=Prio(t.get("prio") or "보통"),
        notes=f"‘{e.subject}’ 메일에서 아리가 추출한 작업이에요.",
    )
    s.add(task)
    s.commit()
    s.refresh(task)
    return task


@router.post("/mail/{eid}/extract", response_model=ExtractResponse)
def extract(eid: str, body: ExtractRequest, s: Session = Depends(get_session)):
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    ai = e.ai_json or {}
    if body.kind == "task":
        task = _make_task(s, e, ai["tasks"][body.index])
        node = to_node(s, task, s.exec(select(Task)).all())
        return ExtractResponse(kind="task", task=node, message="작업에 추가했어요")
    if body.kind == "event":
        ev = ai["events"][body.index]
        ev_id = create_event_from_extract(s, ev, source=f"mail:{eid}")
        return ExtractResponse(kind="event", event_id=ev_id, message="일정에 추가했어요")
    if body.kind == "file":
        proj = ai["file"]["project"]
        return ExtractResponse(kind="file", project=proj, message=f"‘{proj}’에 정리했어요")
    raise HTTPException(400, "kind must be task|event|file")


@router.post("/mail/{eid}/extract-all", response_model=ExtractAllResponse)
def extract_all(eid: str, s: Session = Depends(get_session)):
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    ai = e.ai_json or {}
    task_ids, event_ids = [], []
    for t in ai.get("tasks", []):
        task_ids.append(_make_task(s, e, t).id)
    for ev in ai.get("events", []):
        event_ids.append(create_event_from_extract(s, ev, source=f"mail:{eid}"))
    filed = ai.get("file", {}).get("project") if ai.get("file") else None
    return ExtractAllResponse(
        created_task_ids=task_ids,
        created_event_ids=event_ids,
        filed_project=filed,
        message="아리가 준비한 작업을 모두 적용했어요",
    )


@router.post("/mail/triage/analyze")
def triage_analyze(
    limit: int = Query(4, ge=1, le=12),
    s: Session = Depends(get_session),
    user: Person = Depends(current_user),
    provider: LLMProvider = Depends(get_provider),
):
    """아리 비서: 안읽음 집중 받은편지함 중 미분석 메일을 LLM 으로 배치 분석(best-effort).
    한 번에 limit 통만 처리하고 remaining 으로 남은 수를 알려 점진 분석을 가능케 한다."""
    from ..services.mail_ai import analyze_email

    since = _ensure_triage_since(s)
    rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id]
    todo = [
        e
        for e in rows
        if not e.ai_json
        and _email_in_folder(e, "smart")
        and _is_new_mail(e, since)  # 기준 시각 이후 새 메일(읽음 무관 — 폰서 먼저 읽어도 정리)
    ]
    todo.sort(key=_email_sort_key, reverse=True)
    analyzed, errors = 0, 0
    for e in todo[:limit]:
        try:
            analyze_email(s, e, provider)
            analyzed += 1
        except Exception:  # LLM 일시 오류·파싱 실패는 건너뛴다(다음 호출서 재시도).
            errors += 1
    return {"analyzed": analyzed, "remaining": max(0, len(todo) - analyzed), "errors": errors}


def _triage_new_count(s: Session, user: Person, since: datetime) -> int:
    """기준 시각 이후 도착한 집중(focused) 새 메일 수 — 아리 정리 배지(읽음 여부 무관).
    사용자가 폰/다른 앱에서 먼저 읽어도 '정리할 새 메일'로 센다(서버 read 가 곧 True 가 되므로)."""
    rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id]
    return sum(1 for e in rows if _email_in_folder(e, "smart") and _is_new_mail(e, since))


@router.get("/mail/triage/baseline")
def triage_baseline_get(
    s: Session = Depends(get_session),
    user: Person = Depends(current_user),
):
    """아리 정리 기준 시각(ISO) + 그 이후 도착한 새 메일 수 — 없으면 지금으로 초기화."""
    since = _ensure_triage_since(s)
    return {"since": since.isoformat(), "count": _triage_new_count(s, user, since)}


@router.post("/mail/triage/baseline")
def triage_baseline_reset(
    s: Session = Depends(get_session),
    user: Person = Depends(current_user),
):
    """기준 시각을 지금으로 재설정 — 이후 도착하는 새 메일부터 다시 정리."""
    since = _set_triage_since(s, now())
    return {"since": since.isoformat(), "count": _triage_new_count(s, user, since)}


@router.post("/mail/{eid}/reply-draft", response_model=ReplyDraftOut)
def reply_draft(
    eid: str,
    body: ReplyDraftRequest,
    s: Session = Depends(get_session),
    provider: LLMProvider = Depends(get_provider),
):
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    p = s.get(Person, e.from_key)
    to = f"{p.name} <{p.email}>" if p and getattr(p, "email", "") else (p.name if p else e.from_key)
    ai = e.ai_json or {}
    if body.reply_index is not None and ai.get("replies"):
        draft = ai["replies"][body.reply_index]["body"]
    else:
        prompt = (
            "이 메일에 보낼 정중한 한국어 회신 초안 한 문단:\n"
            f"{e.subject}\n{' '.join(e.body or [])}"
        )
        # 실 LLM 전용(휴리스틱 템플릿 폴백 제거) — 미가용 시 예외 전파.
        draft = provider.generate_json(
            prompt, {"type": "object", "properties": {"body": {"type": "string"}}}
        ).get("body", "")
    return ReplyDraftOut(to=to, from_account=e.account, subject="Re: " + e.subject, body=draft)


def _connector_for_mail_account(s: Session, mail_account: str) -> ConnectorAccount | None:
    """발신 MailAccount id → 연결된 real ConnectorAccount(없으면 None=mock 발송)."""
    rows = s.exec(
        select(ConnectorAccount).where(ConnectorAccount.domain == ConnectorDomain.mail)
    ).all()
    for a in rows:
        if a.external_account_id and mail_account_id_for(a.external_account_id) == mail_account:
            return a
    return None


@router.post("/mail/send", response_model=SendResponse)
def send(body: SendRequest, s: Session = Depends(get_session)):
    """회신/새 메일 보내기 = high-risk → 결재함. 승인 시 연결된 real 계정으로 실제 발송(phase-16+).
    연결된 계정이 없으면 거부(가짜 발송 없음)."""
    conn_acct = _connector_for_mail_account(s, body.from_account)
    if not conn_acct or conn_acct.state != ConnState.connected:
        raise HTTPException(400, "연결된 메일 계정이 필요해요 — 계정을 먼저 연결하세요")
    aid = "ap-" + uuid.uuid4().hex[:8]
    appr = Approval(
        id=aid,
        icon="mail",
        tone="violet",
        risk="high",
        status="pending",
        source="mail",
        time="보내기 대기",
        title=f"{body.to}에게 메일을 보낼 준비가 됐어요",
        detail=f"제목: {body.subject}\n\n{body.body[:120]}…",
        cta="보내기",
        alt="수정",
        undo_label="",
    )
    s.add(appr)
    s.add(
        OutboundMail(
            id="ob-" + uuid.uuid4().hex[:8],
            approval_id=aid,
            connector_account_id=conn_acct.id,
            mail_account=body.from_account,
            to=body.to,
            cc=body.cc or "",
            bcc=body.bcc or "",
            subject=body.subject,
            body=body.body,
            attachments=[att.model_dump() for att in body.attachments],
            status="pending",
        )
    )
    s.commit()
    return SendResponse(
        approval_id=aid, status="pending", message="보낼 준비가 됐어요 — 결재함에서 확인하세요"
    )
