"""
memory.py
---------
HTTP routes exposing the primary memory operations for HIEROMEM.

Endpoints:
  • POST /remember  → insert new episodic experience
  • POST /recall    → resonance-based retrieval
  • POST /forget    → pruning and decay
  • GET  /metrics   → theoretical + empirical metrics

Author: Sai Rishi Kiran Mannava
"""

from __future__ import annotations

import logging
import time
from typing import Any

import networkx as nx
import numpy as np
from fastapi import APIRouter, Depends, HTTPException

from hieromem_core.memory import Memory
from hieromem_core.metrics import event_type_counter
from hieromem_core.utils.metrics import MemoryMetrics
from hieromem_api.deps import get_memory_engine
from hieromem_api.auth import require_api_key, require_permission
from hieromem_api.rbac import Permission
from hieromem_api.models.requests import (
    BatchDualRecallRequest,
    BatchSymbolicSearchRequest,
    DualRecallRequest,
    ForgetRequest,
    RecallRequest,
    RememberRequest,
    SymbolicSearchRequest,
)
from hieromem_api.models.responses import (
    BatchDualRecallItem,
    BatchDualRecallResponse,
    BatchSymbolicSearchItem,
    BatchSymbolicSearchResponse,
    DualRecallEpisode,
    DualRecallResponse,
    ForgetResponse,
    MetricResponse,
    RecallResponse,
    RememberResponse,
    SymbolicMatch,
    SymbolicSearchResponse,
    EpisodeDetail,
    TreeItem,
)
from hieromem_api.instrumentation.prometheus import (
    record_dual_recall_metrics,
    update_episode_metrics,
    update_stability_metrics,
    update_symbolic_inconsistencies,
)
from hieromem_api.services.parameter_advisor import AdaptiveParameterAdvisor
from hieromem_api.services.recall_services import RecallService
from hieromem_core.services.cache_service import CacheService
from hieromem_api.settings import get_settings
from hieromem_api.tenants import Tenant, canonical_namespace, public_namespace, resolve_tenant
from hieromem_api.quotas import enforce_write_quotas

# --------------------------------------------------------------------------- #
# Router setup
# --------------------------------------------------------------------------- #

router = APIRouter(prefix="/memory", tags=["Memory"])

_SERVICE_START_TS = time.time()

memory: Memory = get_memory_engine()
cache_service = CacheService()
recall_service = RecallService(
    memory,
    cache_service=cache_service,
    cache_ttl_seconds=get_settings().recall_cache_ttl_seconds,
)
parameter_advisor = AdaptiveParameterAdvisor(memory)


def _require_encoder(mem: Memory) -> None:
    if not hasattr(mem, "encoder"):
        raise HTTPException(status_code=500, detail="Encoder not configured")


def _build_query_graph(edges: list[Any] | None) -> nx.DiGraph | None:
    if not edges:
        return None
    graph = nx.DiGraph()
    for edge in edges:
        attrs: dict[str, Any] = {}
        relation = getattr(edge, "relation", None)
        if relation is not None:
            attrs["relation"] = relation
        graph.add_edge(edge.source, edge.target, **attrs)
    return graph


