#!/usr/bin/env python3
"""
winston_graph.py — App-only Microsoft Graph client for Winston (Email Bot).

Provides file-read (OneDrive/Files.ReadWrite.All) and mail-draft-create
(Mail.ReadWrite scoped to sales@icastinc.com) operations using the same
MSAL client-credentials pattern as notifications.py.

Required env vars (same as Togen's existing Graph setup):
    AZURE_CLIENT_ID        App registration client/application ID
    AZURE_CLIENT_SECRET    App registration client secret
    AZURE_TENANT_ID        Azure AD tenant ID

Winston-specific env vars:
    WINSTON_ONEDRIVE_USER  UPN or object ID of the user whose OneDrive
                           holds the Takeoffs/Completed Quotes folders
                           (e.g. "administrator@icastinc.com")
    WINSTON_MAIL_FROM      Mailbox to create drafts in (default: sales@icastinc.com)
"""

import base64
import json
import logging
import os
import time
import uuid
from pathlib import Path

import msal
import requests

log = logging.getLogger("togen.winston_graph")

_SETTINGS_FILE = Path(__file__).resolve().parent / "winston_settings.json"


def _winston_settings() -> dict:
    """Load winston_settings.json (best-effort). Saved values take precedence
    over env vars for Azure/OneDrive config so admins can update them in the UI
    without editing the systemd service file (DVI-829)."""
    if _SETTINGS_FILE.is_file():
        try:
            data = json.loads(_SETTINGS_FILE.read_text())
            if isinstance(data, dict):
                return data
        except (json.JSONDecodeError, OSError):
            pass
    return {}


def get_onedrive_user() -> str:
    """Effective OneDrive user UPN: settings file preferred, env var fallback."""
    return _winston_settings().get("onedrive_user", "") or WINSTON_ONEDRIVE_USER

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
_AZ_CLIENT_ID = os.environ.get("AZURE_CLIENT_ID", "")
_AZ_SECRET    = os.environ.get("AZURE_CLIENT_SECRET", "")
_AZ_TENANT    = os.environ.get("AZURE_TENANT_ID", "")
_AZ_AUTHORITY = f"https://login.microsoftonline.com/{_AZ_TENANT}" if _AZ_TENANT else ""

WINSTON_ONEDRIVE_USER = os.environ.get("WINSTON_ONEDRIVE_USER", "")
WINSTON_MAIL_FROM     = os.environ.get("WINSTON_MAIL_FROM", "sales@icastinc.com")
WINSTON_SERVICE_USER  = os.environ.get("WINSTON_SERVICE_USER", "togen@icastinc.com")

_SCRATCH_FOLDER = "winston-pipeline-scratch"

_GRAPH_SCOPES = ["https://graph.microsoft.com/.default"]
_GRAPH_BASE   = "https://graph.microsoft.com/v1.0"

# SharePoint site drive (DVI-947) --------------------------------------------
# The pipeline's SHARED Take-offs/Completed Quotes folders live in a SharePoint
# document library, NOT a personal OneDrive. The pipeline account (togen@) has
# no personal site, so /users/{upn}/drive returns 404 "User's mysite not found".
# Shared reads/uploads must therefore address the site's default drive directly.
# Server-relative site form for GET /sites/{host}:/sites/{name}.
_DEFAULT_SP_SITE = "icastinc.sharepoint.com:/sites/InfrastructurePrecastInc"
# Sentinel used by winston_pipeline to mark "resolve via the SharePoint site
# drive" instead of a real user OneDrive UPN.
SITE_DRIVE_UPN = "[sharepoint-site]"
_SITE_DRIVE_TTL = 3600.0
_site_drive_cache: dict[str, tuple[float, str]] = {}


def get_sharepoint_site() -> str:
    """Effective SharePoint site (settings preferred, built-in default)."""
    return _winston_settings().get("sharepoint_site", "") or _DEFAULT_SP_SITE


