"""Provenance capture helpers (docs/provenance/00, Phase 1).

Two concerns, kept free of QGIS imports so they unit-test cleanly:

* ``ensure_model_snapshot`` — embed an immutable snapshot of a model (bytes +
  sha + deterministic summary) the first time its content is seen, returning the
  snapshot id (deduped by content hash).
* ``record_task_run`` — write the per-run provenance manifest
  (``workflow_runs`` + ``workflow_step_runs`` + ``result_lineage``) for one task
  execution, idempotent per execution id.

Both take a ``WorkbenchMetadataManager``-shaped object; nothing here depends on
the engine bridge directly (a ``summary_provider`` callable is injected).
"""

from __future__ import annotations

import hashlib
import os
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional


# Engine-type value (models.EngineType.value) -> contract model_kind.
_ENGINE_MODEL_KIND = {
    "NetWeaver": "netweaver",
    "CDP": "cdp",
    "LPA": "lpa",
    "Portfolio": "portfolio",
}


def model_kind_for_engine(engine_type_value: str) -> str:
    """Map an EngineType value to the contract's model_kind string."""
    return _ENGINE_MODEL_KIND.get(str(engine_type_value), str(engine_type_value or "").lower())


def _sha256_file_bytes(data: bytes) -> str:
    return hashlib.sha256(data or b"").hexdigest()


def ensure_model_snapshot(
    manager,
    *,
    model_kind: str,
    model_path: str,
    summary_provider: Optional[Callable[[str], Dict[str, Any]]] = None,
    engine_version: str = "",
    created_by: str = "",
) -> str:
    """Ensure an immutable snapshot exists for ``model_path``; return its id.

    Deduped by content sha256: re-running the same model content reuses the row.
    ``summary_provider(model_path)`` is called only for genuinely new content and
    may return ``{"model_summary", "engine_version", "model_schema_version"}``;
    failures are swallowed so a missing/old engine never blocks a run.
    Returns "" when there is no usable model file.
    """
    if not model_path or not os.path.isfile(model_path):
        return ""

    try:
        with open(model_path, "rb") as handle:
            content = handle.read()
    except OSError:
        return ""

    content_sha = _sha256_file_bytes(content)
    existing = manager.get_model_snapshot_by_sha(content_sha)
    if existing is not None:
        return existing.id

    summary: Dict[str, Any] = {}
    resolved_engine_version = engine_version
    schema_version = ""
    if summary_provider is not None:
        try:
            provided = summary_provider(model_path) or {}
            summary = provided.get("model_summary") or {}
            resolved_engine_version = provided.get("engine_version") or engine_version
            schema_version = provided.get("model_schema_version") or ""
        except Exception:
            # Best-effort: snapshot still captured with bytes + hash.
            pass

    try:
        file_mtime = datetime.fromtimestamp(os.path.getmtime(model_path)).isoformat()
    except OSError:
        file_mtime = ""

    snapshot_id = manager.save_model_snapshot(
        model_kind=model_kind,
        content_blob=content,
        display_name=os.path.basename(model_path),
        source_path=model_path,
        source_format=os.path.splitext(model_path)[1],
        schema_version=schema_version,
        engine_version=resolved_engine_version,
        model_summary=summary,
        file_mtime=file_mtime,
        created_by=created_by,
    )
    return snapshot_id or ""


