#!/usr/bin/env python3
"""
winston_date_logic.py — Winston "Date Logic" pipeline config (DVI-834).

A "Date Logic" pipeline pairs a display name with a date-resolution rule
(``base_rule``) and per-process display labels. The two seeded built-ins mirror
the historical schedule profiles:

  prev_biz   Previous business day (the 7am pattern)
  today      Today's date (the 11am / 3pm pattern)

The list of *processes* (the linear steps Winston runs per quote task) is fixed
and shared by every pipeline — see PROCESS_KEYS. Renaming a process only changes
its display label; the underlying pipeline code (winston_pipeline.py) is
unchanged. New pipelines are additional named views over an existing base_rule
and become selectable as a schedule's Date Logic.

Both app.py and winston_scheduler.py import this module; it depends on stdlib
only to avoid import cycles.
"""

import json
import threading
import uuid
from datetime import datetime
from pathlib import Path

_BASE_DIR = Path(__file__).resolve().parent
_STORE_FILE = _BASE_DIR / "winston_date_logic.json"
_LOCK = threading.Lock()

# Fixed linear progression of Winston pipeline processes (winston_pipeline.py).
PROCESS_KEYS = [
    "clickup_pull",
    "find_download",
    "openpyxl_extract",
    "graph_convert",
    "outlook_draft",
    "clickup_writeback",
    "routed_upload",
]

DEFAULT_PROCESS_LABELS = {
    "clickup_pull": "ClickUp pull",
    "find_download": "Find + download workbook",
    "openpyxl_extract": "Extract quote sheet",
    "graph_convert": "Convert to PDF (Graph)",
    "outlook_draft": "Create mail draft",
    "clickup_writeback": "ClickUp writeback",
    "routed_upload": "Routed PDF upload",
}

# Read-only, human-readable description of what each process does at runtime.
# Grounded in togen/winston_pipeline.py — keep in sync if the pipeline changes.
# Surfaced in the Date Logic tab when a process is clicked (DVI-834 follow-up).
PROCESS_DETAILS = {
    "clickup_pull": {
        "summary": "Fetches the quote tasks to process for the pipeline's target date from ClickUp.",
        "system": "ClickUp API",
        "queries": "Quote tasks in the configured ClickUp list (clickup_list_id); if no list is set, a workspace/team-wide query (clickup_team_id). Tasks are filtered by the configured date field (default: date_updated) matching the target date.",
        "pulls": "For each matching task: quote number, ClickUp task id, owner email, recipient email, and the Quote Amount custom-field id.",
        "pushes": "The list of quote tasks (quote numbers) to the next step, which locates each workbook. If no tasks match, the run ends here.",
    },
    "find_download": {
        "summary": "Locates and downloads the quote's source workbook from OneDrive.",
        "system": "Microsoft Graph (OneDrive)",
        "queries": "Searches for the workbook named for the quote number ({quote}.xlsm) using a shared-first → personal-fallback rule. First checks the shared Quote Take-offs folder (shared_quote_takeoff_location in Togen Admin; falls back to the legacy Takeoffs folder when unset). If not found there, falls back to the quote owner's personal Quote Take-offs folder (quote_takeoff_location from their User Settings Documents tab), searching their own OneDrive.",
        "pulls": "The raw .xlsm workbook bytes (via the Graph download URL, or a direct drive read).",
        "pushes": "The workbook bytes to the extract step. If the workbook isn't found or the download fails, that quote is skipped.",
    },
    "openpyxl_extract": {
        "summary": "Strips the workbook down to the single quote sheet and reads the quote total.",
        "system": "Local (openpyxl, in-memory)",
        "queries": "No external system — operates on the downloaded workbook in memory.",
        "pulls": "Reads the grand-total cell (H137) on the \"Quote (Automated)\" sheet; sets the print area (A1:AM138) and deletes every other sheet (removing the large Structure Input sheet that otherwise breaks PDF conversion).",
        "pushes": "A slimmed single-sheet .xlsx plus the grand-total value to the PDF, mail-draft and write-back steps.",
    },
    "graph_convert": {
        "summary": "Converts the slimmed .xlsx into a PDF.",
        "system": "Microsoft Graph (Files)",
        "queries": "Uploads the .xlsx to a scratch location on the service drive, requests it back as PDF (?format=pdf), then deletes the scratch file.",
        "pulls": "The rendered PDF bytes.",
        "pushes": "The PDF to the mail-draft, ClickUp write-back and routed-upload steps. If conversion fails, that quote is skipped.",
    },
    "outlook_draft": {
        "summary": "Creates a draft quote email (with the PDF attached) in the sales mailbox.",
        "system": "Microsoft Graph (Outlook mail)",
        "queries": "Checks the mailbox Drafts folder for an existing message whose subject contains the quote number (dedupe — skips creating a duplicate).",
        "pulls": "Nothing new; reuses the PDF and grand total from earlier steps.",
        "pushes": "A new draft in the configured mailbox (default sales@icastinc.com): subject \"Quote {quote} — iCast Infrastructure\", addressed to the task's recipient, quote total in the body, PDF attached. The draft is left for a human to review and send — the pipeline never sends it.",
    },
    "clickup_writeback": {
        "summary": "Writes the quote total and PDF back onto the ClickUp task.",
        "system": "ClickUp API",
        "queries": "Checks whether the PDF is already attached to the task (skips re-attaching if so).",
        "pulls": "Nothing new.",
        "pushes": "Sets the task's Quote Amount custom field to the grand total and attaches the quote PDF to the task.",
    },
    "routed_upload": {
        "summary": "Uploads the final quote PDF to its Completed Quotes destination folder in OneDrive.",
        "system": "Microsoft Graph (OneDrive)",
        "queries": "Resolves the Completed Quotes destination by owner: if the quote owner has a personal Completed Quotes path (quotes_location) set in their User Settings Documents tab, that OneDrive folder is used; otherwise the shared Completed Quotes path (shared_quotes_location in Togen Admin → Documents → Quotes).",
        "pulls": "Nothing new.",
        "pushes": "Uploads the PDF to the resolved Completed Quotes destination folder. This is the final step of the pipeline.",
    },
}