def resolve_site_drive_id(token: str, site: str | None = None) -> str | None:
    """Resolve the default document-library driveId for a SharePoint site.

    ``site`` is the "{host}:/sites/{name}" server-relative form; defaults to the
    InfrastructurePrecastInc site (DVI-896/DVI-947). Cached for ``_SITE_DRIVE_TTL``
    seconds to avoid a two-call resolve on every pipeline run.
    """
    site = site or get_sharepoint_site()
    now = time.time()
    cached = _site_drive_cache.get(site)
    if cached and (now - cached[0]) < _SITE_DRIVE_TTL:
        return cached[1]
    try:
        r = requests.get(f"{_GRAPH_BASE}/sites/{site}", headers=_auth_header(token), timeout=30)
        if r.status_code != 200:
            log.error("resolve_site_drive_id: site lookup failed (%s) for %s: %s",
                      r.status_code, site, r.text[:300])
            return None
        site_id = r.json().get("id")
        if not site_id:
            log.error("resolve_site_drive_id: site %s returned no id", site)
            return None
        dr = requests.get(f"{_GRAPH_BASE}/sites/{site_id}/drive",
                          headers=_auth_header(token), timeout=30)
        if dr.status_code != 200:
            log.error("resolve_site_drive_id: drive lookup failed (%s): %s",
                      dr.status_code, dr.text[:300])
            return None
        drive_id = dr.json().get("id")
        if not drive_id:
            log.error("resolve_site_drive_id: site %s default drive has no id", site)
            return None
        _site_drive_cache[site] = (now, drive_id)
        return drive_id
    except Exception:
        log.exception("resolve_site_drive_id error")
        return None


# ---------------------------------------------------------------------------
# Token acquisition
# ---------------------------------------------------------------------------
def acquire_graph_token() -> str | None:
    """Acquire an app-only Graph token via client credentials. Returns token or None.

    Saved settings take precedence over env vars (DVI-829)."""
    s = _winston_settings()
    client_id = s.get("azure_client_id", "") or _AZ_CLIENT_ID
    secret    = s.get("azure_client_secret", "") or _AZ_SECRET
    tenant    = s.get("azure_tenant_id", "") or _AZ_TENANT
    authority = f"https://login.microsoftonline.com/{tenant}" if tenant else ""
    if not (client_id and secret and authority):
        log.warning("Graph token unavailable: AZURE_CLIENT_ID/SECRET/TENANT_ID not configured")
        return None
    try:
        client = msal.ConfidentialClientApplication(
            client_id, authority=authority, client_credential=secret)
        result = client.acquire_token_for_client(scopes=_GRAPH_SCOPES)
        token = result.get("access_token")
        if not token:
            log.error("Graph token request failed: %s", result.get("error_description", result))
        return token
    except Exception:
        log.exception("Graph token acquisition error")
        return None


def _auth_header(token: str) -> dict:
    return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}


# ---------------------------------------------------------------------------
# OneDrive / Files operations  (requires Files.ReadWrite.All application permission)
# ---------------------------------------------------------------------------
def list_drives(token: str) -> list[dict] | None:
    """List all drives visible to the app (useful for initial discovery)."""
    try:
        resp = requests.get(f"{_GRAPH_BASE}/drives", headers=_auth_header(token), timeout=30)
        if resp.status_code != 200:
            log.error("list_drives failed (%s): %s", resp.status_code, resp.text[:300])
            return None
        return resp.json().get("value", [])
    except Exception:
        log.exception("list_drives error")
        return None


def drive_read_file(token: str, drive_id: str, drive_path: str) -> bytes | None:
    """Download raw bytes of a file at drive_path inside the specified drive.

    drive_path is relative to root, e.g. "Takeoffs/2026-06-29_tasks.json".
    Returns file bytes or None on error.
    """
    url = f"{_GRAPH_BASE}/drives/{drive_id}/root:/{drive_path}:/content"
    try:
        resp = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=60)
        if resp.status_code != 200:
            log.error("drive_read_file failed (%s) for %s: %s",
                      resp.status_code, drive_path, resp.text[:300])
            return None
        return resp.content
    except Exception:
        log.exception("drive_read_file error")
        return None


def user_drive_read_file(token: str, user_upn: str, drive_path: str) -> bytes | None:
    """Download a file from a user's OneDrive by UPN.

    Requires Files.ReadWrite.All (application). drive_path is relative to root.
    """
    url = f"{_GRAPH_BASE}/users/{user_upn}/drive/root:/{drive_path}:/content"
    try:
        resp = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=60)
        if resp.status_code != 200:
            log.error("user_drive_read_file failed (%s) for %s/%s: %s",
                      resp.status_code, user_upn, drive_path, resp.text[:300])
            return None
        return resp.content
    except Exception:
        log.exception("user_drive_read_file error")
        return None


