"""Learning and agent memory tools plugin.

Extracts cross-printer learning and persistent agent memory tools from
server.py into a focused plugin module.  These tools enable agents to
record print outcomes, query learning insights, get printer suggestions,
recommend settings, and persist notes across sessions.

Auto-discovered by :func:`~kiln.plugin_loader.register_all_plugins` —
no manual imports needed.
"""

from __future__ import annotations

import contextlib
import logging
import os
import time
from typing import Any

from kiln.failure_vocabulary import (
    AUTO_CLASSIFY_MIN_CONFIDENCE as _AUTO_CLASSIFY_MIN_CONFIDENCE,
)
from kiln.failure_vocabulary import (
    VALID_FAILURE_MODES as _VALID_FAILURE_MODES,
)
from kiln.failure_vocabulary import (
    to_canonical as _to_canonical_failure_mode,
)

_logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Constants — validation sets and safety limits
# ---------------------------------------------------------------------------

_VALID_OUTCOMES = frozenset({"success", "failed", "partial"})
_VALID_QUALITY_GRADES = frozenset({"excellent", "good", "acceptable", "poor"})

# Hard safety limits — recorded settings cannot exceed these.
# Prevents malicious agents from poisoning the learning database
# with dangerous temperature data that could damage printers.
_MAX_SAFE_TOOL_TEMP: float = 320.0  # Above this, even high-temp materials are dangerous
_MAX_SAFE_BED_TEMP: float = 140.0
_MAX_SAFE_SPEED: float = 500.0  # mm/s — beyond any consumer printer

_LEARNING_SAFETY_NOTICE = (
    "These insights are advisory only. They do NOT override safety limits. "
    "Always run preflight checks before starting a print. Temperature and "
    "G-code safety enforcement applies regardless of learning data."
)


# ---------------------------------------------------------------------------
# Standalone functions — importable for direct calls and testing.
#
# Each function reads _check_auth, _error_dict, etc. from kiln.server at
# call time (via module attribute access) so that monkeypatching works in
# tests.
# ---------------------------------------------------------------------------


def _auto_classify_failure(
    job_id: str | None,
    printer_name: str | None,
) -> dict[str, Any] | None:
    """Run the failure classifier and translate its output for storage.

    Returns a dict describing the classification (always safe to echo to
    the caller), or ``None`` when the classifier cannot be reached.  The
    ``stored`` flag tells the caller whether the classification was
    confident enough to become the row's ``failure_mode``.

    Separated from ``record_print_outcome`` so the logic has a single
    testable entry point and so the caller still writes the outcome row
    when classification fails — never lets auto-classify break the
    primitive.
    """
    try:
        from kiln.failure_recovery import analyze_failure
    except Exception:
        _logger.debug("Failure classifier unavailable", exc_info=True)
        return None

    try:
        analysis = analyze_failure(job_id=job_id, printer_name=printer_name)
    except Exception:
        _logger.debug("Failure classifier raised", exc_info=True)
        return None

    classification = analysis.classification
    raw_type = classification.failure_type.value
    canonical = _to_canonical_failure_mode(raw_type)
    stored = (
        canonical is not None
        and classification.confidence >= _AUTO_CLASSIFY_MIN_CONFIDENCE
    )
    return {
        "failure_type": raw_type,
        "db_mode": canonical or raw_type,  # echo something even on untranslatable
        "confidence": round(classification.confidence, 3),
        "evidence": list(classification.evidence),
        "stored": stored,
        "min_confidence": _AUTO_CLASSIFY_MIN_CONFIDENCE,
    }


