# backend/app/connectors/mail/real_outlook.py — Microsoft Graph 메일 sync 커넥터 (phase-16)
# OAuth2 액세스 토큰으로 Graph REST(/me/messages) 증분 호출 + /me/sendMail 발송.
import re
from datetime import UTC, datetime

import httpx
from sqlmodel import Session, select

from ...config import get_settings
from ...models import Email, ExternalLink
from ..base import BaseConnector, NormalizedRecord, RawRecord
from ..oauth import valid_access_token
from ..ratelimit import RateLimiter
from .normalize import (
    folder_dict,
    mail_account_id_for,
    normalize_email,
    replace_account_folders,
    upsert_email,
)

GRAPH = "https://graph.microsoft.com/v1.0"

# 잘 알려진 폴더(slug). sent/drafts/outbox 는 메시지를 서버에서 안 가져오고 표시만(또는 숨김).
_WELLKNOWN = {
    "inbox": "inbox",
    "junkemail": "spam",
    "deleteditems": "trash",
    "archive": "archive",
    "sentitems": "_skip",
    "drafts": "_skip",
    "outbox": "_skip",
}
# 시스템 폴더 표시 메타: slug → (한글명, 아이콘, 정렬). 사이드바 트리 노출용.
_SYS_META = {
    "inbox": ("받은편지함", "inbox", 0),
    "spam": ("스팸", "alert", 60),
    "trash": ("휴지통", "trash", 65),
    "archive": ("보관함", "archive", 45),
}
# 사이드바에서도 숨길 노이즈 폴더(표시명, 소문자). 로캘 영향 최소화 위해 잘 알려진 것만.
_NOISE_NAMES = {
    "conversation history",
    "sync issues",
    "scheduled",
    "subscription",
    "rss feeds",
    "outbox",
    "drafts",
    "sent items",
}

# 계정별 well-known 폴더 id 캐시(id 는 안정적 → 매 sync 재해석 방지).
_WK_ID_CACHE: dict[str, dict[str, str]] = {}


def _parse_dt(s):
    if not s:
        return None
    try:
        return datetime.fromisoformat(s.replace("Z", "+00:00"))
    except ValueError:
        return None


def _addrs(to: str) -> list[dict]:
    """'이름 <a@b.com>, c@d.com' → Graph toRecipients."""
    out = []
    for part in (to or "").split(","):
        part = part.strip()
        if not part:
            continue
        m = re.search(r"<([^>]+)>", part)
        out.append({"emailAddress": {"address": m.group(1) if m else part}})
    return out