def _execute_dual_recall(
    *,
    query: str | None,
    embedding: list[float] | None,
    namespace: str | None,
    namespace_label: str | None,
    k: int,
    alpha_dense: float,
    beta_symbolic: float,
    temperature: float,
    max_iterations: int,
    tolerance: float,
    query_graph_edges: list[Any] | None,
    lambda_decay: float | None = None,
    insights: list[str] | None = None,
) -> DualRecallResponse:
    embedding_provided = embedding is not None
    if embedding_provided:
        query_vector = np.asarray(embedding, dtype=float)
    else:
        _require_encoder(memory)
        query_vector = memory.encoder.encode_single(query or "")

    query_graph = _build_query_graph(query_graph_edges)

    result = memory.dual_retrieve(
        query_vector,
        query_graph=query_graph,
        k=k,
        alpha_dense=alpha_dense,
        beta_symbolic=beta_symbolic,
        temperature=temperature,
        max_iterations=max_iterations,
        tolerance=tolerance,
        namespace=namespace,
    )

    dense_scores = {
        episode_id: float(score) for episode_id,
        score in result.dense_scores.items()}
    symbolic_scores = {
        episode_id: float(score) for episode_id,
        score in result.symbolic_scores.items()}
    combined_weights = {
        episode_id: float(score) for episode_id,
        score in result.combined_scores.items()}

    response_items: list[DualRecallEpisode] = []
    weight_list = result.weights.tolist()
    for episode, combined_score in zip(result.episodes, weight_list):
        episode_id = episode.id
        response_items.append(
            DualRecallEpisode(
                id=episode_id,
                trust=float(episode.trust),
                timestamp=episode.tocc.isoformat(),
                text=episode.meta.get("text", "") if episode.meta else "",
                meta=episode.meta,
                dense_score=dense_scores.get(episode_id, 0.0),
                symbolic_score=symbolic_scores.get(episode_id, 0.0),
                combined_score=float(combined_score),
            )
        )

    record_dual_recall_metrics(
        namespace=namespace or getattr(memory, "namespace", None),
        iterations=result.iterations,
        dense_scores=dense_scores,
        symbolic_scores=symbolic_scores,
        combined_scores=combined_weights,
    )

    return DualRecallResponse(
        query=query,
        namespace=namespace_label,
        k=k,
        alpha_dense=alpha_dense,
        beta_symbolic=beta_symbolic,
        lambda_decay=lambda_decay if lambda_decay is not None else memory.lam,
        temperature=temperature,
        iterations=result.iterations,
        embedding_provided=embedding_provided,
        dense_scores=dense_scores,
        symbolic_scores=symbolic_scores,
        combined_weights=combined_weights,
        episodic=response_items,
        symbolic=[],
        insights=insights or [],
    )


def _execute_symbolic_search(
    *,
    namespace: str | None,
    namespace_label: str | None,
    path: list[str] | None,
    pattern_edges: list[tuple[str, str]] | None,
    limit: int,
) -> SymbolicSearchResponse:
    pattern_graph = None
    if pattern_edges:
        pattern_graph = nx.DiGraph()
        for u, v in pattern_edges:
            pattern_graph.add_edge(u, v)

    matches = memory.symbolic_search(
        path=path,
        pattern=pattern_graph,
        namespace=namespace,
        limit=limit,
    )

    results = [
        SymbolicMatch(
            episode_id=episode.id,
            trust=float(episode.trust),
            timestamp=episode.tocc.isoformat(),
            details=details,
        )
        for episode, details in matches
    ]

    return SymbolicSearchResponse(
        namespace=namespace_label,
        path=path,
        pattern_edges=pattern_edges,
        results=results,
    )


