# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Licensed under the MIT License - see LICENSE file for details

"""
Macro Engine for Familiar.

Deterministic multi-step workflow executor. The LLM picks a macro and
fills in params; the engine executes steps without any LLM involvement.
This lets lightweight models (qwen2.5:0.5b) trigger reliable operations.

Usage:
    from familiar.core.macros import get_macro_registry

    registry = get_macro_registry()
    result = registry.execute("attach_grant_document", {
        "file_path": "/tmp/application.pdf",
        "grant_id": "g-001",
        "description": "Grant application for XYZ Foundation",
    })
"""

import json
import logging
import threading
import zipfile
from dataclasses import asdict, dataclass, field
from io import BytesIO
from pathlib import Path
from typing import Any, Optional

logger = logging.getLogger(__name__)


@dataclass
class MacroStep:
    """A single deterministic step in a macro."""

    action: str  # store_file, query_files, update_record, add_memory, zip_bundle
    params: dict = field(default_factory=dict)
    output_key: str = ""  # name to store result under for later steps


@dataclass
class MacroDefinition:
    """A registered macro with its steps."""

    name: str
    label: str
    description: str
    job_classes: list[str]  # which job classes see this macro
    input_params: dict = field(default_factory=dict)  # JSON Schema for LLM
    steps: list[MacroStep] = field(default_factory=list)

    def to_dict(self) -> dict:
        d = asdict(self)
        return d

    def to_tool_schema(self) -> dict:
        """Convert to OpenAI tool-calling schema so the LLM can invoke this macro like a tool."""
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": self.input_params if self.input_params else {"type": "object", "properties": {}},
            },
        }


class MacroResult:
    """Result of executing a macro."""

    def __init__(self):
        self.success = True
        self.outputs: dict[str, Any] = {}
        self.error: str = ""
        self.failed_step: int = -1
        self.steps_completed: int = 0

    def to_dict(self) -> dict:
        return {
            "success": self.success,
            "outputs": self.outputs,
            "error": self.error,
            "failed_step": self.failed_step,
            "steps_completed": self.steps_completed,
        }


