#!/usr/bin/env python3
"""
scada_report_approvals.py — SCADA report approver→send workflow (DVI-1102,
SCADA View Reporting F4).

Part of SCADA View Reporting (DVI-1095). A small, Flask-independent engine
module (mirrors ``scada_report.py`` / ``scada_store.py``) shared by BOTH the web
app (``app.py`` — approver UI + approve/reject routes) and the standalone report
scheduler (``scada_report_scheduler.py`` — enqueues external sends for approval).

Why this exists
---------------
Scheduled and manual SCADA reports are emailed to three recipient groups
(DVI-487): per-schedule *notify*, global *enforced*, and global *external*. F4
adds a single-approver gate — modeled on the WO Approvals pattern — so **no
report is ever auto-sent to EXTERNAL recipients without an explicit approval**.

Scope decision (documented, not silent — the issue names "External recipients"):
  * The gate applies ONLY to the *external* recipient list. Internal delivery
    (per-schedule notify + enforced recipients) still happens at generation
    time, unchanged — those audiences are trusted staff, and gating them would
    silently stop every scheduled report the moment the feature deploys.
  * When a report has external recipients, instead of sending to them the
    caller enqueues an ``awaiting_approval`` request here and notifies the
    configured approver(s). An approver approves (→ the persisted XLSX is
    emailed to the external list + the send is logged) or rejects (→ recorded,
    never sent).
  * "Single-approver" = a single approval suffices; any configured approver
    (or a Togen/SCADA admin, enforced in app.py) may action a request.

No-approver safety
------------------
If a report has external recipients but NO approver is configured, the request
is still enqueued as ``awaiting_approval`` (the invariant "never auto-sent
externally" holds) and ``enqueue_external_send`` returns ``notified=False`` so
the caller can surface a LOUD warning to the enforced/internal recipients rather
than silently dropping external delivery (the DVI-1114 class of failure).

State
-----
``scada_report_approvals.json`` — a JSON list of request records, newest last.
The persisted XLSX itself lives in the shared ``reports/`` dir (written by the
scheduler / on-demand generation); a request references it by bare filename.

Record shape::

    {
      "id":            "<uuid4 hex>",
      "created_at":    ISO-8601,
      "source":        "schedule" | "manual",
      "schedule_id":   str | null,
      "report_name":   str,           # human schedule/report name
      "report_type":   str,           # registry id (DVI-1101), e.g. "kiln_temp"
      "date_from":     "YYYY-MM-DD" | null,
      "date_to":       "YYYY-MM-DD" | null,
      "row_count":     int,
      "report_file":   "<name>.xlsx", # bare filename in the reports dir
      "attach_name":   str,           # email attachment filename
      "subject":       str,
      "recipients":    ["a@b.com", ...],  # external list to send on approve
      "requested_by":  str,           # "scheduler" or a user email
      "status":        "awaiting_approval" | "approved" | "rejected",
      "actioned_by":   str | null,
      "actioned_at":   ISO-8601 | null,
      "reject_reason": str | null,
      "send_status":   null | "sent" | "send_failed"
    }
"""

import json
import logging
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path

try:
    import notifications
except ImportError:  # pragma: no cover - notifications optional in some tests
    notifications = None

try:
    import scada_report
except ImportError:  # pragma: no cover
    scada_report = None

_LOG = logging.getLogger("scada_report_approvals")
_BASE_DIR = Path(__file__).resolve().parent

APPROVALS_FILE = Path(
    os.environ.get("SCADA_REPORT_APPROVALS",
                   _BASE_DIR / "scada_report_approvals.json"))
REPORTS_DIR = Path(os.environ.get("SCADA_REPORTS_DIR", _BASE_DIR / "reports"))

# scada_config.json is the shared config both app.py and the scheduler read;
# the approver list lives under the top-level ``report_approvers`` key.
_SCADA_CONFIG_PATH = (
    getattr(scada_report, "SCADA_CONFIG_PATH", None)
    or (_BASE_DIR / "scada_config.json"))