class OutlookConnector(BaseConnector):
    domain = "mail"
    entity_type = "email"
    _rl = RateLimiter(rate=4, per=1.0)  # Graph throttling 보수적 제한

    def _wellknown_ids(self, c, auth) -> dict[str, str]:
        """well-known 폴더 id → slug 매핑(계정별 캐시). inbox/spam/trash/archive 식별용."""
        key = self.account.external_account_id or self.account.id
        if key in _WK_ID_CACHE:
            return _WK_ID_CACHE[key]
        out: dict[str, str] = {}
        for wk, slug in _WELLKNOWN.items():
            self._rl.acquire()
            r = c.get(f"{GRAPH}/me/mailFolders/{wk}", headers=auth, params={"$select": "id"})
            if r.status_code == 200:
                out[r.json()["id"]] = slug
        _WK_ID_CACHE[key] = out
        return out

    def _sync_folders(self, session, c, auth):
        """mailFolders 목록 → AccountFolder 미러 갱신 + 메시지 수집 대상 반환.
        반환: [(folder_id, slug, email_folder_name)] — 이번 sync 에서 메시지를 가져올 폴더."""
        wk = self._wellknown_ids(c, auth)
        self._rl.acquire()
        r = c.get(
            f"{GRAPH}/me/mailFolders",
            headers=auth,
            params={
                "$top": 100,
                "$select": "id,displayName,unreadItemCount,totalItemCount",
            },
        )
        self._rl.handle_response(r)
        r.raise_for_status()
        macct = mail_account_id_for(self.account.external_account_id)
        # 집중/기타(focused/other)는 받은편지함 안의 탭(slug others)으로 처리 — 별도 폴더 없음.
        folders: list[dict] = [folder_dict("starred", "별표", "star", 40)]
        fetch_ids: list[tuple] = []  # (id, slug, name_for_email)
        for f in r.json().get("value", []):
            fid = f["id"]
            name = f.get("displayName", "")
            unread = int(f.get("unreadItemCount") or 0)
            total = int(f.get("totalItemCount") or 0)
            slug = wk.get(fid)
            if slug == "_skip" or name.lower() in _NOISE_NAMES:
                continue
            if slug in _SYS_META:  # 받은편지함/스팸/휴지통/보관함
                kn, icon, sort = _SYS_META[slug]
                folders.append(
                    folder_dict(slug, kn, icon, sort, provider_id=fid, unread=unread, total=total)
                )
                fetch_ids.append((fid, slug, ""))
            elif slug is None and total > 0:  # 사용자 커스텀 폴더(내용 있는 것만)
                cslug = f"of:{fid}"
                folders.append(
                    folder_dict(cslug, name, "folder", 10, kind="custom",
                                provider_id=fid, unread=unread, total=total)
                )
                fetch_ids.append((fid, cslug, name))
        # 보낸편지함/임시보관함은 OutboundMail(우리 발송) 경로로 표시 — 트리 항목만 추가.
        folders.append(folder_dict("sent", "보낸편지함", "send", 50))
        folders.append(folder_dict("drafts", "임시보관함", "pen", 55))
        replace_account_folders(session, macct, folders)
        return fetch_ids

    def _reconcile_status(self, session, c, auth, fetch_ids) -> None:
        """최근 받은편지함/스팸을 $select 최소(본문 제외)로 재조회해 두 가지를 맞춘다:
        ① 서버에서 읽음 처리된 기존 메일의 read/star 반영,
        ② 서버 폴더에서 사라진(이동/삭제된) 우리 메일 제거 — 윈도우 범위 내 유령 정리."""
        macct = mail_account_id_for(self.account.external_account_id)
        links = session.exec(
            select(ExternalLink).where(
                ExternalLink.account_id == self.account.id,
                ExternalLink.entity_type == "email",
            )
        ).all()
        ext_to_link = {lk.external_id: lk for lk in links}
        eid_to_ext = {lk.entity_id: lk.external_id for lk in links}
        for fid, slug, _ in fetch_ids:
            if slug not in ("inbox", "spam"):
                continue
            self._rl.acquire()
            r = c.get(
                f"{GRAPH}/me/mailFolders/{fid}/messages",
                headers=auth,
                params={
                    # 동기화한 메일 전체를 덮도록 넉넉히(본문 제외라 1콜로 가볍다).
                    "$top": 250,
                    "$orderby": "receivedDateTime desc",
                    # inferenceClassification 도 함께 받아 기존 메일의 집중/기타 분류 backfill.
                    "$select": "id,isRead,flag,receivedDateTime,inferenceClassification",
                },
            )
            if r.status_code != 200:
                continue
            server_ids: set[str] = set()
            cutoff: datetime | None = None  # 받아온 윈도우의 가장 오래된 수신시각
            for m in r.json().get("value", []):
                server_ids.add(m["id"])
                rcv = _parse_dt(m.get("receivedDateTime", ""))
                if rcv and (cutoff is None or rcv < cutoff):
                    cutoff = rcv
                link = ext_to_link.get(m["id"])
                if not link:
                    continue
                e = session.get(Email, link.entity_id)
                if not e:
                    continue
                e.read = bool(m.get("isRead", e.read))
                e.starred = (m.get("flag") or {}).get("flagStatus") == "flagged"
                if slug == "inbox":  # 집중/기타 분류 갱신(기존 메일 backfill 포함)
                    e.inference = m.get("inferenceClassification") or e.inference
                session.add(e)
            # ② 유령 제거: 이 폴더 우리 메일 중 윈도우 범위(>=cutoff)인데 서버에 없으면 이동/삭제됨.
            if cutoff is not None:
                ours = session.exec(
                    select(Email).where(Email.account == macct, Email.folder == slug)
                ).all()
                for e in ours:
                    ext = eid_to_ext.get(e.id)
                    rcv = e.received_at
                    if rcv is not None and rcv.tzinfo is None:
                        rcv = rcv.replace(tzinfo=UTC)
                    if ext and ext not in server_ids and rcv is not None and rcv >= cutoff:
                        lk = ext_to_link.get(ext)
                        if lk:
                            session.delete(lk)
                        session.delete(e)
        session.commit()

    def fetch(self, session: Session, *, full: bool = False):
        st = self._state_row(session)
        token = valid_access_token(session, self.account)  # token_expired 면 예외 → sync error
        cfg = get_settings()
        auth = {"Authorization": f"Bearer {token}"}
        headers = {
            **auth,
            # 원본 HTML 본문(서식·이미지 보존) — Reader 가 샌드박스 iframe 으로 렌더.
            "Prefer": 'outlook.body-content-type="html"',
        }
        expand = (
            "attachments($select=id,name,contentType,size,isInline,"
            "microsoft.graph.fileAttachment/contentId)"
        )
        latest = st.cursor or ""
        with httpx.Client(timeout=cfg.connector_http_timeout) as c:
            fetch_ids = self._sync_folders(session, c, auth)
            # 읽음/별표 상태 동기화(증분 fetch 가 안 건드리는 기존 메일) — 가벼운 재조회.
            self._reconcile_status(session, c, auth, fetch_ids)
            for fid, slug, fname in fetch_ids:
                # 받은편지함/스팸만 증분(매 sync) — 나머지(휴지통/보관함/커스텀)는 full 때만.
                light = slug in ("inbox", "spam")
                if not light and not full:
                    continue
                params = {
                    "$top": cfg.sync_page_size,
                    "$orderby": "receivedDateTime desc",
                    "$expand": expand,
                }
                if st.cursor and not full and light:
                    params["$filter"] = f"receivedDateTime ge {st.cursor}"
                self._rl.acquire()
                r = c.get(
                    f"{GRAPH}/me/mailFolders/{fid}/messages", headers=headers, params=params
                )
                self._rl.handle_response(r)
                r.raise_for_status()
                for msg in r.json().get("value", []):
                    rcv = msg.get("receivedDateTime", "")
                    if rcv and rcv > latest:
                        latest = rcv
                    # 폴더 라우팅 정보를 payload 에 주입 → normalize 가 Email.folder 로 사용.
                    msg["_folder"] = slug
                    msg["_folder_name"] = fname
                    yield RawRecord(
                        external_id=msg["id"],
                        payload=msg,
                        etag=str(msg.get("@odata.etag", "")),
                        external_updated_at=_parse_dt(rcv),
                    )
        if latest:
            st.cursor = latest
            session.add(st)
            session.commit()

    def normalize(self, raw: RawRecord) -> NormalizedRecord:
        return normalize_email(self.account, raw, provider="outlook")

    def write(self, session: Session, norm: NormalizedRecord):
        return upsert_email(session, norm)

    def event_for(self, norm, entity_id):
        return "mail.received"

    def patch_message(self, session: Session, external_id: str, payload: dict) -> None:
        """읽음(isRead)·플래그(flag) 등 메시지 속성을 Graph 에 반영."""
        token = valid_access_token(session, self.account)
        with httpx.Client(timeout=get_settings().connector_http_timeout) as c:
            self._rl.acquire()
            r = c.patch(
                f"{GRAPH}/me/messages/{external_id}",
                headers={"Authorization": f"Bearer {token}"},
                json=payload,
            )
            self._rl.handle_response(r)
            r.raise_for_status()

    def move_message(self, session: Session, external_id: str, dest: str) -> None:
        """메일을 폴더로 이동(dest = 'archive' | 'inbox' | 'deleteditems')."""
        token = valid_access_token(session, self.account)
        with httpx.Client(timeout=get_settings().connector_http_timeout) as c:
            self._rl.acquire()
            r = c.post(
                f"{GRAPH}/me/messages/{external_id}/move",
                headers={"Authorization": f"Bearer {token}"},
                json={"destinationId": dest},
            )
            self._rl.handle_response(r)
            r.raise_for_status()

    def delete_message(self, session: Session, external_id: str) -> None:
        """메일 삭제(지운 편지함으로 이동)."""
        token = valid_access_token(session, self.account)
        with httpx.Client(timeout=get_settings().connector_http_timeout) as c:
            self._rl.acquire()
            r = c.delete(
                f"{GRAPH}/me/messages/{external_id}",
                headers={"Authorization": f"Bearer {token}"},
            )
            self._rl.handle_response(r)
            if r.status_code not in (204, 404):
                r.raise_for_status()

    def get_attachment(
        self, session: Session, external_id: str, attachment_id: str
    ) -> bytes:
        """Outlook 첨부 본문(bytes) 다운로드."""
        import base64

        token = valid_access_token(session, self.account)
        with httpx.Client(timeout=get_settings().connector_http_timeout) as c:
            self._rl.acquire()
            r = c.get(
                f"{GRAPH}/me/messages/{external_id}/attachments/{attachment_id}",
                headers={"Authorization": f"Bearer {token}"},
            )
            self._rl.handle_response(r)
            r.raise_for_status()
            content = r.json().get("contentBytes", "")
        return base64.b64decode(content)

    def send_mail(
        self,
        session: Session,
        *,
        to: str,
        subject: str,
        body: str,
        cc: str = "",
        bcc: str = "",
        attachments: list[dict] | None = None,
    ) -> str:
        token = valid_access_token(session, self.account)
        cfg = get_settings()
        message: dict = {
            "subject": subject,
            "body": {"contentType": "Text", "content": body},
            "toRecipients": _addrs(to),
        }
        if cc:
            message["ccRecipients"] = _addrs(cc)
        if bcc:
            message["bccRecipients"] = _addrs(bcc)
        if attachments:
            message["attachments"] = [
                {
                    "@odata.type": "#microsoft.graph.fileAttachment",
                    "name": att.get("name", "attachment"),
                    "contentType": att.get("mime") or "application/octet-stream",
                    "contentBytes": att.get("data_b64", ""),
                }
                for att in attachments
            ]
        payload = {"message": message, "saveToSentItems": True}
        with httpx.Client(timeout=cfg.connector_http_timeout) as c:
            self._rl.acquire()
            r = c.post(
                f"{GRAPH}/me/sendMail",
                headers={"Authorization": f"Bearer {token}"},
                json=payload,
            )
            self._rl.handle_response(r)
            r.raise_for_status()
        return "sent"