class MacroRegistry:
    """Registry and executor for deterministic macros.

    Step types:
        store_file     — Store a file from disk to the FileStore.
        query_files    — List files for a record.
        update_record  — Update a field in a skill's JSON data.
        add_memory     — Write an episodic memory string.
        zip_bundle     — Bundle queried files into a ZIP.
        delete_file    — Remove a file from the FileStore.
    """

    def __init__(self):
        self._macros: dict[str, MacroDefinition] = {}
        self._lock = threading.Lock()

    def register(self, macro: MacroDefinition) -> None:
        """Register a macro definition."""
        with self._lock:
            self._macros[macro.name] = macro
        logger.debug("Registered macro: %s", macro.name)

    def get(self, name: str) -> Optional[MacroDefinition]:
        """Get a macro by name."""
        return self._macros.get(name)

    def list_all(self) -> list[MacroDefinition]:
        """List all registered macros."""
        return list(self._macros.values())

    def list_for_job_class(self, job_class: str) -> list[MacroDefinition]:
        """List macros available for a specific job class."""
        return [
            m for m in self._macros.values() if job_class in m.job_classes or "all" in m.job_classes
        ]

    def execute(self, name: str, params: dict, context: Optional[dict] = None) -> MacroResult:
        """Execute a macro by name with provided params.

        Args:
            name: Macro name.
            params: User-provided parameters (from LLM or UI).
            context: Optional extra context (agent ref, user_id, etc.).

        Returns:
            MacroResult with success/failure and step outputs.
        """
        macro = self._macros.get(name)
        if not macro:
            result = MacroResult()
            result.success = False
            result.error = f"Unknown macro: {name}"
            return result

        result = MacroResult()
        step_context = dict(params)
        step_context["_context"] = context or {}

        for i, step in enumerate(macro.steps):
            try:
                resolved = self._resolve_params(step.params, step_context)
                output = self._exec_step(step.action, resolved, step_context)
                result.steps_completed = i + 1

                if step.output_key:
                    step_context[step.output_key] = output
                    result.outputs[step.output_key] = output

            except Exception as e:
                logger.error(
                    "Macro %s failed at step %d (%s): %s",
                    name,
                    i,
                    step.action,
                    e,
                )
                result.success = False
                result.error = f"Step {i} ({step.action}) failed: {e}"
                result.failed_step = i
                return result

        return result

    def _resolve_params(self, params: dict, context: dict) -> dict:
        """Resolve {placeholder} references in step params."""
        resolved = {}
        for key, value in params.items():
            if isinstance(value, str) and "{" in value:
                try:
                    resolved[key] = value.format(**context)
                except (KeyError, IndexError):
                    resolved[key] = value
            else:
                resolved[key] = value
        return resolved

    def _exec_step(self, action: str, params: dict, context: dict) -> Any:
        """Execute a single macro step."""
        handler = self._step_handlers.get(action)
        if not handler:
            raise ValueError(f"Unknown step action: {action}")
        return handler(self, params, context)

    # ── Step Handlers ──

    def _exec_store_file(self, params: dict, context: dict) -> dict:
        """Store a file in the FileStore."""
        from familiar.core.files import get_file_store

        file_path = params.get("file_path", "")
        if not file_path:
            raise ValueError("file_path is required")

        path = Path(file_path)
        if not path.exists():
            raise FileNotFoundError(f"File not found: {file_path}")

        data = path.read_bytes()
        store = get_file_store()
        record = store.store(
            data=data,
            filename=params.get("filename", path.name),
            job_class=params.get("job_class", "general"),
            record_type=params.get("record_type", "general"),
            record_id=params.get("record_id", "unlinked"),
            description=params.get("description", ""),
        )
        return record.to_dict()

    def _exec_query_files(self, params: dict, context: dict) -> list:
        """Query files from the FileStore."""
        from familiar.core.files import get_file_store

        store = get_file_store()
        record_type = params.get("record_type", "")
        record_id = params.get("record_id", "")

        if record_type and record_id:
            files = store.list_for_record(
                record_type,
                record_id,
                job_class=params.get("job_class"),
            )
        elif params.get("job_class"):
            files = store.list_for_job_class(params["job_class"])
        else:
            files = store.search(
                query=params.get("query", ""),
                job_class=params.get("job_class"),
                limit=params.get("limit", 50),
            )
        return [f.to_dict() for f in files]

    def _exec_update_record(self, params: dict, context: dict) -> dict:
        """Update a field in a skill's JSON data file."""
        data_file = params.get("data_file", "")
        record_type = params.get("record_type", "")
        record_id = params.get("record_id", "")
        updates = params.get("updates", {})

        if not data_file or not record_id or not updates:
            raise ValueError("data_file, record_id, and updates are required")

        from familiar.core.paths import DATA_DIR

        path = DATA_DIR / data_file
        if not path.exists():
            raise FileNotFoundError(f"Data file not found: {data_file}")

        data = json.loads(path.read_text(encoding="utf-8"))
        records = data.get(record_type, [])

        updated = False
        for rec in records:
            if rec.get("id") == record_id:
                rec.update(updates)
                updated = True
                break

        if not updated:
            raise ValueError(f"Record {record_id} not found in {record_type}")

        path.write_text(json.dumps(data, indent=2), encoding="utf-8")
        return {"updated": True, "record_id": record_id}

    def _exec_add_memory(self, params: dict, context: dict) -> dict:
        """Write an episodic memory note."""
        content = params.get("content", "")
        if not content:
            raise ValueError("content is required")

        # Write to a simple memory log file
        from familiar.core.paths import DATA_DIR

        log_path = DATA_DIR / "macro_memories.jsonl"
        import datetime

        entry = {
            "content": content,
            "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
            "source": "macro",
            "macro": context.get("_context", {}).get("macro_name", ""),
        }
        with open(log_path, "a", encoding="utf-8") as f:
            f.write(json.dumps(entry) + "\n")
        return {"remembered": True, "content": content}

    def _exec_zip_bundle(self, params: dict, context: dict) -> dict:
        """Bundle files into a ZIP archive."""
        from familiar.core.files import get_file_store

        store = get_file_store()
        file_ids = params.get("file_ids", [])
        output_name = params.get("output_name", "bundle.zip")

        # If file_ids not provided, try to use files from a previous step
        if not file_ids and "files" in context:
            files_data = context["files"]
            if isinstance(files_data, list):
                file_ids = [f["file_id"] for f in files_data if "file_id" in f]

        if not file_ids:
            raise ValueError("No files to bundle")

        buf = BytesIO()
        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
            for fid in file_ids:
                path = store.get_path(fid)
                record = store.get(fid)
                if path and record:
                    zf.write(path, record.filename)

        from familiar.core.paths import DATA_DIR

        out_path = DATA_DIR / "downloads" / output_name
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_bytes(buf.getvalue())

        return {
            "zip_path": str(out_path),
            "zip_size": len(buf.getvalue()),
            "file_count": len(file_ids),
        }

    def _exec_delete_file(self, params: dict, context: dict) -> dict:
        """Delete a file from the FileStore."""
        from familiar.core.files import get_file_store

        file_id = params.get("file_id", "")
        if not file_id:
            raise ValueError("file_id is required")

        store = get_file_store()
        deleted = store.delete(file_id)
        return {"deleted": deleted, "file_id": file_id}

    # Step handler dispatch
    _step_handlers = {
        "store_file": _exec_store_file,
        "query_files": _exec_query_files,
        "update_record": _exec_update_record,
        "add_memory": _exec_add_memory,
        "zip_bundle": _exec_zip_bundle,
        "delete_file": _exec_delete_file,
    }