@router.post("/remember", response_model=RememberResponse)
def remember(req: RememberRequest, tenant: Tenant = Depends(
        require_api_key), _perm_check: None = Depends(require_permission(Permission.WRITE_MEMORY))) -> RememberResponse:
    """Insert a new episodic experience with optional multi-modal payloads."""

    tenant = resolve_tenant(tenant)
    payload: dict[str, Any] = {}
    if req.text is not None:
        payload["text"] = req.text
    if req.images:
        payload["image"] = req.images
    if req.audio:
        payload["audio"] = req.audio
    if req.embedding is not None:
        payload["embedding"] = req.embedding

    if req.embedding is None:
        _require_encoder(memory)

    namespace = canonical_namespace(tenant, req.namespace)
    namespace_exists = True
    if getattr(memory, "_multi_tenant", False):
        namespace_exists = namespace in getattr(
            memory, "_episodes_by_namespace", {})

    enforce_write_quotas(memory, tenant, namespace_exists=namespace_exists)

    try:
        episode = memory.multi_modal_insert(
            payload,
            meta=req.meta or {},
            namespace=namespace,
        )
        recall_service.invalidate_cache(namespace)
        return RememberResponse(
            episode_id=episode.id,
            trust=float(episode.trust),
            message=f"Episode {episode.id} stored successfully",
        )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    except Exception as exc:  # pragma: no cover - FastAPI will surface HTTPException
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.post("/recall", response_model=RecallResponse)
def recall(req: RecallRequest, tenant: Tenant = Depends(
        require_api_key), _perm_check: None = Depends(require_permission(Permission.READ_MEMORY))) -> RecallResponse:
    """Retrieve top-k relevant episodes for a given query."""

    tenant = resolve_tenant(tenant)
    _require_encoder(memory)

    try:
        namespace = canonical_namespace(tenant, req.namespace)
        return recall_service.recall(req.query, req.k, namespace=namespace)
    except Exception as exc:  # pragma: no cover - FastAPI will surface HTTPException
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.post("/dual-recall", response_model=DualRecallResponse)
def dual_recall(
    req: DualRecallRequest, tenant: Tenant = Depends(require_api_key),
    _perm_check: None = Depends(require_permission(Permission.READ_MEMORY))
) -> DualRecallResponse:
    """Execute dual dense + symbolic retrieval with explicit weighting controls."""

    tenant = resolve_tenant(tenant)
    try:
        decision = parameter_advisor.recommend(
            query=req.query,
            alpha_override=req.alpha_dense,
            beta_override=req.beta_symbolic,
            lambda_override=req.lambda_decay,
        )
        parameter_advisor.apply_lambda(decision.lambda_decay)
        namespace = canonical_namespace(tenant, req.namespace)
        return _execute_dual_recall(
            query=req.query,
            embedding=req.embedding,
            namespace=namespace,
            namespace_label=req.namespace,
            k=req.k,
            alpha_dense=decision.alpha_dense,
            beta_symbolic=decision.beta_symbolic,
            temperature=req.temperature,
            max_iterations=req.max_iterations,
            tolerance=req.tolerance,
            query_graph_edges=req.query_graph_edges,
            lambda_decay=decision.lambda_decay,
            insights=decision.insights,
        )
    except Exception as exc:  # pragma: no cover - FastAPI will surface HTTPException
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.post("/batch/dual-recall", response_model=BatchDualRecallResponse)
def batch_dual_recall(
    req: BatchDualRecallRequest, tenant: Tenant = Depends(require_api_key),
    _perm_check: None = Depends(require_permission(Permission.READ_MEMORY))
) -> BatchDualRecallResponse:
    """Execute multiple dual recall queries in a single request."""

    tenant = resolve_tenant(tenant)
    try:
        results: list[BatchDualRecallItem] = []
        lambda_baseline = float(memory.lam)
        for idx, query in enumerate(req.queries):
            public_namespace_value = query.namespace or req.namespace
            namespace = canonical_namespace(tenant, public_namespace_value)
            k = query.k if query.k is not None else req.k
            alpha_override = (
                query.alpha_dense if query.alpha_dense is not None else req.alpha_dense)
            beta_override = (
                query.beta_symbolic if query.beta_symbolic is not None else req.beta_symbolic)
            lambda_override = (
                query.lambda_decay if query.lambda_decay is not None else req.lambda_decay)
            temperature = query.temperature if query.temperature is not None else req.temperature
            max_iterations = (
                query.max_iterations if query.max_iterations is not None else req.max_iterations)
            tolerance = query.tolerance if query.tolerance is not None else req.tolerance

            decision = parameter_advisor.recommend(
                query=query.query,
                alpha_override=alpha_override,
                beta_override=beta_override,
                lambda_override=lambda_override,
                lam_baseline=lambda_baseline,
            )
            parameter_advisor.apply_lambda(decision.lambda_decay)
            response = _execute_dual_recall(
                query=query.query,
                embedding=query.embedding,
                namespace=namespace,
                namespace_label=public_namespace_value,
                k=k,
                alpha_dense=decision.alpha_dense,
                beta_symbolic=decision.beta_symbolic,
                temperature=temperature,
                max_iterations=max_iterations,
                tolerance=tolerance,
                query_graph_edges=query.query_graph_edges,
                lambda_decay=decision.lambda_decay,
                insights=decision.insights,
            )

            results.append(
                BatchDualRecallItem(
                    index=idx,
                    query_id=query.query_id,
                    response=response))

        return BatchDualRecallResponse(
            total_queries=len(results), items=results)
    except Exception as exc:  # pragma: no cover - FastAPI will surface HTTPException
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.post("/symbolic-search", response_model=SymbolicSearchResponse)
def symbolic_search(
    req: SymbolicSearchRequest, tenant: Tenant = Depends(require_api_key),
    _perm_check: None = Depends(require_permission(Permission.READ_MEMORY))
) -> SymbolicSearchResponse:
    """Execute symbolic graph queries against stored scene graphs."""

    tenant = resolve_tenant(tenant)
    try:
        namespace = canonical_namespace(tenant, req.namespace)
        return _execute_symbolic_search(
            namespace=namespace,
            namespace_label=req.namespace,
            path=req.path,
            pattern_edges=req.pattern_edges,
            limit=req.limit,
        )
    except Exception as exc:  # pragma: no cover - FastAPI will surface HTTPException
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.post("/graph-search")
def graph_search(
    episode_id: str,
    depth: int = 2,
    limit: int = 50,
    namespace: str | None = None,
    tenant: Tenant = Depends(require_api_key),
    _perm_check: None = Depends(require_permission(Permission.READ_MEMORY))
) -> dict:
    """Execute multi-hop graph traversal from a given episode.

    Uses the configured GraphStore to find related entities/episodes
    through relationship chains.

    Args:
        episode_id: The episode ID to start traversal from
        depth: Number of hops to traverse (default: 2)
        limit: Maximum results to return (default: 50)
        namespace: Optional namespace for multi-tenant scenarios

    Returns:
        Dictionary with 'results' containing list of related entities/paths
    """
    tenant = resolve_tenant(tenant)
    try:
        effective_namespace = canonical_namespace(tenant, namespace)

        # Check if graph store is available
        if not hasattr(memory, 'graph_store') or memory.graph_store is None:
            raise HTTPException(
                status_code=501,
                detail="Graph search unavailable: no GraphStore configured"
            )

        results = memory.graph_search(
            episode_id,
            depth=depth,
            limit=limit,
            namespace=effective_namespace
        )

        return {
            "episode_id": episode_id,
            "depth": depth,
            "limit": limit,
            "namespace": namespace,
            "results": results,
            "count": len(results)
        }
    except RuntimeError as exc:
        raise HTTPException(status_code=501, detail=str(exc)) from exc
    except Exception as exc:  # pragma: no cover
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.post("/batch/symbolic-search",
             response_model=BatchSymbolicSearchResponse)