def record_print_outcome(
    job_id: str,
    outcome: str,
    quality_grade: str | None = None,
    failure_mode: str | None = None,
    settings: dict | None = None,
    environment: dict | None = None,
    notes: str | None = None,
    printer_name: str | None = None,
    file_name: str | None = None,
    file_hash: str | None = None,
    material_type: str | None = None,
    decoration_slug: str | None = None,
    decoration_settings: dict | None = None,
    auto_classify: bool = False,
    auto_recorded: bool = False,
) -> dict:
    """Record the outcome of a print for cross-printer learning.

    The learning database helps agents make better decisions about which
    printer to use for a given job and material.  Outcomes are agent-curated
    quality data — separate from the auto-populated print history.

    **Safety**: Settings are validated against hard safety limits.  Outcomes
    with temperatures exceeding safe maximums are rejected to prevent
    poisoning the learning database with dangerous data.

    **Decoration feedback**: When ``decoration_slug`` is provided, the
    corresponding decoration's proven-settings counter is auto-updated
    (``success_count`` or ``failure_count``) so the library's tracked
    reliability reflects real field outcomes without manual curation.

    **Auto-classification** (opt-in): When ``auto_classify=True`` and the
    outcome is ``"failed"`` with no explicit ``failure_mode``, the failure
    classifier runs (:func:`kiln.failure_recovery.analyze_failure`) and
    its result is mapped into the canonical DB vocabulary.  The
    classification is always echoed back in the ``auto_classification``
    key of the response; it is only STORED as ``failure_mode`` when the
    classifier's confidence meets or exceeds
    ``_AUTO_CLASSIFY_MIN_CONFIDENCE`` (0.75).  Lower-confidence guesses
    are surfaced to the caller without poisoning the learning database.

    Args:
        job_id: The job ID from the print queue.
        outcome: One of ``"success"``, ``"failed"``, or ``"partial"``.
        quality_grade: Optional — ``"excellent"``, ``"good"``, ``"acceptable"``, ``"poor"``.
        failure_mode: Optional — e.g. ``"spaghetti"``, ``"layer_shift"``, ``"warping"``.
        settings: Optional dict of print settings used (temp_tool, temp_bed, speed, etc.).
        environment: Optional dict of environment conditions (ambient_temp, humidity).
        notes: Optional free-text notes about the print.
        printer_name: Printer used.  Auto-resolved from job if omitted.
        file_name: File printed.  Auto-resolved from job if omitted.
        file_hash: Optional hash of the file for cross-printer comparison.
        material_type: Material used (e.g. ``"PLA"``, ``"PETG"``).
        decoration_slug: Optional decoration slug that was applied to this
            print.  When set, the matching decoration's success/failure
            counters are auto-updated.
        decoration_settings: Optional dict of decoration settings used
            (``depth_mm``, ``mode``, ``image_style``).  Falls back to the
            decoration's current defaults when omitted.
        auto_classify: When True and this outcome is a failure with no
            explicit ``failure_mode``, run the failure classifier and
            store its best-guess mode if confidence >= 0.75.  Default
            False — callers opt in.
        auto_recorded: When True, tags the outcome as auto-fired by
            the terminal-state hook (see
            :mod:`kiln.auto_record_hook`).  Agents can later refine
            the outcome by calling record_print_outcome again with the
            same ``job_id`` — the most recent call wins at the
            ``proven_settings`` level.  Default False.
    """
    import kiln.server as _srv
    from kiln.persistence import get_db

    if err := _srv._check_auth("learning"):
        return err

    # --- Validate enums ---
    if outcome not in _VALID_OUTCOMES:
        return _srv._error_dict(
            f"Invalid outcome {outcome!r}. Must be one of: {', '.join(sorted(_VALID_OUTCOMES))}",
            code="VALIDATION_ERROR",
        )
    if quality_grade and quality_grade not in _VALID_QUALITY_GRADES:
        return _srv._error_dict(
            f"Invalid quality_grade {quality_grade!r}. Must be one of: {', '.join(sorted(_VALID_QUALITY_GRADES))}",
            code="VALIDATION_ERROR",
        )

    # --- Auto-classification (opt-in) ---
    # Runs only for failed outcomes with no explicit failure_mode, and
    # only when the caller opts in.  High-confidence classifications
    # promote to ``failure_mode``; lower-confidence results are echoed
    # back so the caller can decide what to do.  Never raises.
    auto_classification: dict[str, Any] | None = None
    if auto_classify and outcome == "failed" and not failure_mode:
        auto_classification = _auto_classify_failure(job_id, printer_name)
        if auto_classification and auto_classification.get("stored"):
            failure_mode = auto_classification["db_mode"]

    if failure_mode and failure_mode not in _VALID_FAILURE_MODES:
        return _srv._error_dict(
            f"Invalid failure_mode {failure_mode!r}. Must be one of: {', '.join(sorted(_VALID_FAILURE_MODES))}",
            code="VALIDATION_ERROR",
        )

    # --- Safety: validate settings against hard limits ---
    if settings:
        _SETTING_LIMITS = {
            "temp_tool": (0.0, _MAX_SAFE_TOOL_TEMP, "\u00b0C"),
            "temp_bed": (0.0, _MAX_SAFE_BED_TEMP, "\u00b0C"),
            "speed": (0.0, _MAX_SAFE_SPEED, "mm/s"),
        }
        for key, (lo, hi, unit) in _SETTING_LIMITS.items():
            raw = settings.get(key)
            if raw is None:
                continue
            try:
                val = float(raw)
            except (ValueError, TypeError):
                return _srv._error_dict(
                    f"Setting {key!r} value {raw!r} is not a valid number.",
                    code="VALIDATION_ERROR",
                )
            if val < lo or val > hi:
                return _srv._error_dict(
                    f"Recorded {key} {val}{unit} is outside safe range "
                    f"({lo}\u2013{hi}{unit}). Outcome rejected to protect hardware.",
                    code="SAFETY_VIOLATION",
                )

    # --- Resolve printer/file from job if not provided ---
    try:
        job_record = get_db().get_print_record(job_id)
        if job_record and not printer_name:
            printer_name = job_record.get("printer_name", "unknown")
        if job_record and not file_name:
            file_name = job_record.get("file_name")
    except Exception as exc:
        _logger.debug("Failed to resolve printer/file from job %s: %s", job_id, exc)

    if not printer_name:
        printer_name = "unknown"

    # Bug #10: auto-recorded outcomes get a tag in the notes so agents
    # and analytics can distinguish them from agent-curated outcomes.
    # This is the minimal tagging that avoids a schema change; a full
    # dedicated column lives on the follow-up roadmap.
    if auto_recorded:
        tag = "[auto-recorded]"
        if notes:
            if tag not in notes:
                notes = f"{tag} {notes}"
        else:
            notes = tag

    try:
        row_id = get_db().save_print_outcome(
            {
                "job_id": job_id,
                "printer_name": printer_name,
                "file_name": file_name,
                "file_hash": file_hash,
                "material_type": material_type,
                "outcome": outcome,
                "quality_grade": quality_grade,
                "failure_mode": failure_mode,
                "settings": settings,
                "environment": environment,
                "notes": notes,
                "agent_id": "auto" if auto_recorded else "mcp",
                "created_at": time.time(),
            }
        )
        # Telemetry: count completed print + print hours
        try:
            from kiln.daily_stats import record_event, record_print_hours
            record_event("prints")
            # Try to get print time from the job record
            if job_record and job_record.get("print_time_seconds"):
                record_print_hours(job_record["print_time_seconds"] / 3600.0)
        except Exception:
            pass

        # Auto-contribute to community (anonymous, opt-in).
        #
        # The ``printer_model`` field is what lets the pull side
        # ``fetch_community_insights`` aggregate across users with the
        # same hardware — so we need a canonical model identifier, not
        # the per-user ``printer_name`` (which might be "my-bambu-01").
        # Resolution order:
        #   1. adapter.get_printer_info().model — the printer's own
        #      self-reported model string (what
        #      ``resolve_printer_generation_context`` uses on the pull
        #      side — guarantees push/pull match)
        #   2. printer_name — backward compat with existing rows
        #      pre-dating this fix
        try:
            import hashlib as _hl
            import json as _js

            from kiln.community_sync import community_opt_in_enabled

            if community_opt_in_enabled() and file_hash:
                from kiln.community_sync import sync_community_print_async

                resolved_model: str | None = None
                try:
                    adapter = _srv._registry.get(printer_name)
                    if adapter is not None:
                        info = adapter.get_printer_info()
                        resolved_model = (
                            getattr(info, "model", None)
                            or getattr(info, "printer_model", None)
                        )
                except Exception:
                    _logger.debug("Could not resolve printer_model for community push", exc_info=True)

                _grade_map = {
                    "excellent": "A", "good": "B",
                    "acceptable": "C", "poor": "D",
                }
                sync_community_print_async({
                    "geometric_signature": file_hash,
                    "printer_model": resolved_model or printer_name or "unknown",
                    "material": material_type or "unknown",
                    "settings_hash": _hl.sha256(
                        _js.dumps(settings or {}, sort_keys=True).encode(),
                    ).hexdigest()[:16],
                    "settings": settings,
                    "outcome": outcome,
                    "quality_grade": _grade_map.get(quality_grade or "", "B"),
                    "failure_mode": failure_mode,
                    "print_time_seconds": 0,
                })
        except Exception:
            pass  # Never let community sync block outcome recording

        # Auto-update decoration proven-settings counters when this print
        # carried a decoration.  Mirrors the community-sync pattern — the
        # dispatch is silent and never blocks the outcome return.
        #
        # We only dispatch when we have real settings to record: either
        # the caller supplied them, or the library already has a proven
        # setting for this material that we can re-stamp.  If neither is
        # true we skip silently rather than invent defaults — proven
        # settings must reflect real prints, not our guesses.
        if decoration_slug and material_type and outcome in ("success", "failed"):
            try:
                from kiln.decoration_library import (
                    get_decoration,
                    record_decoration_failure,
                    record_decoration_success,
                )

                dec = get_decoration(decoration_slug)
                if dec is not None:
                    ds = decoration_settings or {}
                    existing = dec.proven_settings.get(material_type)
                    have_explicit = ds.get("depth_mm") is not None
                    if have_explicit or existing is not None:
                        depth_mm = float(
                            ds["depth_mm"] if have_explicit else existing.depth_mm
                        )
                        mode = str(ds.get("mode") or (existing.mode if existing else "emboss"))
                        image_style = str(
                            ds.get("image_style")
                            or (existing.image_style if existing else "auto")
                        )

                        if outcome == "success":
                            record_decoration_success(
                                decoration_slug,
                                material=material_type,
                                depth_mm=depth_mm,
                                mode=mode,
                                image_style=image_style,
                            )
                        else:
                            record_decoration_failure(
                                decoration_slug,
                                material=material_type,
                                depth_mm=depth_mm,
                                mode=mode,
                                image_style=image_style,
                                failure_mode=failure_mode,
                            )
            except Exception:
                _logger.debug(
                    "Decoration outcome update failed (non-fatal)",
                    exc_info=True,
                )

        result: dict[str, Any] = {
            "success": True,
            "outcome_id": row_id,
            "job_id": job_id,
            "printer_name": printer_name,
            "outcome": outcome,
            "quality_grade": quality_grade,
        }
        if failure_mode is not None:
            result["failure_mode"] = failure_mode
        if auto_classification is not None:
            result["auto_classification"] = auto_classification
        return result
    except Exception as exc:
        _logger.exception("Unexpected error in record_print_outcome")
        return _srv._error_dict(f"Unexpected error: {exc}", code="INTERNAL_ERROR")