def record_task_run(
    manager,
    *,
    workspace_id: str,
    study_id: str,
    analysis_id: str,
    task_id: str,
    task_name: str = "",
    engine_type: str = "",
    execution_id: str = "",
    engine_version: str = "",
    model_snapshot_id: str = "",
    parameters: Optional[Dict[str, Any]] = None,
    field_mappings: Optional[List[Any]] = None,
    input_refs: Optional[List[Any]] = None,
    output_summary: Optional[Dict[str, Any]] = None,
    status: str = "completed",
    start_time: str = "",
    end_time: str = "",
    duration_ms: int = 0,
    rows_processed: int = 0,
    error_message: str = "",
    run_kind: str = "baseline",
    scenario_id: str = "",
    parent_run_id: str = "",
    run_label: str = "",
    run_notes: str = "",
    derived_from_result_id: str = "",
    workflow_step_id: str = "",
    emds_version: str = "",
    gis_environment: str = "QGIS",
    gis_version: str = "",
    created_by: str = "",
) -> Dict[str, str]:
    """Write the run/step/lineage manifest for one task execution.

    Idempotent per ``execution_id``: if a step-run already references this
    execution, nothing new is written and the existing ids are returned.
    Returns ``{"run_id", "step_run_id"}`` ("" on failure).
    """
    if execution_id:
        existing_step = manager.get_step_run_for_result(execution_id)
        if existing_step is not None:
            return {"run_id": existing_step.run_id, "step_run_id": existing_step.id}

    run_id = manager.save_workflow_run(
        workspace_id=workspace_id,
        study_id=study_id,
        analysis_id=analysis_id,
        run_kind=run_kind,
        scenario_id=scenario_id,
        parent_run_id=parent_run_id,
        label=run_label,
        notes=run_notes,
        status=status,
        start_time=start_time,
        end_time=end_time,
        duration_ms=duration_ms,
        emds_version=emds_version,
        gis_environment=gis_environment,
        gis_version=gis_version,
        created_by=created_by,
    ) or ""

    step_run_id = manager.save_workflow_step_run(
        run_id=run_id,
        workflow_step_id=workflow_step_id or task_id,
        task_id=task_id,
        name=task_name,
        engine_type=engine_type,
        engine_version=engine_version,
        model_snapshot_id=model_snapshot_id,
        parameters=parameters or {},
        field_mappings=field_mappings or [],
        input_refs=input_refs or [],
        output_summary=output_summary or {},
        result_id=execution_id,
        status=status,
        start_time=start_time,
        end_time=end_time,
        duration_ms=duration_ms,
        rows_processed=rows_processed,
        error_message=error_message,
    ) or ""

    manager.save_result_lineage(
        result_id=execution_id,
        step_run_id=step_run_id,
        run_id=run_id,
        model_snapshot_id=model_snapshot_id,
        derived_from_result_id=derived_from_result_id,
    )

    return {"run_id": run_id, "step_run_id": step_run_id}


def current_user() -> str:
    """Best-effort current OS user for provenance attribution."""
    return os.environ.get("USERNAME") or os.environ.get("USER") or ""


def _norm_json(value: Any) -> str:
    import json
    try:
        return json.dumps(value, sort_keys=True, default=str)
    except Exception:
        return str(value)


def task_stale_reasons(manager, task_id: str) -> List[str]:
    """Reasons a task's results are out of date vs its latest recorded run.

    Compares the current model file hash and the current task config against
    what was actually run last (the latest ``workflow_step_run`` + its model
    snapshot). Returns [] when the task has never run or is up to date.
    Manager-driven (no in-memory Task needed) so the tree can badge cheaply.
    """
    try:
        task_meta = manager.get_task(task_id)
    except Exception:
        task_meta = None
    if task_meta is None:
        return []
    try:
        runs = manager.get_step_runs_for_task(task_id)
    except Exception:
        runs = []
    if not runs:
        return []
    latest = runs[0]
    metadata = getattr(task_meta, "metadata", {}) or {}
    reasons: List[str] = []

    snapshot = None
    if latest.model_snapshot_id:
        try:
            snapshot = manager.get_model_snapshot(latest.model_snapshot_id)
        except Exception:
            snapshot = None
    model_path = getattr(task_meta, "model_path", "") or metadata.get("model_file") or ""
    if snapshot and model_path and os.path.isfile(model_path):
        try:
            with open(model_path, "rb") as handle:
                current_sha = hashlib.sha256(handle.read()).hexdigest()
            if current_sha and current_sha != snapshot.content_sha256:
                reasons.append("Model file changed since last run")
        except OSError:
            pass

    if _norm_json(metadata.get("field_mappings", []) or []) != _norm_json(latest.field_mappings):
        reasons.append("Field mappings changed since last run")

    for key, value in (latest.parameters or {}).items():
        if key == "field_mappings":
            continue
        if _norm_json(metadata.get(key)) != _norm_json(value):
            reasons.append("Parameters changed since last run")
            break

    return reasons
