"""Doctor-bundle primitive — diagnostic report scaffold.

Stability tier: **experimental** (AP-11) — added in 0.36.0 to
promote the existing ``mgf-common doctor`` CLI command's
report-rendering logic into a reusable primitive. vm-vmanager-app's
``vmanager doctor bundle`` + iosRecovery's planned
``figrecovery doctor bundle`` both wire this.

Why this module exists
----------------------

Two-consumer pattern: vm-vmanager-app already has a "doctor bundle"
shape; iosRecovery's `STARTHERE.md` calls one out as Phase 0
scaffolding. Both projects need:

- A redacted text + JSON snapshot of "what does this install
  look like?" — settings, version, paths, runtime info.
- Section composability — a consumer's doctor extends the
  built-in sections with project-specific ones (VM connectivity,
  iFig device detection, GPU presence, ...).
- File output at ``0o600`` (per SC-13 — diagnostic bundles often
  carry redacted-but-still-sensitive info).
- A status enum for at-a-glance ops scanning.

Usage
-----

.. code-block:: python

    from mgf.common.doctor import DoctorBundle, ReportSection

    bundle = DoctorBundle(app_name="my-app", app_version="1.0.0")
    bundle.add_section(ReportSection(
        name="settings",
        status="ok",
        details={"app_name": "my-app", "log_level": "INFO"},
    ))
    bundle.add_section(ReportSection(
        name="gpu",
        status="warn",
        details={"detected": True, "driver_version": "<old>", "advice": "upgrade to >=550"},
    ))

    # Render
    print(bundle.render_text())
    bundle.write_to(Path("/tmp/my-app-doctor.txt"))  # 0o600

    # Or render JSON (machine-readable for SIEM ingest)
    import json
    print(json.dumps(bundle.render_json(), indent=2))

Status taxonomy
---------------

Three values, ordered by severity:

- ``"ok"`` — section observed, contract met.
- ``"warn"`` — section observed, non-blocking deviation found.
  Operator should investigate but the system is still
  functional.
- ``"fail"`` — section observed, contract violated. Operator
  must intervene.

The bundle's ``overall_status`` property aggregates: any
``fail`` → ``fail``; any ``warn`` → ``warn``; otherwise ``ok``.

Redaction
---------

``ReportSection.details`` is a generic ``Mapping[str, Any]``.
**Sensitive values MUST be redacted at construction** by the
consumer — the doctor primitive does NOT automatically scrub
(it would mask consumer bugs). Pair with
``mgf.common.observability.redaction.Redactor`` at the section
construction site for the canonical redaction flow.

Cross-references
----------------

- **SC-13** — file modes for sensitive on-disk artefacts.
- **OP-04** — the doctor / health-check pattern.
- **AP-11** — stability tiers.

Cross-consumer pattern: see
`docs/inprogress/pre_release_additions.md` §5 (C-NEW-3 row).
"""

from __future__ import annotations

import contextlib
import json
import os
import sys
import tempfile
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    Literal,
)

if TYPE_CHECKING:
    from collections.abc import Mapping

__all__ = [
    "DoctorBundle",
    "ReportSection",
    "ReportStatus",
]


ReportStatus = Literal["ok", "warn", "fail"]
"""Section status. Lower-cased + literal for JSON-stability."""

_STATUS_RANK = {"ok": 0, "warn": 1, "fail": 2}
_STATUS_SYMBOL = {"ok": "✓", "warn": "⚠", "fail": "✗"}


@dataclass(frozen=True)
class ReportSection:
    """One section of a doctor report.

    Frozen — sections are append-only on the bundle; mutating
    a section in-place would surprise consumers. Use
    :meth:`with_details_merged` to derive an updated copy.
    """

    name: str
    """Section identifier. Convention: lowercase-with-dashes
    (``settings``, ``gpu``, ``db-connectivity``)."""

    status: ReportStatus
    """One of ``"ok"``, ``"warn"``, ``"fail"`` (see module
    docstring for the taxonomy)."""

    details: Mapping[str, Any] = field(default_factory=dict)
    """Structured per-section data. Sensitive values MUST be
    pre-redacted at construction; the bundle does NOT scrub
    automatically."""

    def with_details_merged(
        self,
        more: Mapping[str, Any],
    ) -> ReportSection:
        """Return a copy with ``more`` merged into ``details``.
        Convenience for composing sections mid-construction.
        """
        merged = {**dict(self.details), **dict(more)}
        return ReportSection(name=self.name, status=self.status, details=merged)