XLSX_CONTENT_TYPE = (
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")

STATUS_AWAITING = "awaiting_approval"
STATUS_APPROVED = "approved"
STATUS_REJECTED = "rejected"


# ---------------------------------------------------------------------------
# Store
# ---------------------------------------------------------------------------

def _now_iso():
    return datetime.now(timezone.utc).isoformat()


def load_approvals(path=None):
    """Load the approval request list ([] when absent/invalid)."""
    path = Path(path) if path else APPROVALS_FILE
    if not path.is_file():
        return []
    try:
        data = json.loads(path.read_text())
    except (json.JSONDecodeError, OSError):
        return []
    return [r for r in data if isinstance(r, dict)] if isinstance(data, list) else []


def _save_approvals(records, path=None):
    path = Path(path) if path else APPROVALS_FILE
    try:
        path.write_text(json.dumps(records, indent=2))
        return True
    except OSError:
        _LOG.exception("Failed to persist %s", path)
        return False


def get_request(request_id, path=None):
    """Return a single request by id, or None."""
    for r in load_approvals(path):
        if r.get("id") == request_id:
            return r
    return None


def pending(path=None):
    """Requests still awaiting approval, newest first."""
    return [r for r in reversed(load_approvals(path))
            if r.get("status") == STATUS_AWAITING]


# ---------------------------------------------------------------------------
# Approver config (scada_config.json -> report_approvers)
# ---------------------------------------------------------------------------

def report_approvers(config_path=None):
    """Return the configured approver emails (lowercased, de-duped).

    Reads ``report_approvers`` from scada_config.json — the same file the Flask
    UI and the scheduler share — so both processes see one source of truth
    without app.py having to hand the list to the scheduler.
    """
    path = Path(config_path) if config_path else _SCADA_CONFIG_PATH
    if not path.is_file():
        return []
    try:
        cfg = json.loads(path.read_text())
    except (json.JSONDecodeError, OSError):
        return []
    raw = cfg.get("report_approvers") if isinstance(cfg, dict) else None
    return normalize_approvers(raw)


def normalize_approvers(raw):
    """Normalize an approver list: lowercased, trimmed, de-duped, order-stable.

    Also used by app.py to validate the list on save.
    """
    if not isinstance(raw, list):
        return []
    seen, out = set(), []
    for e in raw:
        if not isinstance(e, str):
            continue
        norm = e.strip().lower()
        if norm and "@" in norm and norm not in seen:
            seen.add(norm)
            out.append(norm)
    return out


def is_approver(email, config_path=None):
    """True if ``email`` is in the configured approver list."""
    if not isinstance(email, str):
        return False
    return email.strip().lower() in report_approvers(config_path)


# ---------------------------------------------------------------------------
# Enqueue (called by the scheduler and by any manual external-send path)
# ---------------------------------------------------------------------------

def enqueue_external_send(*, report_name, report_type, date_from, date_to,
                          row_count, report_file, attach_name, subject,
                          recipients, source="schedule", schedule_id=None,
                          requested_by="scheduler", path=None,
                          config_path=None, notify=True):
    """Record an ``awaiting_approval`` request for an EXTERNAL report send and
    (optionally) notify the configured approver(s).

    ``report_file`` is the bare filename of the already-persisted XLSX in the
    reports dir. ``recipients`` is the external recipient list to email on
    approval. Returns ``{"request": record, "approvers": [...],
    "notified": bool}``. ``notified`` is False when no approver is configured
    (caller should surface a loud warning — external delivery is held, not
    sent).
    """
    record = {
        "id": uuid.uuid4().hex,
        "created_at": _now_iso(),
        "source": source,
        "schedule_id": schedule_id,
        "report_name": report_name,
        "report_type": report_type,
        "date_from": date_from,
        "date_to": date_to,
        "row_count": int(row_count or 0),
        "report_file": report_file,
        "attach_name": attach_name,
        "subject": subject,
        "recipients": list(recipients or []),
        "requested_by": requested_by,
        "status": STATUS_AWAITING,
        "actioned_by": None,
        "actioned_at": None,
        "reject_reason": None,
        "send_status": None,
    }
    records = load_approvals(path)
    records.append(record)
    _save_approvals(records, path)

    approvers = report_approvers(config_path)
    notified = False
    if notify and approvers:
        notified = _notify_approvers(record, approvers)
    return {"request": record, "approvers": approvers, "notified": notified}


# ---------------------------------------------------------------------------
# Approve / reject
# ---------------------------------------------------------------------------

def _set(records, request_id, **fields):
    for r in records:
        if r.get("id") == request_id:
            r.update(fields)
            return r
    return None


def approve(request_id, actioned_by, *, path=None, reports_dir=None):
    """Approve a request and email the persisted XLSX to its external recipients.

    Returns ``{"ok": bool, "record": record|None, "error": str|None}``. The
    record's ``status`` becomes ``approved`` regardless of send outcome (the
    approval decision stands); ``send_status`` records whether the email went
    out so a failed send can be retried from the UI.
    """
    records = load_approvals(path)
    record = next((r for r in records if r.get("id") == request_id), None)
    if record is None:
        return {"ok": False, "record": None, "error": "Approval request not found."}
    if record.get("status") != STATUS_AWAITING:
        return {"ok": False, "record": record,
                "error": f"Request already {record.get('status')}."}

    reports = Path(reports_dir) if reports_dir else REPORTS_DIR
    name = record.get("report_file") or ""
    # Path-traversal guard: only a bare filename inside the reports dir.
    if not name or name != Path(name).name:
        return {"ok": False, "record": record, "error": "Invalid report file."}
    report_path = (reports / name).resolve()
    try:
        report_path.relative_to(reports.resolve())
    except ValueError:
        return {"ok": False, "record": record, "error": "Invalid report file."}
    if not report_path.is_file():
        return {"ok": False, "record": record,
                "error": "Report file no longer exists; cannot send."}

    recipients = record.get("recipients") or []
    if not recipients:
        # Approved but nobody to send to — mark approved, no send attempted.
        _set(records, request_id, status=STATUS_APPROVED,
             actioned_by=actioned_by, actioned_at=_now_iso(),
             send_status="no_recipients")
        _save_approvals(records, path)
        return {"ok": True, "record": get_request(request_id, path), "error": None}

    data = report_path.read_bytes()
    html = _approved_email_html(record)
    sent = False
    if notifications is not None:
        sent = notifications.send_email_with_attachment(
            recipients, record.get("subject", "SCADA Report"), html,
            filename=record.get("attach_name") or name,
            content=data, content_type=XLSX_CONTENT_TYPE)
    _set(records, request_id, status=STATUS_APPROVED, actioned_by=actioned_by,
         actioned_at=_now_iso(), send_status="sent" if sent else "send_failed")
    _save_approvals(records, path)
    _log_external_send(record, recipients, sent)
    if not sent:
        return {"ok": False, "record": get_request(request_id, path),
                "error": "Approved, but the email send failed. Retry from the "
                         "approvals list."}
    return {"ok": True, "record": get_request(request_id, path), "error": None}


def reject(request_id, actioned_by, reason=None, *, path=None):
    """Reject a request. The report is never sent to external recipients."""
    records = load_approvals(path)
    record = next((r for r in records if r.get("id") == request_id), None)
    if record is None:
        return {"ok": False, "record": None, "error": "Approval request not found."}
    if record.get("status") != STATUS_AWAITING:
        return {"ok": False, "record": record,
                "error": f"Request already {record.get('status')}."}
    _set(records, request_id, status=STATUS_REJECTED, actioned_by=actioned_by,
         actioned_at=_now_iso(), reject_reason=(reason or "").strip() or None)
    _save_approvals(records, path)
    return {"ok": True, "record": get_request(request_id, path), "error": None}


# ---------------------------------------------------------------------------
# Emails
# ---------------------------------------------------------------------------

def _esc(value):
    return (str(value)
            .replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))