def user_drive_list_folder(token: str, user_upn: str, folder_path: str) -> list[dict] | None:
    """List folder contents from a user's OneDrive. Returns item list or None."""
    url = f"{_GRAPH_BASE}/users/{user_upn}/drive/root:/{folder_path}:/children"
    try:
        resp = requests.get(url, headers=_auth_header(token), timeout=30)
        if resp.status_code != 200:
            log.error("user_drive_list_folder failed (%s) for %s/%s: %s",
                      resp.status_code, user_upn, folder_path, resp.text[:300])
            return None
        return resp.json().get("value", [])
    except Exception:
        log.exception("user_drive_list_folder error")
        return None


def user_drive_list_folder_detail(
    token: str, user_upn: str, folder_path: str, top: int = 200
) -> dict:
    """List a OneDrive folder returning diagnostic detail (DVI-943).

    Unlike ``user_drive_list_folder`` (which collapses every failure to None),
    this surfaces the HTTP status and Graph error text so the Files views can
    show *why* the Logic pipeline's account can or cannot reach a path.

    Returns a dict: ``{status, items, truncated, error}`` where ``items`` is a
    list on success or None on failure.
    """
    url = f"{_GRAPH_BASE}/users/{user_upn}/drive/root:/{folder_path}:/children"
    params = {
        "$top": top,
        "$select": "id,name,size,folder,file,lastModifiedDateTime,@microsoft.graph.downloadUrl",
    }
    try:
        resp = requests.get(url, headers=_auth_header(token), params=params, timeout=30)
    except Exception as exc:  # noqa: BLE001 — report the transport failure verbatim
        log.exception("user_drive_list_folder_detail error")
        return {"status": 0, "items": None, "truncated": False, "error": str(exc)}
    if resp.status_code != 200:
        log.error("user_drive_list_folder_detail failed (%s) for %s/%s: %s",
                  resp.status_code, user_upn, folder_path, resp.text[:300])
        return {"status": resp.status_code, "items": None, "truncated": False,
                "error": resp.text[:500]}
    data = resp.json()
    return {
        "status": 200,
        "items": data.get("value", []),
        "truncated": bool(data.get("@odata.nextLink")),
        "error": None,
    }


def user_drive_upload_file(
    token: str, user_upn: str, drive_path: str, content: bytes, content_type: str = "application/octet-stream"
) -> dict | None:
    """Upload/overwrite a file in a user's OneDrive. Returns the item metadata or None."""
    url = f"{_GRAPH_BASE}/users/{user_upn}/drive/root:/{drive_path}:/content"
    try:
        resp = requests.put(
            url,
            headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
            data=content,
            timeout=120,
        )
        if resp.status_code not in (200, 201):
            log.error("user_drive_upload_file failed (%s) for %s/%s: %s",
                      resp.status_code, user_upn, drive_path, resp.text[:300])
            return None
        return resp.json()
    except Exception:
        log.exception("user_drive_upload_file error")
        return None


def user_drive_list_children(token: str, user_upn: str, item_id: str) -> list[dict] | None:
    """List children of a drive folder by item ID (for folder navigation)."""
    url = f"{_GRAPH_BASE}/users/{user_upn}/drive/items/{item_id}/children"
    try:
        resp = requests.get(url, headers=_auth_header(token), timeout=30)
        if resp.status_code != 200:
            log.error("user_drive_list_children failed (%s) for %s item %s: %s",
                      resp.status_code, user_upn, item_id, resp.text[:300])
            return None
        return resp.json().get("value", [])
    except Exception:
        log.exception("user_drive_list_children error")
        return None


def user_drive_list_root(token: str, user_upn: str) -> list[dict] | None:
    """List the root of a user's OneDrive."""
    url = f"{_GRAPH_BASE}/users/{user_upn}/drive/root/children"
    try:
        resp = requests.get(url, headers=_auth_header(token), timeout=30)
        if resp.status_code != 200:
            log.error("user_drive_list_root failed (%s) for %s: %s",
                      resp.status_code, user_upn, resp.text[:300])
            return None
        return resp.json().get("value", [])
    except Exception:
        log.exception("user_drive_list_root error")
        return None