@dataclass
class DoctorBundle:
    """A composable diagnostic bundle.

    Construct with ``app_name`` + ``app_version``; add sections
    via :meth:`add_section`. Render to text or JSON; write to
    disk with ``0o600`` mode.

    Sections are kept in insertion order — consumers ordering
    matters (e.g., "settings first, then connectivity, then
    runtime"). Re-adding a section with the same name REPLACES
    the previous one (idempotent compose; common pattern in
    consumer doctors).
    """

    app_name: str
    app_version: str
    when: datetime = field(default_factory=lambda: datetime.now(UTC))
    _sections: list[ReportSection] = field(default_factory=list)
    _section_index: dict[str, int] = field(default_factory=dict)

    def add_section(self, section: ReportSection) -> None:
        """Append ``section``. If a section with the same name
        already exists, REPLACE it (idempotent compose)."""
        if section.name in self._section_index:
            idx = self._section_index[section.name]
            self._sections[idx] = section
        else:
            self._section_index[section.name] = len(self._sections)
            self._sections.append(section)

    def sections(self) -> tuple[ReportSection, ...]:
        """Read-only view of the registered sections, in
        insertion order."""
        return tuple(self._sections)

    @property
    def overall_status(self) -> ReportStatus:
        """The worst status across all sections.

        Empty bundle returns ``"ok"`` — the canonical "we have
        nothing to report" state. Some consumers prefer ``"warn"``
        for empty; if you need that, layer it in your consumer.
        """
        worst = "ok"
        for s in self._sections:
            if _STATUS_RANK[s.status] > _STATUS_RANK[worst]:
                worst = s.status
        return worst  # type: ignore[return-value]

    # ------------------------------------------------------------------
    # Rendering
    # ------------------------------------------------------------------

    def render_text(self) -> str:
        """Human-readable text report. Designed for terminal +
        bug-report-paste. ASCII + a tiny status glyph set."""
        lines: list[str] = []
        lines.append(f"=== {self.app_name} doctor report ===")
        lines.append(f"app_version: {self.app_version}")
        lines.append(f"generated:   {self.when.isoformat()}")
        lines.append(f"overall:     {_STATUS_SYMBOL[self.overall_status]} {self.overall_status}")
        lines.append("")
        if not self._sections:
            lines.append("(no sections registered)")
            lines.append("")
            return "\n".join(lines)

        for section in self._sections:
            lines.append(f"[{_STATUS_SYMBOL[section.status]} {section.status:5}] {section.name}")
            if section.details:
                for k, v in section.details.items():
                    lines.append(f"    {k}: {v}")
            lines.append("")
        # Cap with platform line for triage.
        lines.append(f"-- platform: {sys.platform}, python: {sys.version.split()[0]} --")
        return "\n".join(lines)

    def render_json(self) -> dict[str, Any]:
        """Machine-readable JSON-friendly dict. Stable schema for
        SIEM / log-aggregator ingestion."""
        return {
            "schema_version": 1,
            "app_name": self.app_name,
            "app_version": self.app_version,
            "generated_at": self.when.isoformat(),
            "overall_status": self.overall_status,
            "platform": sys.platform,
            "python_version": sys.version.split()[0],
            "sections": [
                {
                    "name": s.name,
                    "status": s.status,
                    "details": dict(s.details),
                }
                for s in self._sections
            ],
        }

    # ------------------------------------------------------------------
    # Disk output
    # ------------------------------------------------------------------

    def write_to(
        self,
        path: Path,
        *,
        output_format: Literal["text", "json"] = "text",
    ) -> None:
        """Write the rendered report to ``path`` with ``0o600``
        (owner-only read/write) per SC-13.

        Writes atomically via tmp + rename so a partial write
        never leaves a half-readable bundle on disk. The parent
        directory MUST already exist (we never create arbitrary
        directory trees — SC-13).

        Parameters
        ----------
        path
            Destination. The directory must exist.
        output_format
            ``"text"`` (default) or ``"json"``. JSON is rendered
            with ``indent=2`` for grep-ability. (Named
            ``output_format`` to avoid shadowing the builtin
            ``format``.)
        """
        if output_format == "text":
            content = self.render_text()
        elif output_format == "json":
            content = json.dumps(self.render_json(), indent=2, default=str)
        else:
            raise ValueError(
                f"DoctorBundle.write_to(output_format=...) must be 'text' or "
                f"'json'; got {output_format!r}"
            )

        if not path.parent.is_dir():
            raise FileNotFoundError(
                f"DoctorBundle.write_to: parent directory {path.parent!r} does "
                f"not exist. We don't create arbitrary directory trees per "
                f"SC-13 — create the parent first, then call write_to()."
            )

        # Atomic write: tmp in the same dir, then rename.
        fd, tmp_path_str = tempfile.mkstemp(
            prefix=f".{path.name}.",
            suffix=".tmp",
            dir=str(path.parent),
        )
        tmp_path = Path(tmp_path_str)
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                f.write(content)
            # 0o600 — owner-only read/write (SC-13).
            tmp_path.chmod(0o600)
            tmp_path.replace(path)
        except Exception:
            # Best-effort cleanup of the tmp file on any failure.
            with contextlib.suppress(OSError):
                tmp_path.unlink()
            raise