# Date-resolution rules a pipeline can be built on. Keys must match the profile
# strings understood by winston_scheduler.target_dates().
BASE_RULES = {
    "prev_biz": "Previous business day",
    "today": "Today's date",
}

_BUILTINS = [
    {"id": "prev_biz", "name": "Previous business day", "base_rule": "prev_biz"},
    {"id": "today", "name": "Today's date", "base_rule": "today"},
]


# ---------------------------------------------------------------------------
# Store I/O
# ---------------------------------------------------------------------------

def _load_raw() -> list:
    if _STORE_FILE.is_file():
        try:
            data = json.loads(_STORE_FILE.read_text())
            if isinstance(data, list):
                return [p for p in data if isinstance(p, dict)]
        except (json.JSONDecodeError, OSError):
            pass
    return []


def _save_raw(pipelines: list) -> None:
    _STORE_FILE.write_text(json.dumps(pipelines, indent=2))


def _seed_if_empty(pipelines: list) -> list:
    if pipelines:
        return pipelines
    now = datetime.utcnow().isoformat()
    seeded = []
    for b in _BUILTINS:
        seeded.append({
            "id": b["id"],
            "name": b["name"],
            "base_rule": b["base_rule"],
            "builtin": True,
            "process_labels": {},
            "created_at": now,
            "updated_at": now,
        })
    _save_raw(seeded)
    return seeded


def _load() -> list:
    """Load pipelines, seeding the two built-ins on first use."""
    with _LOCK:
        return _seed_if_empty(_load_raw())


# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------

def _process_list(pipeline: dict) -> list:
    labels = pipeline.get("process_labels") or {}
    out = []
    for i, key in enumerate(PROCESS_KEYS):
        default = DEFAULT_PROCESS_LABELS[key]
        out.append({
            "seq": i + 1,
            "key": key,
            "label": labels.get(key) or default,
            "default_label": default,
            "custom": bool(labels.get(key)),
            "detail": PROCESS_DETAILS.get(key, {}),
        })
    return out