def _detail_rows(record):
    rng = ""
    if record.get("date_from") or record.get("date_to"):
        rng = f"{record.get('date_from') or '…'} – {record.get('date_to') or '…'}"
    rows = [
        ("Report", record.get("report_name", "")),
        ("Date range", rng),
        ("Rows", str(record.get("row_count", 0))),
        ("External recipients", ", ".join(record.get("recipients") or [])),
    ]
    return "".join(
        '<tr><td style="padding:4px 12px 4px 0;color:#6b7280;font-size:13px;'
        'white-space:nowrap;vertical-align:top;">{lbl}</td>'
        '<td style="padding:4px 0;color:#111827;font-size:13px;">{val}</td>'
        '</tr>'.format(lbl=_esc(lbl), val=_esc(val)) for lbl, val in rows if val)


def _shell(title, body_html, subtitle, footer_html=""):
    if notifications is not None and hasattr(notifications, "render_email_shell"):
        return notifications.render_email_shell(
            title=_esc(title), body_html=body_html, subtitle=subtitle,
            footer_html=footer_html)
    return f"<html><body><h1>{_esc(title)}</h1>{body_html}{footer_html}</body></html>"


def _togen_base_url():
    return (os.environ.get("TOGEN_BASE_URL")
            or "https://togen.icastinc.com").rstrip("/")


