#!/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 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"


# ---------------------------------------------------------------------------
# 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_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 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,@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


# ---------------------------------------------------------------------------
# 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,
) -> 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 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_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 file in a service OneDrive, convert to PDF, delete scratch.

    Follows the B1 recipe proven in DVI-807:
      1. PUT /users/{user}/drive/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: OneDrive UPN for the scratch drive
                  (default: WINSTON_SERVICE_USER env var → togen@icastinc.com)
    scratch_filename: filename for the temp xlsx (default: auto-generated UUID name)
    Returns PDF bytes on success, None on error.
    """
    user = service_user or WINSTON_SERVICE_USER
    filename = scratch_filename or f"winston_scratch_{uuid.uuid4().hex}.xlsx"
    scratch_path = f"{_SCRATCH_FOLDER}/{filename}"

    # Upload xlsx to scratch path
    upload_url = f"{_GRAPH_BASE}/users/{user}/drive/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()