def recommend_settings(
    printer_name: str | None = None,
    material_type: str | None = None,
    file_hash: str | None = None,
) -> dict:
    """Recommend print settings based on historical successful outcomes.

    Queries the learning database for settings that produced successful
    prints, filtered by printer, material, and/or file hash.  Returns
    aggregated recommendations (most common temps, speeds, slicer profiles)
    plus the raw successful settings for agent review.

    When kiln-pro is installed AND the user has a calibrated slicer
    profile for ``(printer_name, material_type)``, the calibration coach
    overlays personally-tuned values (flow rate, max volumetric speed,
    pressure advance, retraction distance) on top of the historical
    medians.  The response gains a ``calibration_used`` block and an
    extra ``rationale`` line per overridden value naming the slicer +
    staleness — so the user sees "Using your calibrated flow rate of
    0.95 from OrcaSlicer (updated 12 days ago)" instead of the generic
    aggregate.  Behavior is unchanged when kiln-pro is not installed
    or no calibration is available.

    **Note**: Recommendations are advisory.  They do NOT override safety
    limits or preflight checks.  Always validate settings against printer
    safety profiles before use.

    Args:
        printer_name: Filter by printer (e.g. ``"voron-350"``).
        material_type: Filter by material (e.g. ``"PLA"``, ``"PETG"``).
        file_hash: Filter by file hash for exact file matching.
    """
    import kiln.server as _srv
    from kiln.persistence import get_db

    if err := _srv._check_auth("learning"):
        return err

    if not printer_name and not material_type and not file_hash:
        return _srv._error_dict(
            "At least one filter required: printer_name, material_type, or file_hash",
            code="VALIDATION_ERROR",
        )

    try:
        outcomes = get_db().get_successful_settings(
            printer_name=printer_name,
            material_type=material_type,
            file_hash=file_hash,
            limit=20,
        )

        # Calibration overlay — when kiln-pro is installed AND the user
        # has a calibrated profile for (printer_name, material_type),
        # the coach attaches a calibration_used block and overrides
        # specific slicer values (flow_rate, max_volumetric_speed,
        # pressure_advance, retraction_distance) on top of the historical
        # aggregates.  Lazy-imported so public Kiln keeps working without
        # the pro package.  Returns (None, None, []) when calibration
        # is not available — behavior is then identical to today.
        cal_used, cal_overrides, cal_rationale = _recommend_calibration_overlay(
            printer_name=printer_name,
            material_type=material_type,
        )

        if not outcomes:
            empty: dict[str, Any] = {
                "success": True,
                "has_data": False,
                "message": "No successful outcomes found for the given criteria.",
                "query": {
                    "printer_name": printer_name,
                    "material_type": material_type,
                    "file_hash": file_hash,
                },
                "safety_notice": _LEARNING_SAFETY_NOTICE,
            }
            # Even with zero historical outcomes, surface calibrated
            # overrides so the user's tuning shows up in the response.
            if cal_overrides:
                empty["recommended_settings"] = dict(cal_overrides)
                empty["rationale"] = list(cal_rationale)
            if cal_used is not None:
                empty["calibration_used"] = cal_used
            return empty

        # Aggregate settings across successful outcomes
        temp_tools: list[float] = []
        temp_beds: list[float] = []
        speeds: list[float] = []
        slicer_profiles: list[str] = []
        quality_grades: list[str] = []

        for o in outcomes:
            _settings = o.get("settings") or {}
            if isinstance(_settings, dict):
                if "temp_tool" in _settings:
                    with contextlib.suppress(ValueError, TypeError):
                        temp_tools.append(float(_settings["temp_tool"]))
                if "temp_bed" in _settings:
                    with contextlib.suppress(ValueError, TypeError):
                        temp_beds.append(float(_settings["temp_bed"]))
                if "speed" in _settings:
                    with contextlib.suppress(ValueError, TypeError):
                        speeds.append(float(_settings["speed"]))
                if "slicer_profile" in _settings:
                    slicer_profiles.append(str(_settings["slicer_profile"]))
            if o.get("quality_grade"):
                quality_grades.append(o["quality_grade"])

        def _median(vals: list[float]) -> float | None:
            if not vals:
                return None
            s = sorted(vals)
            n = len(s)
            if n % 2 == 1:
                return round(s[n // 2], 1)
            return round((s[n // 2 - 1] + s[n // 2]) / 2, 1)

        def _mode_str(vals: list[str]) -> str | None:
            if not vals:
                return None
            from collections import Counter

            return Counter(vals).most_common(1)[0][0]

        recommended = {}
        if temp_tools:
            recommended["temp_tool"] = _median(temp_tools)
        if temp_beds:
            recommended["temp_bed"] = _median(temp_beds)
        if speeds:
            recommended["speed"] = _median(speeds)
        if slicer_profiles:
            recommended["slicer_profile"] = _mode_str(slicer_profiles)

        # Confidence based on sample size
        n = len(outcomes)
        confidence = "low" if n < 3 else ("medium" if n < 10 else "high")

        # Calibration overrides win over historical medians — when the
        # user has personally tuned the machine, that work supersedes
        # the generic learning aggregate.  Only flow physics keys are
        # touched; temps and speeds keep their historical values.
        if cal_overrides:
            recommended.update(cal_overrides)

        result: dict[str, Any] = {
            "success": True,
            "has_data": True,
            "recommended_settings": recommended,
            "sample_size": n,
            "confidence": confidence,
            "quality_distribution": {
                grade: quality_grades.count(grade)
                for grade in ["excellent", "good", "acceptable", "poor"]
                if quality_grades.count(grade) > 0
            },
            "query": {
                "printer_name": printer_name,
                "material_type": material_type,
                "file_hash": file_hash,
            },
            "recent_successful_settings": [
                {
                    "settings": o.get("settings"),
                    "quality_grade": o.get("quality_grade"),
                    "printer_name": o.get("printer_name"),
                    "material_type": o.get("material_type"),
                    "notes": o.get("notes"),
                }
                for o in outcomes[:5]  # Only show top 5
            ],
            "safety_notice": _LEARNING_SAFETY_NOTICE,
        }
        if cal_rationale:
            result["rationale"] = list(cal_rationale)
        if cal_used is not None:
            result["calibration_used"] = cal_used
        return result
    except Exception as exc:
        _logger.exception("Unexpected error in recommend_settings")
        return _srv._error_dict(f"Unexpected error: {exc}", code="INTERNAL_ERROR")


# Tiers where the user's calibrated values win over historical medians.
# HIGH: user verified values on this machine.
# MEDIUM: imported profile diverges from defaults — treated as personally
#   tuned (or vendor-baseline fallback, which is what we'd inject anyway).
# LOW / UNKNOWN: skip — values look like defaults, no point overriding
#   with the same numbers.
_RECOMMEND_OVERRIDE_TIERS: frozenset[str] = frozenset({"high", "medium"})


def _recommend_calibration_overlay(
    *,
    printer_name: str | None,
    material_type: str | None,
) -> tuple[dict | None, dict, list[str]]:
    """Lazy-import calibration_coach and resolve overrides for a recommend.

    Returns ``(calibration_used_block, overrides, rationale_lines)``.
    The block is the standard payload from
    :func:`kiln_pro.engineering.calibration_coach.calibration_used_block`
    so the response shape matches the 6 other wire-up sites
    (compute_iso_fit, design_for_load, tolerance_stack_analysis,
    compute_hole, get_clearance_recommendation, slice_and_estimate's
    slicer-args injection).

    Returns ``(None, {}, [])`` when:

    - kiln-pro is not installed (the public Kiln free-tier path)
    - ``printer_name`` is None (calibration is meaningless without a printer)
    - calibration tier is LOW or UNKNOWN (no override warranted)
    - the coach raises (defensive — never break the recommend path)

    Behavior in those cases is identical to the historic recommend
    output, so the wire-up is a strict superset of today's surface.
    """
    if printer_name is None:
        return None, {}, []
    try:
        from kiln_pro.engineering.calibration_coach import (  # type: ignore[import-not-found]
            calibration_for,
            calibration_used_block,
        )
    except ImportError:
        return None, {}, []

    try:
        verdict = calibration_for(printer_name, material_type)
        cal_used = calibration_used_block(verdict, printer_id=printer_name)
    except Exception:
        return None, {}, []

    tier = getattr(verdict.tier, "value", None) or str(verdict.tier)
    if tier not in _RECOMMEND_OVERRIDE_TIERS:
        # LOW / UNKNOWN: still surface the calibration_used block so
        # the user knows the coach looked, but no override.
        return cal_used, {}, []

    profile = verdict.profile
    if profile is None:
        return cal_used, {}, []

    overrides: dict[str, Any] = {}
    rationale: list[str] = []
    candidates: tuple[tuple[str, Any], ...] = (
        ("flow_rate", profile.flow_rate),
        ("max_volumetric_speed", profile.max_volumetric_speed_mm3s),
        ("pressure_advance", profile.pressure_advance),
        ("retraction_distance", profile.retraction_distance_mm),
    )

    slicer_pretty = {
        "orcaslicer": "OrcaSlicer",
        "bambustudio": "Bambu Studio",
        "prusaslicer": "PrusaSlicer",
        "vendor_baseline": "the manufacturer baseline",
    }.get(profile.slicer_name, profile.slicer_name)

    age_days = profile.age_days() if hasattr(profile, "age_days") else None
    if age_days is None or profile.slicer_name == "vendor_baseline":
        age_phrase = ""
    else:
        days = int(round(age_days))
        if days <= 0:
            age_phrase = " (updated today)"
        elif days == 1:
            age_phrase = " (updated 1 day ago)"
        else:
            age_phrase = f" (updated {days} days ago)"

    for key, value in candidates:
        if value is None:
            continue
        overrides[key] = value
        formatted = f"{value:g}"
        rationale.append(
            f"Using your calibrated {key} of {formatted} from "
            f"{slicer_pretty}{age_phrase}"
        )

    staleness_days = getattr(verdict, "staleness_days", None)
    if (
        staleness_days is not None
        and isinstance(staleness_days, (int, float))
        and staleness_days > 180.0
    ):
        rationale.append(
            f"Calibration is {int(round(staleness_days))} days old — "
            "consider re-running the wizard for a fresher baseline."
        )

    return cal_used, overrides, rationale


def save_agent_note(
    key: str,
    value: str,
    scope: str = "global",
    printer_name: str | None = None,
    ttl_seconds: int | None = None,
) -> dict:
    """Save a persistent note or preference that survives across sessions.

    Use this to remember printer quirks, calibration findings, material
    preferences, or any operational knowledge worth preserving.

    Args:
        key: Name for this memory (e.g., ``"z_offset_adjustment"``, ``"pla_temp_notes"``).
        value: The information to store.
        scope: Namespace — ``"global"``, ``"fleet"``, or use *printer_name* for printer-specific.
        printer_name: If provided, scope is automatically set to ``"printer:<name>"``.
        ttl_seconds: Optional time-to-live in seconds.  The note will be
            automatically excluded from queries after this duration.  Pass
            ``None`` (default) for notes that should never expire.
    """
    import kiln.server as _srv
    from kiln.persistence import get_db

    if err := _srv._check_auth("memory"):
        return err
    try:
        agent_id = os.environ.get("KILN_AGENT_ID", "default")
        effective_scope = f"printer:{printer_name}" if printer_name else scope
        get_db().save_memory(agent_id, effective_scope, key, value, ttl_seconds=ttl_seconds)
        return {
            "success": True,
            "agent_id": agent_id,
            "scope": effective_scope,
            "key": key,
            "ttl_seconds": ttl_seconds,
        }
    except Exception as exc:
        _logger.exception("Unexpected error in save_agent_note")
        return _srv._error_dict(f"Unexpected error: {exc}", code="INTERNAL_ERROR")


def get_agent_context(
    printer_name: str | None = None,
    scope: str | None = None,
) -> dict:
    """Retrieve all stored agent memory for context.

    Call this at the start of a session to recall what you've learned
    about printers, materials, and past print outcomes.  Expired entries
    are automatically filtered out.  Each entry includes a ``version``
    field showing how many times it has been updated.

    Args:
        printer_name: If provided, retrieves printer-specific memory.
        scope: Filter by scope (e.g., ``"global"``, ``"fleet"``).
    """
    import kiln.server as _srv
    from kiln.persistence import get_db

    if err := _srv._check_auth("memory"):
        return err
    try:
        agent_id = os.environ.get("KILN_AGENT_ID", "default")
        effective_scope = f"printer:{printer_name}" if printer_name else scope
        entries = get_db().list_memory(agent_id, scope=effective_scope)
        return {"success": True, "agent_id": agent_id, "entries": entries, "count": len(entries)}
    except Exception as exc:
        _logger.exception("Unexpected error in get_agent_context")
        return _srv._error_dict(f"Unexpected error: {exc}", code="INTERNAL_ERROR")


def clean_agent_memory() -> dict:
    """Remove all expired agent memory entries.

    Entries with a TTL that has elapsed are permanently deleted.
    Returns the count of entries removed.
    """
    import kiln.server as _srv
    from kiln.persistence import get_db

    if err := _srv._check_auth("memory"):
        return err
    try:
        deleted = get_db().clean_expired_notes()
        return {
            "success": True,
            "deleted_count": deleted,
            "message": f"Cleaned {deleted} expired memory entries.",
        }
    except Exception as exc:
        _logger.exception("Unexpected error in clean_agent_memory")
        return _srv._error_dict(f"Unexpected error: {exc}", code="INTERNAL_ERROR")


# ---------------------------------------------------------------------------
# Plugin class — registers standalone functions as MCP tools.
# ---------------------------------------------------------------------------


class _LearningToolsPlugin:
    """Cross-printer learning and persistent agent memory tools.

    Provides tools for recording print outcomes, querying learning
    insights, suggesting printers and settings, and managing agent
    memory that persists across sessions.
    """

    @property
    def name(self) -> str:
        return "learning_tools"

    @property
    def description(self) -> str:
        return "Cross-printer learning and agent memory tools"

    def register(self, mcp: Any) -> None:
        """Register learning and memory tools with the MCP server."""

        # Lazy imports — by the time register() runs, server.py is fully
        # initialized so these resolve without circular import issues.
        from kiln.persistence import get_db
        from kiln.server import _check_auth, _error_dict, _registry

        mcp.tool()(record_print_outcome)
        mcp.tool()(recommend_settings)
        mcp.tool()(save_agent_note)
        mcp.tool()(get_agent_context)
        mcp.tool()(clean_agent_memory)

        @mcp.tool()
        def get_printer_insights(
            printer_name: str,
            limit: int = 20,
        ) -> dict:
            """Query cross-printer learning insights for a specific printer.

            Returns success rates, failure mode breakdown, and per-material
            statistics based on previously recorded outcomes.

            **Note**: Insights are advisory.  They do NOT override safety limits
            or preflight checks.

            Args:
                printer_name: The printer to get insights for.
                limit: Maximum recent outcomes to include (default 20).
            """
            if err := _check_auth("learning"):
                return err
            try:
                insights = get_db().get_printer_learning_insights(printer_name)
                recent = get_db().list_print_outcomes(printer_name=printer_name, limit=limit)

                # Confidence level based on sample size
                total = insights.get("total_outcomes", 0)
                if total < 5:
                    confidence = "low"
                elif total < 20:
                    confidence = "medium"
                else:
                    confidence = "high"

                return {
                    "success": True,
                    "printer_name": printer_name,
                    "insights": insights,
                    "recent_outcomes": recent,
                    "confidence": confidence,
                    "safety_notice": _LEARNING_SAFETY_NOTICE,
                }
            except Exception as exc:
                _logger.exception("Unexpected error in get_printer_insights")
                return _error_dict(f"Unexpected error: {exc}", code="INTERNAL_ERROR")

        @mcp.tool()
        def suggest_printer_for_job(
            file_hash: str | None = None,
            material_type: str | None = None,
            file_name: str | None = None,
        ) -> dict:
            """Suggest the best printer for a job based on historical outcomes.

            Rankings are based on success rates from previously recorded outcomes,
            optionally filtered by file hash or material type.  Cross-references
            the printer registry for current availability.

            **Note**: Suggestions are advisory.  They do NOT override safety limits
            or preflight checks.  Always run preflight validation before starting
            a print regardless of learning data.

            Args:
                file_hash: Optional hash of the file to match previous prints.
                material_type: Optional material type to filter by (e.g. ``"PLA"``).
                file_name: Optional file name (informational, not used for matching).
            """
            if err := _check_auth("learning"):
                return err
            try:
                ranked = get_db().suggest_printer_for_outcome(
                    file_hash=file_hash,
                    material_type=material_type,
                )

                # Cross-reference availability from registry
                try:
                    idle = set(_registry.get_idle_printers())
                except Exception as exc:
                    _logger.debug("Failed to get idle printers for ranking: %s", exc)
                    idle = set()

                suggestions = []
                for entry in ranked:
                    pname = entry["printer_name"]
                    rate = entry["success_rate"]
                    total = entry["total_prints"]
                    suggestions.append(
                        {
                            "printer_name": pname,
                            "success_rate": rate,
                            "total_prints": total,
                            "score": round(rate * (1 - 1 / (1 + total)), 2),
                            "reason": f"{int(rate * 100)}% success rate ({total} prints)",
                            "currently_available": pname in idle,
                        }
                    )

                # Sort by score descending
                suggestions.sort(key=lambda s: s["score"], reverse=True)

                total_outcomes = sum(e["total_prints"] for e in ranked)
                confidence = "low" if total_outcomes < 5 else ("medium" if total_outcomes < 20 else "high")

                return {
                    "success": True,
                    "suggestions": suggestions,
                    "query": {
                        "file_hash": file_hash,
                        "material_type": material_type,
                        "file_name": file_name,
                    },
                    "data_quality": {
                        "total_outcomes": total_outcomes,
                        "printers_with_data": len(ranked),
                        "confidence": confidence,
                    },
                    "safety_notice": _LEARNING_SAFETY_NOTICE,
                }
            except Exception as exc:
                _logger.exception("Unexpected error in suggest_printer_for_job")
                return _error_dict(f"Unexpected error: {exc}", code="INTERNAL_ERROR")

        @mcp.tool()
        def delete_agent_note(
            key: str,
            scope: str = "global",
            printer_name: str | None = None,
        ) -> dict:
            """Remove a stored note or preference.

            Args:
                key: The key of the note to delete.
                scope: The scope namespace (default ``"global"``).
                printer_name: If provided, targets ``"printer:<name>"`` scope.
            """
            if err := _check_auth("memory"):
                return err
            try:
                agent_id = os.environ.get("KILN_AGENT_ID", "default")
                effective_scope = f"printer:{printer_name}" if printer_name else scope
                deleted = get_db().delete_memory(agent_id, effective_scope, key)
                if not deleted:
                    return _error_dict(
                        f"No memory entry found for key '{key}' in scope '{effective_scope}'.",
                        code="NOT_FOUND",
                    )
                return {"success": True, "key": key, "scope": effective_scope}
            except Exception as exc:
                _logger.exception("Unexpected error in delete_agent_note")
                return _error_dict(f"Unexpected error: {exc}", code="INTERNAL_ERROR")

        _logger.debug("Registered learning and agent memory tools")


plugin = _LearningToolsPlugin()