def _notify_approvers(record, approvers):
    """Email the approver(s) that a report awaits their approval. Returns bool."""
    if notifications is None:
        return False
    link = f"{_togen_base_url()}/#tool-scada"
    body = (
        '<p style="margin:0;font-size:15px;line-height:1.6;">A SCADA report is '
        'awaiting your approval before it can be emailed to external '
        'recipients.</p>'
        '<table role="presentation" cellpadding="0" cellspacing="0" '
        'style="margin:12px 0 4px;">' + _detail_rows(record) + '</table>'
        '<p style="margin:14px 0 0;font-size:14px;">Review it in Togen → '
        f'<a href="{_esc(link)}" style="color:#0077A8;">SCADA View → Report '
        'Notifications → Pending Approvals</a>.</p>')
    footer = (
        '<hr style="margin:8px 0 14px;border:none;border-top:1px solid #e5e7eb;">'
        '<p style="font-size:12px;color:#9ca3af;text-align:center;margin:0;'
        'line-height:1.6;">You are receiving this because you are a configured '
        'SCADA report approver.</p>')
    subject = f"Approval needed: SCADA report “{record.get('report_name','')}”"
    html = _shell(subject, body, "SCADA Report Approval", footer)
    try:
        return bool(notifications.send_html_email(approvers, subject, html))
    except Exception:  # pragma: no cover - defensive
        _LOG.exception("Failed to notify approvers")
        return False


def _approved_email_html(record):
    body = (
        '<p style="margin:0;font-size:15px;line-height:1.6;">The attached SCADA '
        'report has been approved and is provided for your records.</p>'
        '<table role="presentation" cellpadding="0" cellspacing="0" '
        'style="margin:12px 0 4px;">' + _detail_rows(record) + '</table>')
    footer = (
        '<hr style="margin:8px 0 14px;border:none;border-top:1px solid #e5e7eb;">'
        '<p style="font-size:12px;color:#9ca3af;text-align:center;margin:0;'
        'line-height:1.6;">You are receiving this because you are an external '
        'recipient of this SCADA report.</p>')
    return _shell(record.get("subject", "SCADA Report"), body,
                  "SCADA Report", footer)


def _log_external_send(record, recipients, sent):
    """Best-effort notification-log entry for the external send (audit)."""
    if notifications is None:
        return
    try:
        for r in recipients:
            notifications._record_sent(
                f"scada_report:{record.get('id')}",
                "sent" if sent else "send_failed", r)
    except Exception:  # pragma: no cover - logging must never break the flow
        _LOG.exception("Failed to log external SCADA report send")