def user_drive_get_item(token: str, user_upn: str, item_id: str) -> dict | None:
    """Get metadata for a single drive item by ID (includes @microsoft.graph.downloadUrl)."""
    url = f"{_GRAPH_BASE}/users/{user_upn}/drive/items/{item_id}"
    try:
        resp = requests.get(url, headers=_auth_header(token), timeout=30)
        if resp.status_code != 200:
            log.error("user_drive_get_item failed (%s) for %s item %s: %s",
                      resp.status_code, user_upn, item_id, resp.text[:300])
            return None
        return resp.json()
    except Exception:
        log.exception("user_drive_get_item error")
        return None


def user_drive_read_item(token: str, user_upn: str, item_id: str) -> bytes | None:
    """Fetch a drive item's raw content by ID from a user's OneDrive (DVI-975).

    Used by the Winston Files preview proxy so file previews are served
    same-origin (mirrors the File Browser tool's behaviour). Returns the file
    bytes or None on any failure.
    """
    url = f"{_GRAPH_BASE}/users/{user_upn}/drive/items/{item_id}/content"
    try:
        resp = requests.get(url, headers=_auth_header(token), timeout=60)
        if resp.status_code != 200:
            log.error("user_drive_read_item failed (%s) for %s item %s: %s",
                      resp.status_code, user_upn, item_id, resp.text[:300])
            return None
        return resp.content
    except Exception:
        log.exception("user_drive_read_item error")
        return None


def find_quote_workbook(token: str, user_upn: str, folder_path: str, quote_number: str) -> dict | None:
    """Find the xlsm workbook whose filename contains quote_number in a OneDrive folder.

    Scans up to ~4,700 .xlsm files. Returns the first matching item dict or None.
    Supports paged results ($top=500) to handle large folders efficiently.
    """
    url = f"{_GRAPH_BASE}/users/{user_upn}/drive/root:/{folder_path}:/children"
    params = {"$top": 500, "$select": "id,name,size,lastModifiedDateTime,webUrl,parentReference,@microsoft.graph.downloadUrl"}
    seen = 0
    while url:
        try:
            resp = requests.get(url, headers=_auth_header(token), params=params if "?" not in url else None, timeout=60)
            params = None  # only on first request; @odata.nextLink already carries params
            if resp.status_code != 200:
                log.error("find_quote_workbook folder fetch failed (%s): %s", resp.status_code, resp.text[:300])
                return None
            data = resp.json()
            for item in data.get("value", []):
                name = item.get("name", "")
                seen += 1
                if quote_number in name and name.lower().endswith(".xlsm"):
                    log.info("find_quote_workbook: found %s after scanning %d items", name, seen)
                    return item
            url = data.get("@odata.nextLink")
        except Exception:
            log.exception("find_quote_workbook error")
            return None
    log.warning("find_quote_workbook: quote %s not found in %s (scanned %d items)", quote_number, folder_path, seen)
    return None


# ---------------------------------------------------------------------------
# SharePoint site-drive operations (DVI-947)
# Same read/find/upload as the OneDrive helpers above, but addressed at a
# SharePoint document library via /drives/{driveId} instead of a personal
# OneDrive at /users/{upn}/drive (which 404s for the site-less pipeline account).
# ---------------------------------------------------------------------------
def site_drive_list_folder_detail(
    token: str, folder_path: str, *, site: str | None = None, top: int = 200
) -> dict:
    """Diagnostic folder listing on the SharePoint site drive (mirror of
    ``user_drive_list_folder_detail`` for the shared Files views — DVI-947)."""
    drive_id = resolve_site_drive_id(token, site)
    if not drive_id:
        return {"status": 0, "items": None, "truncated": False,
                "error": f"could not resolve SharePoint site drive ({site or get_sharepoint_site()})"}
    url = f"{_GRAPH_BASE}/drives/{drive_id}/root:/{folder_path}:/children"
    params = {
        "$top": top,
        "$select": "id,name,size,folder,file,lastModifiedDateTime,@microsoft.graph.downloadUrl",
    }
    try:
        resp = requests.get(url, headers=_auth_header(token), params=params, timeout=30)
    except Exception as exc:  # noqa: BLE001 — report the transport failure verbatim
        log.exception("site_drive_list_folder_detail error")
        return {"status": 0, "items": None, "truncated": False, "error": str(exc)}
    if resp.status_code != 200:
        log.error("site_drive_list_folder_detail failed (%s) for %s: %s",
                  resp.status_code, folder_path, resp.text[:300])
        return {"status": resp.status_code, "items": None, "truncated": False,
                "error": resp.text[:500]}
    data = resp.json()
    return {
        "status": 200,
        "items": data.get("value", []),
        "truncated": bool(data.get("@odata.nextLink")),
        "error": None,
    }