def _public(pipeline: dict) -> dict:
    return {
        "id": pipeline.get("id"),
        "name": pipeline.get("name"),
        "base_rule": pipeline.get("base_rule"),
        "base_rule_label": BASE_RULES.get(pipeline.get("base_rule"), pipeline.get("base_rule")),
        "builtin": bool(pipeline.get("builtin")),
        "processes": _process_list(pipeline),
    }


def list_pipelines() -> list:
    return [_public(p) for p in _load()]


def resolve_base_rule(profile: str) -> str:
    """Map a schedule's stored Date Logic id → its date-resolution base_rule.

    Backwards compatible: legacy schedules store "prev_biz"/"today" directly,
    which are also the built-in ids, so they resolve to themselves. Unknown
    values fall back to "today" (matches winston_scheduler.target_dates()).
    """
    for p in _load():
        if p.get("id") == profile:
            br = p.get("base_rule")
            return br if br in BASE_RULES else "today"
    return profile if profile in BASE_RULES else "today"


def valid_pipeline_ids() -> set:
    return {p.get("id") for p in _load()}


def create(name: str, base_rule: str):
    """Create a new (non-builtin) Date Logic pipeline. Returns (public, err)."""
    name = (name or "").strip()
    if not name:
        return None, "Name is required."
    if len(name) > 120:
        return None, "Name is too long (max 120 characters)."
    if base_rule not in BASE_RULES:
        return None, f"Invalid base rule; expected one of {', '.join(BASE_RULES)}."
    now = datetime.utcnow().isoformat()
    pipeline = {
        "id": str(uuid.uuid4()),
        "name": name,
        "base_rule": base_rule,
        "builtin": False,
        "process_labels": {},
        "created_at": now,
        "updated_at": now,
    }
    with _LOCK:
        pipelines = _seed_if_empty(_load_raw())
        pipelines.append(pipeline)
        _save_raw(pipelines)
    return _public(pipeline), None


def update(pipeline_id: str, name=None, process_labels=None):
    """Update a pipeline's name and/or process display labels. Returns (public, err)."""
    with _LOCK:
        pipelines = _seed_if_empty(_load_raw())
        idx = next((i for i, p in enumerate(pipelines) if p.get("id") == pipeline_id), None)
        if idx is None:
            return None, "Date Logic not found."
        pipeline = pipelines[idx]

        if name is not None:
            name = (name or "").strip()
            if not name:
                return None, "Name is required."
            if len(name) > 120:
                return None, "Name is too long (max 120 characters)."
            pipeline["name"] = name

        if process_labels is not None:
            if not isinstance(process_labels, dict):
                return None, "process_labels must be an object."
            labels = dict(pipeline.get("process_labels") or {})
            for key, val in process_labels.items():
                if key not in DEFAULT_PROCESS_LABELS:
                    continue
                val = (str(val) if val is not None else "").strip()
                if len(val) > 120:
                    return None, "Process name is too long (max 120 characters)."
                if val:
                    labels[key] = val
                else:
                    labels.pop(key, None)  # empty → revert to default
            pipeline["process_labels"] = labels

        pipeline["updated_at"] = datetime.utcnow().isoformat()
        pipelines[idx] = pipeline
        _save_raw(pipelines)
    return _public(pipeline), None


def delete(pipeline_id: str, referenced: bool):
    """Delete a non-builtin pipeline. Returns (ok, err, status)."""
    with _LOCK:
        pipelines = _seed_if_empty(_load_raw())
        pipeline = next((p for p in pipelines if p.get("id") == pipeline_id), None)
        if pipeline is None:
            return False, "Date Logic not found.", 404
        if pipeline.get("builtin"):
            return False, "Built-in Date Logic pipelines cannot be deleted.", 400
        if referenced:
            return False, "This Date Logic is used by one or more schedules.", 409
        _save_raw([p for p in pipelines if p.get("id") != pipeline_id])
    return True, None, 200