def _register_builtins(registry: MacroRegistry) -> None:
    """Register built-in macros for all job classes."""

    # ── Nonprofit Director ──

    registry.register(
        MacroDefinition(
            name="attach_grant_document",
            label="Attach Document to Grant",
            description=(
                "Upload a document (application, budget, award letter) "
                "and attach it to a grant in the pipeline."
            ),
            job_classes=["nonprofit_director"],
            input_params={
                "type": "object",
                "properties": {
                    "file_path": {"type": "string", "description": "Path to file"},
                    "grant_id": {"type": "string", "description": "Grant ID"},
                    "description": {"type": "string", "description": "File description"},
                },
                "required": ["file_path", "grant_id"],
            },
            steps=[
                MacroStep(
                    action="store_file",
                    params={
                        "file_path": "{file_path}",
                        "job_class": "nonprofit",
                        "record_type": "grants",
                        "record_id": "{grant_id}",
                        "description": "{description}",
                    },
                    output_key="stored_file",
                ),
                MacroStep(
                    action="add_memory",
                    params={
                        "content": "Attached document to grant {grant_id}: {description}",
                    },
                ),
            ],
        )
    )

    registry.register(
        MacroDefinition(
            name="attach_donor_receipt",
            label="Attach Donor Receipt",
            description="Attach a receipt or acknowledgment letter to a donor record.",
            job_classes=["nonprofit_director"],
            input_params={
                "type": "object",
                "properties": {
                    "file_path": {"type": "string"},
                    "donor_id": {"type": "string"},
                    "description": {"type": "string"},
                },
                "required": ["file_path", "donor_id"],
            },
            steps=[
                MacroStep(
                    action="store_file",
                    params={
                        "file_path": "{file_path}",
                        "job_class": "nonprofit",
                        "record_type": "donors",
                        "record_id": "{donor_id}",
                        "description": "{description}",
                    },
                    output_key="stored_file",
                ),
            ],
        )
    )

    # ── Social Worker ──

    registry.register(
        MacroDefinition(
            name="attach_case_document",
            label="Attach Document to Case",
            description=(
                "Upload an intake form, safety plan, referral letter, or other "
                "document and attach it to a case."
            ),
            job_classes=["social_worker"],
            input_params={
                "type": "object",
                "properties": {
                    "file_path": {"type": "string"},
                    "case_id": {"type": "string"},
                    "description": {"type": "string"},
                },
                "required": ["file_path", "case_id"],
            },
            steps=[
                MacroStep(
                    action="store_file",
                    params={
                        "file_path": "{file_path}",
                        "job_class": "social_worker",
                        "record_type": "cases",
                        "record_id": "{case_id}",
                        "description": "{description}",
                    },
                    output_key="stored_file",
                ),
                MacroStep(
                    action="add_memory",
                    params={
                        "content": "Filed document for case {case_id}: {description}",
                    },
                ),
            ],
        )
    )

    registry.register(
        MacroDefinition(
            name="export_case_bundle",
            label="Export Case Documents",
            description="Bundle all documents for a case into a downloadable ZIP.",
            job_classes=["social_worker"],
            input_params={
                "type": "object",
                "properties": {
                    "case_id": {"type": "string", "description": "Case ID"},
                },
                "required": ["case_id"],
            },
            steps=[
                MacroStep(
                    action="query_files",
                    params={
                        "record_type": "cases",
                        "record_id": "{case_id}",
                        "job_class": "social_worker",
                    },
                    output_key="files",
                ),
                MacroStep(
                    action="zip_bundle",
                    params={
                        "output_name": "case_{case_id}_export.zip",
                    },
                    output_key="bundle",
                ),
            ],
        )
    )

    # ── Business Buddy ──

    registry.register(
        MacroDefinition(
            name="attach_invoice",
            label="Attach Invoice or Contract",
            description="Attach an invoice, contract, or proposal to a client record.",
            job_classes=["business_buddy"],
            input_params={
                "type": "object",
                "properties": {
                    "file_path": {"type": "string"},
                    "client_id": {"type": "string"},
                    "description": {"type": "string"},
                },
                "required": ["file_path", "client_id"],
            },
            steps=[
                MacroStep(
                    action="store_file",
                    params={
                        "file_path": "{file_path}",
                        "job_class": "business",
                        "record_type": "clients",
                        "record_id": "{client_id}",
                        "description": "{description}",
                    },
                    output_key="stored_file",
                ),
            ],
        )
    )

    # ── Chef ──

    registry.register(
        MacroDefinition(
            name="attach_recipe_photo",
            label="Attach Photo to Recipe",
            description="Attach a photo or document to a recipe.",
            job_classes=["chef"],
            input_params={
                "type": "object",
                "properties": {
                    "file_path": {"type": "string"},
                    "recipe_id": {"type": "string"},
                    "description": {"type": "string"},
                },
                "required": ["file_path", "recipe_id"],
            },
            steps=[
                MacroStep(
                    action="store_file",
                    params={
                        "file_path": "{file_path}",
                        "job_class": "kitchen",
                        "record_type": "recipes",
                        "record_id": "{recipe_id}",
                        "description": "{description}",
                    },
                    output_key="stored_file",
                ),
            ],
        )
    )

    # ── Artist ──

    registry.register(
        MacroDefinition(
            name="attach_commission_reference",
            label="Attach Commission Reference",
            description="Attach a reference image or contract to a commission.",
            job_classes=["artist"],
            input_params={
                "type": "object",
                "properties": {
                    "file_path": {"type": "string"},
                    "commission_id": {"type": "string"},
                    "description": {"type": "string"},
                },
                "required": ["file_path", "commission_id"],
            },
            steps=[
                MacroStep(
                    action="store_file",
                    params={
                        "file_path": "{file_path}",
                        "job_class": "studio",
                        "record_type": "commissions",
                        "record_id": "{commission_id}",
                        "description": "{description}",
                    },
                    output_key="stored_file",
                ),
            ],
        )
    )

    # ── Universal (all job classes) ──

    registry.register(
        MacroDefinition(
            name="export_record_files",
            label="Export Record Files",
            description="Bundle all files attached to any record into a ZIP.",
            job_classes=["all"],
            input_params={
                "type": "object",
                "properties": {
                    "record_type": {"type": "string"},
                    "record_id": {"type": "string"},
                    "job_class": {"type": "string"},
                },
                "required": ["record_type", "record_id"],
            },
            steps=[
                MacroStep(
                    action="query_files",
                    params={
                        "record_type": "{record_type}",
                        "record_id": "{record_id}",
                        "job_class": "{job_class}",
                    },
                    output_key="files",
                ),
                MacroStep(
                    action="zip_bundle",
                    params={
                        "output_name": "{record_type}_{record_id}_files.zip",
                    },
                    output_key="bundle",
                ),
            ],
        )
    )

    registry.register(
        MacroDefinition(
            name="attach_general_file",
            label="Attach File",
            description="Attach any file to a record.",
            job_classes=["all"],
            input_params={
                "type": "object",
                "properties": {
                    "file_path": {"type": "string", "description": "Path to file"},
                    "record_type": {"type": "string"},
                    "record_id": {"type": "string"},
                    "job_class": {"type": "string"},
                    "description": {"type": "string"},
                },
                "required": ["file_path", "record_type", "record_id"],
            },
            steps=[
                MacroStep(
                    action="store_file",
                    params={
                        "file_path": "{file_path}",
                        "job_class": "{job_class}",
                        "record_type": "{record_type}",
                        "record_id": "{record_id}",
                        "description": "{description}",
                    },
                    output_key="stored_file",
                ),
                MacroStep(
                    action="add_memory",
                    params={
                        "content": "Attached file to {record_type}/{record_id}: {description}",
                    },
                ),
            ],
        )
    )


# ── Singleton ──

_macro_registry: Optional[MacroRegistry] = None
_macro_lock = threading.Lock()


def get_macro_registry() -> MacroRegistry:
    """Get or create the singleton MacroRegistry with builtins."""
    global _macro_registry
    if _macro_registry is None:
        with _macro_lock:
            if _macro_registry is None:
                _macro_registry = MacroRegistry()
                _register_builtins(_macro_registry)
    return _macro_registry