def site_find_quote_workbook(
    token: str, folder_path: str, quote_number: str, *, site: str | None = None
) -> dict | None:
    """Site-drive equivalent of ``find_quote_workbook`` (DVI-947)."""
    drive_id = resolve_site_drive_id(token, site)
    if not drive_id:
        log.error("site_find_quote_workbook: could not resolve site drive")
        return None
    url = f"{_GRAPH_BASE}/drives/{drive_id}/root:/{folder_path}:/children"
    params = {"$top": 500, "$select": "id,name,size,lastModifiedDateTime,webUrl,parentReference,@microsoft.graph.downloadUrl"}
    seen = 0
    while url:
        try:
            resp = requests.get(url, headers=_auth_header(token),
                                params=params if "?" not in url else None, timeout=60)
            params = None
            if resp.status_code != 200:
                log.error("site_find_quote_workbook folder fetch failed (%s): %s",
                          resp.status_code, resp.text[:300])
                return None
            data = resp.json()
            for item in data.get("value", []):
                name = item.get("name", "")
                seen += 1
                if quote_number in name and name.lower().endswith(".xlsm"):
                    log.info("site_find_quote_workbook: found %s after scanning %d items", name, seen)
                    return item
            url = data.get("@odata.nextLink")
        except Exception:
            log.exception("site_find_quote_workbook error")
            return None
    log.warning("site_find_quote_workbook: quote %s not found in %s (scanned %d items)",
                quote_number, folder_path, seen)
    return None


def site_drive_read_file(token: str, drive_path: str, *, site: str | None = None) -> bytes | None:
    """Read a file from the SharePoint site drive by path (DVI-947)."""
    drive_id = resolve_site_drive_id(token, site)
    if not drive_id:
        return None
    return drive_read_file(token, drive_id, drive_path)


def site_drive_read_item(token: str, item_id: str, *, site: str | None = None) -> bytes | None:
    """Fetch a drive item's raw content by ID from the SharePoint site drive
    (site-drive equivalent of ``user_drive_read_item`` — DVI-975). Returns the
    file bytes or None on any failure."""
    drive_id = resolve_site_drive_id(token, site)
    if not drive_id:
        return None
    url = f"{_GRAPH_BASE}/drives/{drive_id}/items/{item_id}/content"
    try:
        resp = requests.get(url, headers=_auth_header(token), timeout=60)
        if resp.status_code != 200:
            log.error("site_drive_read_item failed (%s) for item %s: %s",
                      resp.status_code, item_id, resp.text[:300])
            return None
        return resp.content
    except Exception:
        log.exception("site_drive_read_item error")
        return None


def site_drive_upload_file(
    token: str, drive_path: str, content: bytes,
    content_type: str = "application/octet-stream", *, site: str | None = None
) -> dict | None:
    """Upload/overwrite a file on the SharePoint site drive (DVI-947).

    Requires Sites.ReadWrite.All (confirmed granted). Returns item metadata or None.
    """
    drive_id = resolve_site_drive_id(token, site)
    if not drive_id:
        return None
    url = f"{_GRAPH_BASE}/drives/{drive_id}/root:/{drive_path}:/content"
    try:
        resp = requests.put(
            url,
            headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
            data=content,
            timeout=120,
        )
        if resp.status_code not in (200, 201):
            log.error("site_drive_upload_file failed (%s) for %s: %s",
                      resp.status_code, drive_path, resp.text[:300])
            return None
        return resp.json()
    except Exception:
        log.exception("site_drive_upload_file error")
        return None


