"""
Data models for the EMDS Workbench hierarchy.

Workspace -> Study -> Analysis -> Task -> Result
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional
import json


class EngineType(Enum):
    """Engine/task families supported by EMDS Workbench."""

    NETWEAVER = "NetWeaver"
    CDP = "CDP"
    WORKFLOW = "Workflow"
    TEMPLATE_1 = "LPA"
    TEMPLATE_2 = "Portfolio"
    PYTHON_SCRIPT = "PythonScript"


class LayerKind(Enum):
    """Kind of a QGIS layer for EMDS-homogeneity purposes.

    A single Study (analysis input set) must be all-VECTOR or all-RASTER;
    layers of the opposite kind are allowed only as display-only spatial
    features (Study.display_layers), never as analysis inputs.
    """

    VECTOR = "vector"
    RASTER = "raster"


@dataclass
class LayerRef:
    """Typed reference to a study-area layer with per-input carry-forward.

    For vector inputs, `fields_to_carry` lists the attribute fields that
    survive the vector identity-overlay into the output layer (empty list
    means "all fields").

    For raster inputs, `bands_to_carry` and `rat_columns_to_carry`
    together describe what survives the raster overlay (GRASS r.cross
    analogue of ArcGIS Spatial Analyst Combine) into the output raster's
    RAT. At least one of the two lists must be non-empty for the input
    to contribute anything to the output RAT.

    The `role` field uses the canonical ArcGIS Pro vocabulary
    (raster-support Phase 3.5b): `"target"` (one per study, defines the
    snap-to grid for raster studies and the target layer for vector
    identity overlays) and `"overlay"` (any number, contribute their
    attributes to the output). Vector studies additionally use
    `"selector"` and `"selector_with_data"` for spatial selection
    semantics; rasters explicitly reject those roles
    (ArcGisRasterStudyAreaBuilder.cs:57-59). Legacy QGIS Phase 1
    writes used `"included"`; `from_dict` normalizes that to `"overlay"`
    on read for the grace-cycle.
    """

    name: str
    kind: LayerKind
    role: str = "overlay"  # canonical: "target" | "overlay" | "selector" | "selector_with_data"
    fields_to_carry: List[str] = field(default_factory=list)
    bands_to_carry: List[int] = field(default_factory=list)
    rat_columns_to_carry: List[str] = field(default_factory=list)

    # Legacy role values from QGIS Phase 1 writes, mapped to canonical
    # on read. Remove the mapping when the grace cycle ends (see
    # Study.apply_kind_metadata removal milestone).
    _LEGACY_ROLE_ALIASES = {"included": "overlay"}

    def to_dict(self) -> Dict[str, Any]:
        return {
            "name": self.name,
            "kind": self.kind.value,
            "role": self.role,
            "fields_to_carry": list(self.fields_to_carry),
            "bands_to_carry": list(self.bands_to_carry),
            "rat_columns_to_carry": list(self.rat_columns_to_carry),
        }

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "LayerRef":
        raw_kind = data.get("kind", LayerKind.VECTOR.value)
        try:
            kind = LayerKind(raw_kind)
        except ValueError:
            kind = LayerKind.VECTOR
        raw_role = str(data.get("role", "overlay")) or "overlay"
        role = cls._LEGACY_ROLE_ALIASES.get(raw_role, raw_role)
        return cls(
            name=str(data.get("name", "")),
            kind=kind,
            role=role,
            fields_to_carry=list(data.get("fields_to_carry", []) or []),
            bands_to_carry=[int(b) for b in (data.get("bands_to_carry", []) or [])],
            rat_columns_to_carry=list(data.get("rat_columns_to_carry", []) or []),
        )


@dataclass
class RasterOverlaySpec:
    """Alignment + output config for a raster Study's overlay step.

    Captured by the raster Create-Study-Area dialog and consumed by the
    raster overlay primitive (GRASS r.cross wrapper) at study-area
    preparation time. Vector studies leave this None.
    """

    snap_to: str = ""              # input layer name whose grid defines output
    resampling: str = "nearest"    # "nearest" | "bilinear" | "cubic" | "mode"
    output_format: str = "GTiff"   # "GTiff" | "COG"
    output_path: str = ""

    def to_dict(self) -> Dict[str, Any]:
        return {
            "snap_to": self.snap_to,
            "resampling": self.resampling,
            "output_format": self.output_format,
            "output_path": self.output_path,
        }

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "RasterOverlaySpec":
        return cls(
            snap_to=str(data.get("snap_to", "")),
            resampling=str(data.get("resampling", "nearest")) or "nearest",
            output_format=str(data.get("output_format", "GTiff")) or "GTiff",
            output_path=str(data.get("output_path", "")),
        )


@dataclass
class Result:
    """Output produced by a task."""

    id: str
    layer_name: str
    feature_count: int
    layer_type: str
    visible: bool = True
    created_at: datetime = field(default_factory=datetime.now)
    metadata: Dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "id": self.id,
            "layer_name": self.layer_name,
            "feature_count": self.feature_count,
            "layer_type": self.layer_type,
            "visible": self.visible,
            "created_at": self.created_at.isoformat(),
            "metadata": self.metadata,
        }


@dataclass
class Task:
    """A single runnable unit inside an analysis."""

    id: str
    name: str
    engine_type: EngineType
    parameters: Dict[str, Any] = field(default_factory=dict)
    metadata: Dict[str, Any] = field(default_factory=dict)
    result: Optional[Result] = None
    children: List["Task"] = field(default_factory=list)
    created_at: datetime = field(default_factory=datetime.now)
    depth: int = 0

    @property
    def results(self) -> Optional[Result]:
        """Compatibility shim for pre-Workbench code paths."""
        return self.result

    @results.setter
    def results(self, value: Optional[Result]) -> None:
        self.result = value

    def add_child(self, child: "Task") -> bool:
        """Add a child task if the nesting depth limit has not been reached."""
        if self.depth >= 5:
            return False
        child.depth = self.depth + 1
        self.children.append(child)
        return True

    def to_dict(self) -> Dict[str, Any]:
        return {
            "id": self.id,
            "name": self.name,
            "engine_type": self.engine_type.value,
            "parameters": self.parameters,
            "metadata": self.metadata,
            "result": self.result.to_dict() if self.result else None,
            "children": [child.to_dict() for child in self.children],
            "created_at": self.created_at.isoformat(),
            "depth": self.depth,
        }


@dataclass
class Analysis:
    """A named body of decision work within a study."""

    id: str
    name: str
    description: str = ""
    metadata: Dict[str, Any] = field(default_factory=dict)
    tasks: List[Task] = field(default_factory=list)
    created_at: datetime = field(default_factory=datetime.now)

    @property
    def workflow_steps(self) -> List[Task]:
        """Compatibility shim for pre-Workbench code paths."""
        return self.tasks

    @workflow_steps.setter
    def workflow_steps(self, value: List[Task]) -> None:
        self.tasks = value

    def add_task(self, task: Task) -> None:
        self.tasks.append(task)

    def add_workflow_step(self, task: Task) -> None:
        self.add_task(task)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "id": self.id,
            "name": self.name,
            "description": self.description,
            "metadata": self.metadata,
            "tasks": [task.to_dict() for task in self.tasks],
            "created_at": self.created_at.isoformat(),
        }


@dataclass
class Study:
    """GIS scope for analysis: target layer, included layers, and prepared outputs.

    Layer-kind model (raster support, Phase 1):
      - `kind` is the homogeneous kind of all analysis-input layers
        (all-VECTOR or all-RASTER). Locked at study-area creation.
      - `input_layers: List[str]` is kept as the legacy name list for
        back-compat with callers that index/assign it as strings (dock
        widget, project metadata managers). This is the names of all
        analysis-input layers.
      - `input_layer_refs: List[LayerRef]` is the new typed list carrying
        per-input role (target/included) and carry-forward configuration
        (vector field list or raster bands + RAT columns). New code
        should populate this; legacy rows default to one VECTOR LayerRef
        per name in `input_layers` (synthesized on load).
      - `display_layers: List[LayerRef]` is the explicit "display-only
        spatial feature" lane (basemap / context). Opposite-kind layers
        live here, never in `input_layers`.
      - `raster_overlay` carries the raster-only alignment + output
        config consumed by the overlay primitive (GRASS r.cross). Vector
        studies leave it None.
    """

    id: str
    name: str
    description: str = ""
    input_layers: List[str] = field(default_factory=list)
    metadata: Dict[str, Any] = field(default_factory=dict)
    analyses: List[Analysis] = field(default_factory=list)
    created_at: datetime = field(default_factory=datetime.now)
    kind: LayerKind = LayerKind.VECTOR
    input_layer_refs: List[LayerRef] = field(default_factory=list)
    display_layers: List[LayerRef] = field(default_factory=list)
    raster_overlay: Optional[RasterOverlaySpec] = None

    @property
    def provenances(self) -> List[Analysis]:
        """Compatibility shim for pre-Workbench code paths."""
        return self.analyses

    @provenances.setter
    def provenances(self, value: List[Analysis]) -> None:
        self.analyses = value

    def add_analysis(self, analysis: Analysis) -> None:
        self.analyses.append(analysis)

    def add_provenance(self, analysis: Analysis) -> None:
        self.add_analysis(analysis)

    # ---- Layer-ref helpers (raster-support Phase 1) --------------------

    def resolved_input_layer_refs(self) -> List[LayerRef]:
        """Return typed refs, synthesizing defaults for legacy name-only studies.

        If `input_layer_refs` is populated, return it as-is. Otherwise
        build one LayerRef per name in `input_layers`, defaulting to the
        study's `kind`. This lets callers always work with typed refs
        without forcing every legacy load path to backfill them.
        """
        if self.input_layer_refs:
            return list(self.input_layer_refs)
        return [
            LayerRef(name=name, kind=self.kind, role="overlay")
            for name in self.input_layers
        ]

    def get_input_layer_ref(self, name: str) -> Optional[LayerRef]:
        """Look up a typed input-layer ref by name (case-sensitive)."""
        for ref in self.resolved_input_layer_refs():
            if ref.name == name:
                return ref
        return None

    @property
    def reference_layer_name(self) -> str:
        """Back-compat single-value view onto display_layers.

        Today's StudyMetadata carries one reference_layer_name string.
        The Phase-1 model promotes this to a list (display_layers); this
        property returns the first entry for callers that still expect
        a single name.
        """
        return self.display_layers[0].name if self.display_layers else ""

    # ---- Persistence helpers for the raster-kind metadata ---------------
    #
    # Round-trip Study.kind / input_layer_refs / display_layers /
    # raster_overlay through the metadata JSON blob without touching
    # the metadata managers. Phase 3.5a switched the wire format to
    # the canonical ArcGIS Pro key set (see
    # StudyCreationService.cs:34-66): top-level keys are `data_kind`
    # (PascalCase "Vector"/"Raster"), `target_layers` / `overlay_layers`
    # / `selector_layers` / `selector_with_data_layers` /
    # `spatial_marker_layers` (each List[str] of layer names), plus
    # vector-only fields. QGIS-only data (per-input bands + RAT
    # columns, snap-to/resampling/output spec) lives under a single
    # `qgis_extensions` nested key that the ArcGIS Pro side is
    # required to ignore.
    #
    # Reader accepts both canonical and legacy (Phase 1) shapes for
    # one grace cycle, then the legacy fallback is removed.

    # Canonical → in-memory LayerKind enum lookup. Canonical writes
    # PascalCase; legacy QGIS Phase 1 writes lowercase. Both accepted.
    _DATA_KIND_LOOKUP = {
        "Vector": LayerKind.VECTOR,
        "Raster": LayerKind.RASTER,
        "vector": LayerKind.VECTOR,
        "raster": LayerKind.RASTER,
    }

    # Canonical-only top-level keys that embed_kind_metadata writes.
    # Used by tests to assert the wire shape.
    _CANONICAL_TOP_LEVEL_KEYS = (
        "data_kind",
        "target_layers",
        "overlay_layers",
        "selector_layers",
        "selector_with_data_layers",
        "spatial_marker_layers",
        "overlay_name",
        "simplify_polygons",
        "display_borders",
        "selected_feature_count",
        "selected_feature_ids",
        "qgis_extensions",
    )

    QGIS_EXTENSIONS_KEY = "qgis_extensions"

    def apply_kind_metadata(self, metadata: Dict[str, Any]) -> None:
        """Populate kind / input_layer_refs / display_layers / raster_overlay.

        Tries canonical ArcGIS Pro keys first; falls back to legacy
        QGIS Phase 1 keys for the grace cycle. Safe to call with a
        metadata dict that lacks any of these keys (defaults kind to
        VECTOR and leaves the typed lists empty).

        Raises ValueError when a raster study carries non-empty
        selector_layers / selector_with_data_layers (raster-support
        Phase 3.5g; canonical builder explicitly forbids selector
        roles on raster studies — ArcGisRasterStudyAreaBuilder.cs:57).
        """
        if not metadata:
            return

        used_canonical = self._apply_canonical_metadata(metadata)
        if not used_canonical:
            self._apply_legacy_metadata(metadata)
            # One-line breadcrumb so we know when the grace cycle is
            # safe to drop. Best-effort import — debug_logger isn't
            # importable from tests that bypass the package init.
            try:
                from . import debug_logger
                debug_logger.debug(
                    "Loaded study metadata from legacy QGIS Phase-1 shape "
                    "(no canonical data_kind). Re-save the study to migrate "
                    "to the ArcGIS Pro canonical key set.",
                    {"study_id": getattr(self, "id", "?"), "name": self.name},
                )
            except Exception:
                pass

    def _apply_canonical_metadata(self, metadata: Dict[str, Any]) -> bool:
        """Try the canonical ArcGIS Pro key set; return True iff used."""
        if "data_kind" not in metadata:
            return False

        raw_kind = metadata.get("data_kind")
        self.kind = self._DATA_KIND_LOOKUP.get(str(raw_kind), LayerKind.VECTOR)

        # Phase 3.5g: rasters reject selector roles outright.
        if self.kind == LayerKind.RASTER:
            for offending_key in ("selector_layers", "selector_with_data_layers"):
                raw = metadata.get(offending_key) or []
                if isinstance(raw, list) and len(raw) > 0:
                    raise ValueError(
                        f"Raster study area '{self.name}' carries "
                        f"'{offending_key}' = {raw} but the canonical raster "
                        f"builder rejects selector roles. Move those layers "
                        f"to 'spatial_marker_layers' (display-only) or "
                        f"remove them from the study area."
                    )

        # Canonical lists hold layer names only; split into typed
        # LayerRefs by role. The kind comes from the study's overall
        # data_kind (homogeneity rule — see study-area-homogeneity-rule
        # memory). Carry-forward picks live under qgis_extensions and
        # get merged on top below.
        target_names = list(metadata.get("target_layers", []) or [])
        overlay_names = list(metadata.get("overlay_layers", []) or [])
        selector_names = list(metadata.get("selector_layers", []) or [])
        selector_with_data_names = list(metadata.get("selector_with_data_layers", []) or [])
        spatial_marker_names = list(metadata.get("spatial_marker_layers", []) or [])

        refs: List[LayerRef] = []
        refs.extend(LayerRef(name=n, kind=self.kind, role="target") for n in target_names)
        refs.extend(LayerRef(name=n, kind=self.kind, role="overlay") for n in overlay_names)
        # Selectors only meaningful for vector; if a vector study has
        # them, preserve the typed role. Raster selectors were rejected
        # above so this branch is vector-only in practice.
        refs.extend(LayerRef(name=n, kind=self.kind, role="selector") for n in selector_names)
        refs.extend(
            LayerRef(name=n, kind=self.kind, role="selector_with_data")
            for n in selector_with_data_names
        )
        self.input_layer_refs = refs

        # spatial_marker_layers carries the opposite-kind display-only
        # lane. The kind isn't recorded canonically (canonical model
        # doesn't care because ArcGIS Pro figures it out at runtime),
        # so we mark them with the OPPOSITE of the study's kind so
        # display_layers stays semantically right. If neither side is
        # right (e.g. both same-kind markers), the dialog can sort it
        # out later; the metadata-load path stays naive.
        opposite_kind = (
            LayerKind.RASTER if self.kind == LayerKind.VECTOR else LayerKind.VECTOR
        )
        self.display_layers = [
            LayerRef(name=n, kind=opposite_kind, role="spatial_marker")
            for n in spatial_marker_names
        ]

        # qgis_extensions: nested QGIS-only data (band/RAT carry-forward
        # + raster overlay spec). Merge per-input picks back onto the
        # already-built input_layer_refs by name.
        extensions = metadata.get(self.QGIS_EXTENSIONS_KEY) or {}
        if isinstance(extensions, dict):
            self._merge_carry_forward_from_extensions(extensions)
            raw_overlay = extensions.get("raster_overlay")
            if isinstance(raw_overlay, dict):
                self.raster_overlay = RasterOverlaySpec.from_dict(raw_overlay)

        return True

    def _merge_carry_forward_from_extensions(
        self,
        extensions: Dict[str, Any],
    ) -> None:
        """Apply qgis_extensions.raster_carry_forward picks to input_layer_refs."""
        carry = extensions.get("raster_carry_forward") or []
        if not isinstance(carry, list):
            return
        by_name: Dict[str, LayerRef] = {ref.name: ref for ref in self.input_layer_refs}
        for entry in carry:
            if not isinstance(entry, dict):
                continue
            name = str(entry.get("name", ""))
            ref = by_name.get(name)
            if ref is None:
                continue
            ref.bands_to_carry = [
                int(b) for b in (entry.get("bands_to_carry", []) or [])
            ]
            ref.rat_columns_to_carry = list(
                entry.get("rat_columns_to_carry", []) or []
            )
            fields = entry.get("fields_to_carry")
            if isinstance(fields, list):
                ref.fields_to_carry = list(fields)

    def _apply_legacy_metadata(self, metadata: Dict[str, Any]) -> None:
        """Read the QGIS Phase 1 metadata shape. Remove after grace cycle."""
        raw_kind = metadata.get("kind")
        if raw_kind:
            self.kind = self._DATA_KIND_LOOKUP.get(str(raw_kind), LayerKind.VECTOR)

        raw_refs = metadata.get("input_layer_refs") or []
        if isinstance(raw_refs, list) and raw_refs:
            self.input_layer_refs = [
                LayerRef.from_dict(item) if isinstance(item, dict) else item
                for item in raw_refs
            ]

        raw_display = metadata.get("display_layers") or []
        if isinstance(raw_display, list) and raw_display:
            self.display_layers = [
                LayerRef.from_dict(item) if isinstance(item, dict) else item
                for item in raw_display
            ]

        raw_overlay = metadata.get("raster_overlay")
        if isinstance(raw_overlay, dict):
            self.raster_overlay = RasterOverlaySpec.from_dict(raw_overlay)

    def embed_kind_metadata(self, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
        """Return a copy of `metadata` with canonical ArcGIS Pro keys embedded.

        Writes only canonical top-level keys (raster-support Phase 3.5a)
        + QGIS-only data nested under `qgis_extensions`. Pre-existing
        non-canonical keys in the passed metadata are preserved
        (callers may have product-specific extras to round-trip).
        """
        merged = dict(metadata or {})

        # ---- Canonical top-level keys ----
        merged["data_kind"] = "Vector" if self.kind == LayerKind.VECTOR else "Raster"

        target_layers: List[str] = []
        overlay_layers: List[str] = []
        selector_layers: List[str] = []
        selector_with_data_layers: List[str] = []
        for ref in self.input_layer_refs:
            if ref.role == "target":
                target_layers.append(ref.name)
            elif ref.role == "selector":
                selector_layers.append(ref.name)
            elif ref.role == "selector_with_data":
                selector_with_data_layers.append(ref.name)
            else:
                # "overlay" + any unknown role default to overlay so
                # nothing is silently dropped.
                overlay_layers.append(ref.name)
        merged["target_layers"] = target_layers
        merged["overlay_layers"] = overlay_layers
        merged["selector_layers"] = selector_layers
        merged["selector_with_data_layers"] = selector_with_data_layers
        merged["spatial_marker_layers"] = [ref.name for ref in self.display_layers]

        # overlay_name: for raster studies derive from the output_path
        # basename if set, else any caller-provided value. For vector
        # studies pull from metadata's existing output_layer_name.
        if self.kind == LayerKind.RASTER and self.raster_overlay and self.raster_overlay.output_path:
            from pathlib import Path as _Path
            merged["overlay_name"] = _Path(self.raster_overlay.output_path).stem
        else:
            merged.setdefault(
                "overlay_name",
                merged.get("output_layer_name", "") or "",
            )

        # Vector-only canonical fields. Carry caller-supplied values
        # through, otherwise emit defaults so the wire shape is stable.
        # `simplify_polygons` is ArcGIS-owned and inert on the QGIS side by
        # design: it gates management.SimplifyPolygon, a CARTOGRAPHIC
        # generalization that moves vertices. QGIS's D4 cleanup
        # (_drop_degenerate_geometries) is a correctness filter that must always
        # run, so it is deliberately NOT bound to this flag — a user unchecking
        # a box must not be able to put no-data rows back into the Study Area.
        # Round-tripped for wire-shape stability, never read here.
        merged.setdefault("simplify_polygons", bool(merged.get("simplify_polygons", False)))
        merged.setdefault("display_borders", bool(merged.get("display_borders", False)))
        merged.setdefault("selected_feature_count", int(merged.get("selected_feature_count", 0) or 0))
        merged.setdefault(
            "selected_feature_ids",
            list(merged.get("selected_feature_ids", []) or []),
        )

        # ---- QGIS-only data nested under qgis_extensions ----
        # Per-input band/RAT carry-forward only needs to ride along
        # when there's something to carry; same for raster_overlay.
        raster_carry_forward = [
            {
                "name": ref.name,
                "bands_to_carry": list(ref.bands_to_carry),
                "rat_columns_to_carry": list(ref.rat_columns_to_carry),
                "fields_to_carry": list(ref.fields_to_carry),
            }
            for ref in self.input_layer_refs
            if ref.bands_to_carry or ref.rat_columns_to_carry or ref.fields_to_carry
        ]
        existing_extensions = merged.get(self.QGIS_EXTENSIONS_KEY) or {}
        if not isinstance(existing_extensions, dict):
            existing_extensions = {}
        if raster_carry_forward:
            existing_extensions["raster_carry_forward"] = raster_carry_forward
        if self.raster_overlay is not None:
            existing_extensions["raster_overlay"] = self.raster_overlay.to_dict()
        if existing_extensions:
            merged[self.QGIS_EXTENSIONS_KEY] = existing_extensions

        return merged

    def to_dict(self) -> Dict[str, Any]:
        return {
            "id": self.id,
            "name": self.name,
            "description": self.description,
            "input_layers": self.input_layers,
            "metadata": self.metadata,
            "analyses": [analysis.to_dict() for analysis in self.analyses],
            "created_at": self.created_at.isoformat(),
            "kind": self.kind.value,
            "input_layer_refs": [ref.to_dict() for ref in self.input_layer_refs],
            "display_layers": [ref.to_dict() for ref in self.display_layers],
            "raster_overlay": self.raster_overlay.to_dict() if self.raster_overlay else None,
        }


@dataclass
class Workspace:
    """Top-level EMDS-owned container."""

    id: str
    name: str
    description: str = ""
    studies: List[Study] = field(default_factory=list)
    metadata: Dict[str, Any] = field(default_factory=dict)
    created_at: datetime = field(default_factory=datetime.now)

    @property
    def assessments(self) -> List[Study]:
        """Compatibility shim for pre-Workbench code paths."""
        return self.studies

    @assessments.setter
    def assessments(self, value: List[Study]) -> None:
        self.studies = value

    def add_study(self, study: Study) -> None:
        self.studies.append(study)

    def add_assessment(self, study: Study) -> None:
        self.add_study(study)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "id": self.id,
            "name": self.name,
            "description": self.description,
            "studies": [study.to_dict() for study in self.studies],
            "metadata": self.metadata,
            "created_at": self.created_at.isoformat(),
        }

    def to_json(self) -> str:
        return json.dumps(self.to_dict(), indent=2, default=str)


@dataclass
class WorkspaceManager:
    """Manager for all open/known workspaces."""

    workspaces: List[Workspace] = field(default_factory=list)

    @property
    def projects(self) -> List[Workspace]:
        """Compatibility shim for pre-Workbench code paths."""
        return self.workspaces

    @projects.setter
    def projects(self, value: List[Workspace]) -> None:
        self.workspaces = value

    def add_workspace(self, workspace: Workspace) -> None:
        self.workspaces.append(workspace)

    def add_project(self, workspace: Workspace) -> None:
        self.add_workspace(workspace)

    def set_workspace(self, workspace: Workspace) -> None:
        """Add or replace a workspace by ID."""
        for index, existing in enumerate(self.workspaces):
            if existing.id == workspace.id:
                self.workspaces[index] = workspace
                return
        self.add_workspace(workspace)

    def set_project(self, workspace: Workspace) -> None:
        self.set_workspace(workspace)

    def get_workspace(self, workspace_id: str) -> Optional[Workspace]:
        for workspace in self.workspaces:
            if workspace.id == workspace_id:
                return workspace
        return None

    def get_project(self, workspace_id: str) -> Optional[Workspace]:
        return self.get_workspace(workspace_id)

    def delete_workspace(self, workspace_id: str) -> bool:
        for index, workspace in enumerate(self.workspaces):
            if workspace.id == workspace_id:
                self.workspaces.pop(index)
                return True
        return False

    def delete_project(self, workspace_id: str) -> bool:
        return self.delete_workspace(workspace_id)

    def get_study(self, workspace_id: str, study_id: str) -> Optional[Study]:
        workspace = self.get_workspace(workspace_id)
        if workspace:
            for study in workspace.studies:
                if study.id == study_id:
                    return study
        return None

    def get_assessment(self, workspace_id: str, study_id: str) -> Optional[Study]:
        return self.get_study(workspace_id, study_id)

    def get_analysis(self, workspace_id: str, study_id: str, analysis_id: str) -> Optional[Analysis]:
        study = self.get_study(workspace_id, study_id)
        if study:
            for analysis in study.analyses:
                if analysis.id == analysis_id:
                    return analysis
        return None

    def get_provenance(self, workspace_id: str, study_id: str, analysis_id: str) -> Optional[Analysis]:
        return self.get_analysis(workspace_id, study_id, analysis_id)

    def find_task(
        self,
        workspace_id: str,
        study_id: str,
        analysis_id: str,
        task_id: str,
    ) -> Optional[Task]:
        analysis = self.get_analysis(workspace_id, study_id, analysis_id)
        if analysis:
            for task in analysis.tasks:
                found = self._find_task_recursive(task, task_id)
                if found:
                    return found
        return None

    def find_workflow_step(
        self,
        workspace_id: str,
        study_id: str,
        analysis_id: str,
        task_id: str,
    ) -> Optional[Task]:
        return self.find_task(workspace_id, study_id, analysis_id, task_id)

    @staticmethod
    def _find_task_recursive(task: Task, task_id: str) -> Optional[Task]:
        if task.id == task_id:
            return task
        for child in task.children:
            found = WorkspaceManager._find_task_recursive(child, task_id)
            if found:
                return found
        return None

    def to_json(self) -> str:
        data = {"workspaces": [workspace.to_dict() for workspace in self.workspaces]}
        return json.dumps(data, indent=2, default=str)


# Transitional aliases for modules not yet renamed at the file/function level.
Project = Workspace
Assessment = Study
Provenance = Analysis
WorkflowStep = Task
Results = Result
ProjectManager = WorkspaceManager