def batch_symbolic_search(
    req: BatchSymbolicSearchRequest, tenant: Tenant = Depends(require_api_key),
    _perm_check: None = Depends(require_permission(Permission.READ_MEMORY))
) -> BatchSymbolicSearchResponse:
    """Execute multiple symbolic graph searches in a single request."""

    tenant = resolve_tenant(tenant)
    try:
        items: list[BatchSymbolicSearchItem] = []
        for idx, query in enumerate(req.queries):
            public_ns = query.namespace or req.namespace
            namespace = canonical_namespace(tenant, public_ns)
            limit = query.limit if query.limit is not None else req.limit
            response = _execute_symbolic_search(
                namespace=namespace,
                namespace_label=public_ns,
                path=query.path,
                pattern_edges=query.pattern_edges,
                limit=limit,
            )
            items.append(
                BatchSymbolicSearchItem(
                    index=idx,
                    query_id=query.query_id,
                    response=response))

        return BatchSymbolicSearchResponse(
            total_queries=len(items), items=items)
    except Exception as exc:  # pragma: no cover - FastAPI will surface HTTPException
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.post("/forget", response_model=ForgetResponse)
def forget(req: ForgetRequest, tenant: Tenant = Depends(
        require_api_key), _perm_check: None = Depends(require_permission(Permission.DELETE_MEMORY))) -> ForgetResponse:
    """Forget specific episodes or prune based on trust threshold."""

    tenant = resolve_tenant(tenant)
    try:
        namespace = canonical_namespace(tenant, req.namespace)

        if req.episode_ids:
            removed_ids = memory.remove_episode_ids(
                req.episode_ids, namespace=namespace)
            if removed_ids:
                recall_service.invalidate_cache(namespace)
            return ForgetResponse(
                status="ok",
                message=f"Removed {len(removed_ids)} episodes.",
                removed_ids=removed_ids or None,
            )

        with memory.use_namespace(namespace):
            default_threshold = float(memory.theta)

        threshold = req.threshold if req.threshold is not None else default_threshold
        removed_ids = memory.prune_below_trust(threshold, namespace=namespace)
        if removed_ids:
            recall_service.invalidate_cache(namespace)

        return ForgetResponse(
            status="ok",
            message=f"Pruned {len(removed_ids)} episodes (threshold={threshold}).",
            removed_ids=removed_ids or None,
        )
    except Exception as exc:  # pragma: no cover - FastAPI will surface HTTPException
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.get("/metrics", response_model=MetricResponse)
def metrics(
    namespace: str | None = None, tenant: Tenant = Depends(require_api_key),
    _perm_check: None = Depends(require_permission(Permission.READ_MEMORY))
) -> MetricResponse:
    """Return theoretical and empirical metrics scoped to an optional namespace."""

    tenant = resolve_tenant(tenant)
    try:
        effective_namespace = canonical_namespace(tenant, namespace)
        with memory.use_namespace(effective_namespace):
            snapshot = MemoryMetrics(memory).compute()
            stability_snapshot = memory.stability_monitor.stability()
            gsr = getattr(memory, "gsr", None)
            gsr_valid = False
            if gsr is not None and hasattr(gsr, "is_valid"):
                gsr_valid = bool(gsr.is_valid())

            ns = memory.namespace

            update_episode_metrics(
                snapshot.total_episodes,
                snapshot.trust_mean,
                namespace=ns,
                capacity=memory.Nmax,
            )
            update_stability_metrics(
                namespace=ns,
                distortion_bound=stability_snapshot.summary_distortion_bound,
                contraction_factor=stability_snapshot.contraction_factor,
                lyapunov_margin=stability_snapshot.lyapunov_margin,
                lyapunov_warning=stability_snapshot.lyapunov_warning,
            )

            activity: dict[str, float] = {}
            counters = getattr(memory, "metrics", {})
            if isinstance(counters, dict):
                for key in ("remember", "recall", "forget"):
                    counter = counters.get(key)
                    if counter is not None and hasattr(counter, "count"):
                        try:
                            value = float(counter.count())
                        except Exception as exc:  # pragma: no cover - defensive
                            logging.getLogger(__name__).warning(
                                "Failed to read counter '%s': %s", key, exc
                            )
                            value = 0.0
                        if value > 0:
                            activity[f"{key}_last_5m"] = value

            if not activity:
                event_log = getattr(memory, "event_log", None)
                if event_log is not None and hasattr(event_log, "all"):
                    counts = event_type_counter(event_log.all())
                    mapping = {
                        "insert": "remember_last_5m",
                        "retrieve": "recall_last_5m",
                        "forget": "forget_last_5m",
                    }
                    for key, alias in mapping.items():
                        if key in counts:
                            activity[alias] = float(counts[key])

            persistence_info: dict[str, Any] | None = None
            store = getattr(memory, "store", None)
            if store is not None:
                persistence_info = {"backend": store.__class__.__name__}
                if hasattr(store, "count"):
                    try:
                        persistence_info["episodes"] = int(store.count())
                    except Exception as exc:  # pragma: no cover - optional feature
                        logging.getLogger(__name__).warning(
                            "Failed to read persistence count: %s", exc
                        )
                if hasattr(store, "healthcheck"):
                    try:
                        persistence_info["healthy"] = bool(store.healthcheck())
                    except Exception as exc:  # pragma: no cover - optional feature
                        logging.getLogger(__name__).warning(
                            "Failed to run persistence healthcheck: %s", exc
                        )

        contradictions = memory.symbolic_inconsistency_metrics(
            namespace=effective_namespace)
        internal_namespace = contradictions.get("namespace")
        namespace_label = public_namespace(tenant, internal_namespace)
        symbolic_inconsistencies = {
            key: value for key,
            value in contradictions.items() if key != "namespace"}

        update_symbolic_inconsistencies(
            namespace=internal_namespace,
            violations=symbolic_inconsistencies,
        )

        namespace_summaries: dict[str, dict[str, Any]] | None = None
        if (
            namespace is None
            and getattr(memory, "_multi_tenant", False)
            and tenant.enforce_namespace_isolation
        ):
            namespace_summaries = {}
            summaries = memory.symbolic_inconsistency_by_namespace()
            prefix = f"{tenant.tenant_id}::"
            for internal_key, metrics in summaries.items():
                if not internal_key.startswith(prefix):
                    continue
                public_key = public_namespace(tenant, internal_key)
                if public_key is None:
                    continue
                namespace_summaries[public_key] = metrics

        uptime = float(max(0.0, time.time() - _SERVICE_START_TS))

        return MetricResponse(
            total_episodes=snapshot.total_episodes,
            avg_trust=snapshot.trust_mean,
            gsr_valid=gsr_valid,
            summary_distortion=float(stability_snapshot.summary_distortion),
            distortion_bound=float(stability_snapshot.summary_distortion_bound),
            contraction_factor=float(stability_snapshot.contraction_factor),
            lyapunov_margin=float(stability_snapshot.lyapunov_margin),
            lyapunov_warning=bool(stability_snapshot.lyapunov_warning),
            uptime_s=uptime,
            namespace=namespace_label,
            symbolic_inconsistencies=symbolic_inconsistencies or None,
            symbolic_namespace_summaries=namespace_summaries,
            activity=activity or None,
            persistence=persistence_info,
        )
    except Exception as exc:  # pragma: no cover - FastAPI will surface HTTPException
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.get("/tree", response_model=list[TreeItem])
def get_tree(tenant: Tenant = Depends(require_api_key),
             _perm_check: None = Depends(require_permission(Permission.READ_MEMORY))) -> list[TreeItem]:
    """Retrieve hierarchy tree of all active episodes."""
    tenant = resolve_tenant(tenant)
    try:
        # Return all episodes in the current namespace
        # For multi-tenant, memory.episodes is already scoped if we set namespace?
        # But we need to ensure we are reading from the correct namespace.
        # memory.episodes property reads from self.namespace.
        # But we haven't set self.namespace here explicitly?
        # Wait, other routes don't set it on 'memory' object globally?
        # They use 'canonical_namespace' and pass it to methods.
        # But memory.episodes is a property.
        # Let's look at how 'metrics' does it:
        # with memory.use_namespace(effective_namespace):

        # We should use use_namespace context manager.

        # Default namespace handling
        ns = canonical_namespace(tenant, None)  # Default namespace for tenant

        items = []
        with memory.use_namespace(ns):
            for ep in memory.episodes:
                items.append(TreeItem(
                    id=ep.id,
                    parent=ep.parent,
                    trust=float(ep.trust),
                    tocc=ep.tocc.isoformat(),
                    meta=ep.meta
                ))
        return items
    except Exception as exc:
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.get("/{episode_id}", response_model=EpisodeDetail)
def get_episode(episode_id: str, tenant: Tenant = Depends(
        require_api_key), _perm_check: None = Depends(require_permission(Permission.READ_MEMORY))) -> EpisodeDetail:
    """Retrieve full details of a specific episode."""
    tenant = resolve_tenant(tenant)
    try:
        ns = canonical_namespace(tenant, None)

        target_ep = None
        with memory.use_namespace(ns):
            # 1. Search active memory
            for ep in memory.episodes:
                if ep.id == episode_id:
                    target_ep = ep
                    break

            # 2. If not found, try store (if available)
            if not target_ep and memory.store:
                target_ep = memory.store.load_by_id(episode_id)

        if not target_ep:
            raise HTTPException(status_code=404,
                                detail=f"Episode {episode_id} not found")

        # Convert to EpisodeDetail
        # Episode.to_dict() matches structure but we need to be explicit for
        # Pydantic

        # Construct graph dict
        g_nodes = [
            {"id": n, **dict(target_ep.G.nodes[n])}
            for n in target_ep.G.nodes
        ]
        g_edges = [
            {"u": u, "v": v, **dict(target_ep.G.edges[u, v])}
            for u, v in target_ep.G.edges
        ]

        return EpisodeDetail(
            id=target_ep.id,
            trust=float(target_ep.trust),
            timestamp=target_ep.tocc.isoformat(),
            meta=target_ep.meta,
            e=target_ep.e.tolist(),
            G={"nodes": g_nodes, "edges": g_edges},
            parent=target_ep.parent
        )

    except HTTPException:
        raise
    except Exception as exc:
        raise HTTPException(status_code=500, detail=str(exc)) from exc