# ---------------------------------------------------------------------------
# Mail operations  (requires Mail.ReadWrite application permission,
#                   scoped to sales@icastinc.com via Application Access Policy)
# ---------------------------------------------------------------------------
def mail_create_draft(
    token: str,
    subject: str,
    body_html: str,
    to_addresses: list[str],
    *,
    mailbox: str | None = None,
    attachments: list[dict] | None = None,
    cc_addresses: list[str] | None = None,
    bcc_addresses: list[str] | None = None,
) -> dict | None:
    """Create a draft message in the Winston mailbox.

    mailbox defaults to WINSTON_MAIL_FROM env var (sales@icastinc.com).
    attachments is a list of {"name", "contentType", "contentBytes"} dicts
    where contentBytes is already base64-encoded (Graph fileAttachment shape).
    Returns the created message metadata or None on error.
    """
    mailbox = mailbox or WINSTON_MAIL_FROM
    if not mailbox:
        log.error("mail_create_draft: no mailbox configured (WINSTON_MAIL_FROM not set)")
        return None

    message = {
        "subject": subject,
        "body": {"contentType": "HTML", "content": body_html},
        "toRecipients": [{"emailAddress": {"address": a}} for a in to_addresses],
    }
    if cc_addresses:
        message["ccRecipients"] = [{"emailAddress": {"address": a}} for a in cc_addresses]
    if bcc_addresses:
        message["bccRecipients"] = [{"emailAddress": {"address": a}} for a in bcc_addresses]
    if attachments:
        message["attachments"] = [
            {
                "@odata.type": "#microsoft.graph.fileAttachment",
                "name": att["name"],
                "contentType": att.get("contentType", "application/octet-stream"),
                "contentBytes": att["contentBytes"],
            }
            for att in attachments
        ]

    url = f"{_GRAPH_BASE}/users/{mailbox}/messages"
    try:
        resp = requests.post(url, headers=_auth_header(token), json=message, timeout=60)
        if resp.status_code not in (200, 201):
            log.error("mail_create_draft failed (%s) for mailbox %s: %s",
                      resp.status_code, mailbox, resp.text[:300])
            return None
        return resp.json()
    except Exception:
        log.exception("mail_create_draft error")
        return None


def mail_list_drafts(
    token: str,
    *,
    mailbox: str | None = None,
    top: int = 50,
) -> list[dict] | None:
    """List messages in the Drafts folder of the Winston mailbox.

    Returns a list of message dicts (subject, toRecipients, bodyPreview,
    lastModifiedDateTime, hasAttachments) with attachment metadata expanded
    (id/name/contentType/size only — NOT contentBytes, to keep the payload
    small). mailbox defaults to WINSTON_MAIL_FROM. Returns None on error.
    """
    mailbox = mailbox or WINSTON_MAIL_FROM
    if not mailbox:
        log.error("mail_list_drafts: no mailbox configured (WINSTON_MAIL_FROM not set)")
        return None
    url = f"{_GRAPH_BASE}/users/{mailbox}/mailFolders/drafts/messages"
    params = {
        "$top": max(1, min(int(top or 50), 200)),
        "$orderby": "lastModifiedDateTime desc",
        "$select": "id,subject,bodyPreview,toRecipients,lastModifiedDateTime,createdDateTime,hasAttachments",
        "$expand": "attachments($select=id,name,contentType,size,isInline)",
    }
    try:
        resp = requests.get(url, headers=_auth_header(token), params=params, timeout=60)
        if resp.status_code != 200:
            log.error("mail_list_drafts failed (%s) for mailbox %s: %s",
                      resp.status_code, mailbox, resp.text[:300])
            return None
        return resp.json().get("value", [])
    except Exception:
        log.exception("mail_list_drafts error")
        return None


def mail_get_attachment(
    token: str,
    message_id: str,
    attachment_id: str,
    *,
    mailbox: str | None = None,
) -> tuple[bytes, str, str] | None:
    """Download a single mail attachment's raw bytes.

    Returns (content_bytes, content_type, name) or None on error.
    mailbox defaults to WINSTON_MAIL_FROM.
    """
    mailbox = mailbox or WINSTON_MAIL_FROM
    if not mailbox:
        log.error("mail_get_attachment: no mailbox configured")
        return None
    url = f"{_GRAPH_BASE}/users/{mailbox}/messages/{message_id}/attachments/{attachment_id}"
    try:
        resp = requests.get(url, headers=_auth_header(token), timeout=60)
        if resp.status_code != 200:
            log.error("mail_get_attachment failed (%s) for msg %s att %s: %s",
                      resp.status_code, message_id, attachment_id, resp.text[:300])
            return None
        data = resp.json()
        b64 = data.get("contentBytes")
        if not b64:
            log.error("mail_get_attachment: no contentBytes (type=%s) for att %s",
                      data.get("@odata.type"), attachment_id)
            return None
        return (
            base64.b64decode(b64),
            data.get("contentType", "application/octet-stream"),
            data.get("name", "attachment"),
        )
    except Exception:
        log.exception("mail_get_attachment error")
        return None


def mail_delete_message(token: str, message_id: str, *, mailbox: str | None = None) -> bool:
    """Delete a draft message by ID. Returns True on success."""
    mailbox = mailbox or WINSTON_MAIL_FROM
    url = f"{_GRAPH_BASE}/users/{mailbox}/messages/{message_id}"
    try:
        resp = requests.delete(url, headers={"Authorization": f"Bearer {token}"}, timeout=30)
        return resp.status_code == 204
    except Exception:
        log.exception("mail_delete_message error")
        return False


# ---------------------------------------------------------------------------
# xlsx → PDF conversion via Graph (B1 recipe — DVI-807)
# ---------------------------------------------------------------------------
def xlsx_to_pdf(
    token: str,
    xlsx_bytes: bytes,
    *,
    scratch_filename: str | None = None,
    service_user: str | None = None,
) -> bytes | None:
    """Upload xlsx bytes to a scratch location, convert to PDF via Graph, delete scratch.

    Follows the B1 recipe proven in DVI-807:
      1. PUT .../root:/{folder}/{file}:/content  (simple upload, ≤4 MB)
      2. GET /drives/{drive_id}/items/{item_id}/content?format=pdf  → PDF bytes
      3. DELETE the scratch item

    service_user: when given, use that user's OneDrive for the scratch file
                  (legacy behavior). When omitted (the default), the scratch
                  file goes to the SharePoint site drive (DVI-947) because the
                  pipeline account (togen@) has no personal OneDrive.
    scratch_filename: filename for the temp xlsx (default: auto-generated UUID name)
    Returns PDF bytes on success, None on error.
    """
    filename = scratch_filename or f"winston_scratch_{uuid.uuid4().hex}.xlsx"
    scratch_path = f"{_SCRATCH_FOLDER}/{filename}"

    # Upload xlsx to scratch path. Default target = SharePoint site drive
    # (togen@ has no OneDrive); a caller may force a user OneDrive via service_user.
    if service_user:
        upload_url = f"{_GRAPH_BASE}/users/{service_user}/drive/root:/{scratch_path}:/content"
    else:
        scratch_drive_id = resolve_site_drive_id(token)
        if not scratch_drive_id:
            log.error("xlsx_to_pdf: could not resolve SharePoint site drive for scratch upload")
            return None
        upload_url = f"{_GRAPH_BASE}/drives/{scratch_drive_id}/root:/{scratch_path}:/content"
    try:
        up_resp = requests.put(
            upload_url,
            headers={
                "Authorization": f"Bearer {token}",
                "Content-Type": (
                    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
                ),
            },
            data=xlsx_bytes,
            timeout=120,
        )
    except Exception:
        log.exception("xlsx_to_pdf: upload error for %s", filename)
        return None

    if up_resp.status_code not in (200, 201):
        log.error(
            "xlsx_to_pdf: upload failed (%s) for %s: %s",
            up_resp.status_code,
            filename,
            up_resp.text[:300],
        )
        return None

    item_data = up_resp.json()
    item_id = item_data.get("id", "")
    drive_id = item_data.get("parentReference", {}).get("driveId", "")

    if not item_id or not drive_id:
        log.error("xlsx_to_pdf: upload response missing id or driveId for %s", filename)
        return None

    # Convert to PDF via Graph Office rendering
    try:
        pdf_resp = requests.get(
            f"{_GRAPH_BASE}/drives/{drive_id}/items/{item_id}/content?format=pdf",
            headers={"Authorization": f"Bearer {token}"},
            allow_redirects=True,
            timeout=120,
        )
    except Exception:
        log.exception("xlsx_to_pdf: convert request error for %s", filename)
        _delete_drive_item(token, drive_id, item_id)
        return None

    if pdf_resp.status_code != 200:
        log.error(
            "xlsx_to_pdf: convert failed (%s) for %s: %s",
            pdf_resp.status_code,
            filename,
            pdf_resp.text[:300],
        )
        _delete_drive_item(token, drive_id, item_id)
        return None

    pdf_bytes = pdf_resp.content
    _delete_drive_item(token, drive_id, item_id)
    log.info("xlsx_to_pdf: %s → %d KB PDF", filename, len(pdf_bytes) // 1024)
    return pdf_bytes


def _delete_drive_item(token: str, drive_id: str, item_id: str) -> bool:
    """Delete a drive item by drive ID + item ID. Returns True on success (204)."""
    try:
        r = requests.delete(
            f"{_GRAPH_BASE}/drives/{drive_id}/items/{item_id}",
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        ok = r.status_code in (200, 204)
        if not ok:
            log.warning(
                "_delete_drive_item: unexpected status %s for item %s", r.status_code, item_id
            )
        return ok
    except Exception:
        log.exception("_delete_drive_item error: drive=%s item=%s", drive_id, item_id)
        return False


# ---------------------------------------------------------------------------
# Convenience: encode attachment bytes for Graph
# ---------------------------------------------------------------------------
def encode_attachment(name: str, content: bytes, content_type: str = "application/pdf") -> dict:
    """Package raw bytes into a Graph fileAttachment dict for mail_create_draft."""
    return {
        "name": name,
        "contentType": content_type,
        "contentBytes": base64.b64encode(content).decode("ascii"),
    }


# ---------------------------------------------------------------------------
# CLI verification (python winston_graph.py --verify)
# ---------------------------------------------------------------------------
def _verify(onedrive_user: str | None = None, folder: str = "Takeoffs") -> bool:
    """Run a two-part verification: (a) list a OneDrive folder, (b) create+delete a draft.

    Returns True only when both steps succeed. Safe to run in production
    because the draft is created and immediately deleted.
    """
    logging.basicConfig(level=logging.INFO,
                        format="%(levelname)s  %(name)s  %(message)s")
    ok = True

    print("=== Winston Graph verification ===\n")
    print(f"  AZURE_CLIENT_ID : {_AZ_CLIENT_ID[:8]}..." if _AZ_CLIENT_ID else "  AZURE_CLIENT_ID : NOT SET")
    print(f"  AZURE_TENANT_ID : {_AZ_TENANT}")
    print(f"  WINSTON_MAIL_FROM: {WINSTON_MAIL_FROM}\n")

    # Step 1: acquire token
    print("[1/4] Acquiring app-only token...")
    token = acquire_graph_token()
    if not token:
        print("  FAIL: could not acquire token")
        return False
    print("  OK: token acquired\n")

    # Step 2: OneDrive folder list
    user = onedrive_user or WINSTON_ONEDRIVE_USER
    if not user:
        print("[2/4] SKIP: WINSTON_ONEDRIVE_USER not set — cannot verify file access")
        print("       Set WINSTON_ONEDRIVE_USER env var to run this check.\n")
        # Skip is a warning, not a failure — mail-only workflows may not need OneDrive
    else:
        print(f"[2/4] Listing '{folder}' folder for user {user}...")
        items = user_drive_list_folder(token, user, folder)
        if items is None:
            print(f"  FAIL: could not list {folder} — check Files.ReadWrite.All permission + user UPN")
            ok = False
        else:
            print(f"  OK: {len(items)} item(s) in {folder}")
            for it in items[:5]:
                size = it.get("size", "")
                print(f"    - {it.get('name')} ({size} bytes)")
            print()

    # Step 3: create a test draft
    print(f"[3/4] Creating test draft in {WINSTON_MAIL_FROM}...")
    draft = mail_create_draft(
        token,
        subject="[Winston verify] Graph permission check — please ignore and delete",
        body_html="<p>Winston Graph verification draft. This can be deleted.</p>",
        to_addresses=["andy@voyagetech.com"],
    )
    if not draft:
        print(f"  FAIL: could not create draft — check Mail.ReadWrite permission + Application Access Policy")
        ok = False
    else:
        msg_id = draft.get("id", "")
        print(f"  OK: draft created (id: {msg_id[:20]}...)\n")

        # Step 4: clean up the draft
        print("[4/4] Deleting test draft...")
        if mail_delete_message(token, msg_id):
            print("  OK: draft deleted — verification PASSED\n")
        else:
            print("  WARN: draft created but cleanup delete failed — verify it manually\n")

    result = "PASSED" if ok else "FAILED"
    print(f"=== Result: {result} ===")
    return ok


if __name__ == "__main__":
    import argparse
    import sys

    parser = argparse.ArgumentParser(description="Winston Graph verification")
    parser.add_argument("--verify", action="store_true", help="Run permission verification")
    parser.add_argument("--onedrive-user", help="UPN of the OneDrive owner to test")
    parser.add_argument("--folder", default="Takeoffs", help="OneDrive folder to list (default: Takeoffs)")
    args = parser.parse_args()

    if args.verify:
        sys.exit(0 if _verify(args.onedrive_user, args.folder) else 1)
    else:
        parser.print_help()
