import json
import uuid
from copy import deepcopy
from typing import Union, List, Dict


# ── Internal helpers ──────────────────────────────────────────────────────────

def _upper_uuid() -> str:
    return str(uuid.uuid4()).upper()


def _lower_uuid() -> str:
    return str(uuid.uuid4())


def _short_hex_id() -> str:
    return uuid.uuid4().hex[:6]


def _default_table_cell_display() -> dict:
    return {
        "conditionalFormattingOpts": {
            "autoGenerate": True,
            "colorPalette": "#ffa48c,#ffcb94,#fff19b,#cadf7d,#95cc5f",
            "usePercentLimits": False,
        },
        "location": "behindNumber",
        "type": {
            "background": True,
            "backgroundType": "bar",
            "comparison": "none",
            "interpretZeroAsNoValue": False,
            "number": True,
            "percent": True,
            "showAnomaly": False,
            "showForecasting": False,
            "showGrandTotal": True,
            "showSparklines": True,
            "showTotals": True,
            "wrapHeaderText": True,
        },
    }


def _default_data_settings() -> dict:
    return {
        "advancedItemLimit": 5,
        "advancedItemSearch": {
            "alwaysExcludedItems": [],
            "operator": "AND",
            "rules": [],
        },
    }


def _default_search() -> dict:
    return {"alwaysExcludedItems": [], "operator": "AND", "rules": []}


def _intelligent_captions() -> dict:
    return {"captions": [], "hasLoaded": False, "hiddenCaptions": [], "isExpanded": False}


def _metric_node(metric_id: str, metric_name: str) -> dict:
    """Build a single metric node for columnTree.nodes."""
    if metric_id.startswith("cm"):
        component_type = "CalculatedMetric"
    else:
        component_type = "Metric"
    return {
        "_computedValues": [],
        "component": {
            "id": metric_id,
            "__entity__": True,
            "type": component_type,
            "__metaData__": {"name": metric_name},
        },
        "dataSettings": _default_data_settings(),
        "id": _lower_uuid(),
        "name": metric_name,
        "nodes": [],
        "selectionCoordinates": [],
        "tableCellDisplay": _default_table_cell_display(),
        "visible": True,
    }


def _filter_node(filter_id: str, filter_type: str, filter_name: str) -> dict:
    """Build a DimensionItem or Segment filter node for a columnTree node."""
    return {
        "_computedValues": [],
        "component": {
            "id": filter_id,
            "__entity__": True,
            "type": filter_type,
            "__metaData__": {"name": filter_name},
        },
        "dataSettings": _default_data_settings(),
        "id": _lower_uuid(),
        "name": filter_name,
        "nodes": [],
        "selectionCoordinates": [],
        "tableCellDisplay": _default_table_cell_display(),
        "visible": True,
    }


def _get_leaf_metric_id(node: dict) -> str:
    """Return the node id of the first Metric/CalculatedMetric leaf in a columnTree node."""
    comp_type = node.get("component", {}).get("type", "")
    if comp_type in ("Metric", "CalculatedMetric"):
        return node["id"]
    for child in node.get("nodes", []):
        result = _get_leaf_metric_id(child)
        if result:
            return result
    return node["id"]


def _build_column_nodes(metrics: List[dict]) -> tuple:
    """
    Build the list of columnTree nodes from a list of metric dicts.

    Each metric dict may carry an optional ``"filters"`` list.  Each filter
    must have ``"id"``, ``"name"``, and optionally ``"type"``
    (``"DimensionItem"`` or ``"Segment"``; defaults to ``"Segment"``).

    Rules:

    * No filters → plain metric node.
    * Segment filters only → metric node whose ``nodes`` are the segments
      (the metric appears as one column, split by segment sub-columns).
    * DimensionItem filters only → one outer DimensionItem node per filter
      item, each containing a copy of the metric node as its child (each
      dim-item value becomes its own column).
    * Both → one DimensionItem node per dim-item filter, each containing
      the metric node which itself has the segment nodes as children.

    Returns ``(nodes, first_sort_id)`` where *first_sort_id* is the node id
    of the first leaf metric, suitable for ``freeformTable.sort.columnId``.

    Example metric dicts::

        # plain metric
        {"id": "metrics/visits", "name": "Visits"}

        # metric filtered by a dimension item
        {"id": "metrics/visits", "name": "Visits",
         "filters": [{"id": "variables/evar8::581609802", "type": "DimensionItem", "name": "DE"}]}

        # metric split by two segments
        {"id": "metrics/visits", "name": "Visits",
         "filters": [
             {"id": "s4222_aaa", "type": "Segment", "name": "Mobile"},
             {"id": "s4222_bbb", "type": "Segment", "name": "Desktop"},
         ]}

        # metric filtered by two dimension items, each split by a segment
        {"id": "metrics/visits", "name": "Visits",
         "filters": [
             {"id": "variables/evar8::DE_id",  "type": "DimensionItem", "name": "DE"},
             {"id": "variables/evar8::UK_id",  "type": "DimensionItem", "name": "UK"},
             {"id": "s4222_mobile", "type": "Segment", "name": "Mobile"},
         ]}
    """
    all_nodes: List[dict] = []
    first_sort_id: str = ""

    for m in metrics:
        filters = m.get("filters", [])
        dim_filters = [f for f in filters if f.get("type") == "DimensionItem"]
        seg_filters = [f for f in filters if f.get("type") != "DimensionItem"]

        # Build the base metric node
        base_metric = _metric_node(m["id"], m["name"])

        # Attach segment children to the metric node (if any)
        if seg_filters:
            base_metric["nodes"] = [
                _filter_node(f["id"], f.get("type", "Segment"), f["name"])
                for f in seg_filters
            ]

        if not dim_filters:
            # Plain metric, or metric with segment children
            all_nodes.append(base_metric)
            if not first_sort_id:
                first_sort_id = _get_leaf_metric_id(base_metric)
        else:
            # One DimensionItem outer node per dim-item filter
            for dim_f in dim_filters:
                outer = _filter_node(dim_f["id"], "DimensionItem", dim_f["name"])
                inner = deepcopy(base_metric)
                inner["id"] = _lower_uuid()  # fresh ID for each copy
                outer["nodes"] = [inner]
                all_nodes.append(outer)
                if not first_sort_id:
                    first_sort_id = inner["id"]

    return all_nodes, first_sort_id or (all_nodes[0]["id"] if all_nodes else "")


def _column_tree(metric_nodes: List[dict]) -> dict:
    """Build the columnTree wrapper around a list of metric nodes."""
    return {
        "_computedValues": [],
        "dataSettings": _default_data_settings(),
        "id": f"{_short_hex_id()}-0",
        "name": "",
        "nodes": metric_nodes,
        "selectionCoordinates": [],
        "tableCellDisplay": _default_table_cell_display(),
        "visible": True,
    }


def _dimension_setting(dim_id: str, dim_name: str) -> dict:
    return {
        "dimension": {
            "id": dim_id,
            "__entity__": True,
            "type": "Dimension",
            "__metaData__": {"name": dim_name},
        },
        "id": f"{_short_hex_id()}-1",
        "search": _default_search(),
    }


def _build_freeform_table(
    first_metric_node_id: str,
    dim_id: str = None,
    dim_name: str = None,
    static_rows: List[dict] = None,
    breakdowns: List[dict] = None,
    parent_item_ids: List[str] = None,
    n_columns: int = 3,
    view_by: int = 10,
) -> dict:
    """Build a freeformTable object.

    Supply either *dim_id* + *dim_name* for a dynamic dimension table
    (``totalsType='allVisits'``), or *static_rows* for a static-row table
    (``totalsType='columnSum'``).
    """
    column_widths = [100 / n_columns] * n_columns
    use_dimension = dim_id is not None
    return {
        "alignDatesForTimeDimension": True,
        "attributionSettings": [],
        "breakdowns": breakdowns or [],
        "collapsed": False,
        "columnWidths": column_widths,
        "dimensionSettings": [_dimension_setting(dim_id, dim_name)] if use_dimension else [],
        "hyperlinks": [],
        "pagination": {"currentPage": 0, "viewBy": view_by},
        "parentItemIds": parent_item_ids or [],
        "selectionCoordinates": [],
        "settings": {
            "breakdownByPosition": False,
            "rowBasedPercentages": False,
            "showThumbnails": False,
            "totalsType": "allVisits" if use_dimension else "columnSum",
        },
        "sort": {
            "advancedSortRules": [],
            "asc": False,
            "columnId": first_metric_node_id,
            "labelColumn": False,
        },
        "staticRows": static_rows or [],
        "staticSearch": _default_search(),
        "statistics": {"functions": [], "ignoreZeros": True},
    }


def _text_reportlet(title: str, text_content: str) -> dict:
    return {
        "disabled": False,
        "hideTitle": False,
        "intelligentCaptions": _intelligent_captions(),
        "isConfigVisible": True,
        "name": title,
        "readOnly": False,
        "showAnnotations": True,
        "showControls": True,
        "showVizSelectorOnSubPanel": False,
        "textContent": text_content,
        "type": "TextReportlet",
        "useRowBasedPercentages": False,
    }


def _freeform_reportlet(title: str, column_tree: dict, freeform_table: dict) -> dict:
    return {
        "advancedMode": False,
        "advancedSettings": {"rows": [], "tableState": "builder"},
        "columnTree": column_tree,
        "freeformTable": freeform_table,
        "hideTitle": False,
        "intelligentCaptions": _intelligent_captions(),
        "isConfigVisible": True,
        "isReadOnly": False,
        "name": title,
        "showAnnotations": True,
        "showControls": True,
        "type": "FreeformReportlet",
    }


_SWATCH_COLORS = [
    "#26C0C7", "#5151D3", "#E68619", "#D83790", "#908DFA", "#58E06F",
    "#2780EB", "#6F38B1", "#DFBF03", "#CB6F10", "#268D6C", "#9BEC54",
]

# Maps user-facing shorthand to the Workspace reportlet type string.
_VIZ_TYPE_MAP: Dict[str, str] = {
    "line":           "LineReportlet",
    "bar":            "BarReportlet",
    "bar_horizontal": "HorizontalBarReportlet",
    "bar_stacked":    "BarStackReportlet",
    "area":           "AreaReportlet",
    "area_stacked":   "AreaStackReportlet",
    "donut":          "DonutReportlet",
    "scatter":        "ScatterReportlet",
    "treemap":        "TreemapReportlet",
    "histogram":      "HistogramReportlet",
    "bullet":         "BulletReportlet",
    "venn":           "VennReportlet",
}


def _summary_number_reportlet(title: str, column_id: str) -> dict:
    """Build a SummaryNumberReportlet linked to *column_id* in a backing FreeformReportlet.

    The ``column_id`` must match the ``id`` field of the metric node inside the
    backing table's ``columnTree``.  Row position ``-1`` selects the grand-total row.
    """
    return {
        "abbreviatePrecision": 0,
        "abbreviateValue": False,
        "isConfigVisible": True,
        "legendVisible": False,
        "lockedSelection": {
            "breakdowns": [],
            "selectedDataPoints": [{"columnId": column_id, "rowPosition": -1}],
            "type": "FreeformBreakdownSelection",
        },
        "lockType": "positions",
        "name": title,
        "showVizSelectorOnSubPanel": False,
        "sourceCoords": "",
        "summarizeValue": False,
        "summarizeValueBy": "max",
        "type": "SummaryNumberReportlet",
        "usePercentage": False,
        "useRowBasedPercentages": False,
    }


def _detect_component_type(item_id: str) -> str:
    """Infer component type from an ID string.

    * ``variables/...::hash`` → ``"DimensionItem"``
    * ``variables/...`` (no ``"::"``) → ``"Dimension"``
    * anything else → ``"Segment"``
    """
    if "::" in item_id:
        return "DimensionItem"
    if item_id.startswith("variables/"):
        return "Dimension"
    return "Segment"


def _make_static_row(item_id: str, item_name: str, item_type: str = None) -> tuple:
    """Build a static row dict for any component type, returning ``(row_dict, row_id)``.

    *item_type* defaults to auto-detection via :func:`_detect_component_type`.
    """
    comp_type = item_type or _detect_component_type(item_id)
    row_id = f"{_short_hex_id()}-{_short_hex_id()[:3]}"
    return {
        "component": {
            "id": item_id,
            "__entity__": True,
            "type": comp_type,
            "__metaData__": {"name": item_name},
        },
        "dataSettings": _default_data_settings(),
        "id": row_id,
    }, row_id


def _chart_reportlet(reportlet_type: str, title: str) -> dict:
    return {
        "hideTitle": False,
        "name": title,
        "showAnnotations": True,
        "showControls": True,
        "type": reportlet_type,
    }


def _sub_panel(
    name: str,
    reportlet: dict,
    y_position: int = 0,
    auto_height: int = 400,
    swatch_color: str = "#26C0C7",
    viz_index: int = 1,
    collapsed: bool = False,
    description: str = "",
) -> dict:
    return {
        "collapsed": collapsed,
        "description": description,
        "id": _upper_uuid(),
        "isQuickInsightsSubPanel": False,
        "linkedSourceId": "",
        "name": name,
        "position": {
            "autoHeight": auto_height,
            "autoSize": True,
            "width": 100,
            "x": 0,
            "y": y_position,
        },
        "reportlet": reportlet,
        "swatchColor": swatch_color,
        "type": "genericSubPanel",
        "visible": True,
        "visualizationIndex": viz_index,
    }


# ── Public classes ─────────────────────────────────────────────────────────────

class TextBuilder:
    """
    Helper to build rich-text content in Quill Delta format for TextReportlets.

    Usage::

        tb = (
            TextBuilder()
            .addTitle("My Title")
            .addText("\\nSome plain text.\\n")
            .addBold("Important")
            .addNewline()
            .addColor("danger", "var(--spectrum-red-800)")
            .addNewline()
        )
        creator.addTextFreeform("My Panel", tb)
    """

    def __init__(self):
        self._ops: list = []

    # ── Builder methods ────────────────────────────────────────────────────

    def addTitle(self, text: str, level: int = 2) -> "TextBuilder":
        """
        Add a heading line.
        Arguments:
            text  : REQUIRED : Heading text.
            level : OPTIONAL : Heading level (1-6, default 2).
        """
        self._ops.append({"insert": text})
        self._ops.append({"attributes": {"header": level}, "insert": "\n"})
        return self

    def addText(self, text: str) -> "TextBuilder":
        """Add plain text."""
        self._ops.append({"insert": text})
        return self

    def addBold(self, text: str) -> "TextBuilder":
        """Add bold text."""
        self._ops.append({"attributes": {"bold": True}, "insert": text})
        return self

    def addItalic(self, text: str) -> "TextBuilder":
        """Add italic text."""
        self._ops.append({"attributes": {"italic": True}, "insert": text})
        return self

    def addUnderline(self, text: str) -> "TextBuilder":
        """Add underlined text."""
        self._ops.append({"attributes": {"underline": True}, "insert": text})
        return self

    def addColor(self, text: str, color: str) -> "TextBuilder":
        """
        Add colored text.
        Arguments:
            text  : REQUIRED : Text to colorize.
            color : REQUIRED : CSS color or variable, e.g. 'var(--spectrum-red-800)',
                    'var(--spectrum-celery-500)', 'var(--spectrum-blue-800)', or any hex/named color.
        """
        self._ops.append({"attributes": {"color": color}, "insert": text})
        return self

    def addLink(self, text: str, url: str) -> "TextBuilder":
        """
        Add a hyperlink.
        Arguments:
            text : REQUIRED : Display text.
            url  : REQUIRED : URL the link points to.
        """
        self._ops.append({"attributes": {"link": url}, "insert": text})
        return self

    def addNewline(self) -> "TextBuilder":
        """Add a newline character."""
        self._ops.append({"insert": "\n"})
        return self

    # ── Serialisation ──────────────────────────────────────────────────────

    def to_json(self) -> str:
        """Return Quill Delta JSON string (for the textContent field)."""
        return json.dumps({"ops": self._ops})

    def to_dict(self) -> dict:
        """Return Quill Delta as a dict."""
        return {"ops": deepcopy(self._ops)}

    def __str__(self) -> str:
        return self.to_json()

    def __repr__(self) -> str:
        return self.to_json()


class WorkspaceManager:
    """
    Programmatically compose Adobe Analytics Workspace projects.

    Supports two main sub-panel types:

    * **Text freeform** – rich formatted text via :class:`TextBuilder`.
    * **Data freeform table** – created with :meth:`addSimpleFreeform` using either a
      dimension (dynamic top-*N* rows), a list of segments/dimension items (static rows),
      or a single dimension item/segment.  An optional :meth:`addBreakdownFreeform` call
      immediately after adds a breakdown inside the same table.

    Typical usage::

        wc = (
            WorkspaceManager("myrsid", "My Project", rsid_name="My Report Suite")
            .addPanel("Overview", date_range_id="thisMonth")
            .addSegmentFilter("s1234_abc", "My Segment")
            .addTextFreeform(
                "Introduction",
                TextBuilder().addTitle("Hello").addText("\\nSome description.\\n"),
            )
            # Dimension → dynamic top-10 table, then broken down by a second dimension
            .addSimpleFreeform(
                "Activity × Experience",
                item_id="variables/targetraw.activity",
                item_name="Target Activities",
                metrics=[
                    {"id": "metrics/visits", "name": "Visits"},
                    {"id": "metrics/occurrences", "name": "Occurrences"},
                ],
            )
            .addBreakdownFreeform(
                breakdown_id="variables/targetraw.experience",
                breakdown_name="Target Experiences",
            )
            # Segment static rows, broken down by a dimension
            .addSimpleFreeform(
                "Device Comparison",
                items=[
                    {"id": "s4222_mobile",  "name": "Mobile"},
                    {"id": "s4222_desktop", "name": "Desktop"},
                ],
                metrics=[{"id": "metrics/visits", "name": "Visits"}],
            )
            .addBreakdownFreeform(
                breakdown_id="variables/evar8",
                breakdown_name="Market Code",
            )
        )
        project_dict = wc.to_dict()
    """

    DEFAULT_COLOR_SCHEME = [
        "#26C0C7", "#5151D3", "#E68619", "#D83790", "#908DFA", "#58E06F",
        "#2780EB", "#6F38B1", "#DFBF03", "#CB6F10", "#268D6C", "#9BEC54",
        "#5EABFA", "#BE40CC", "#F56BB7", "#FEE02D",
    ]

    def __init__(
        self,
        rsid: str = None,
        name: str = None,
        description: str = "",
        rsid_name: str = "",
        data: Union[str, dict] = None,
        tags: List[Union[int, dict]] = None,
        shares: List[dict] = None,
        config=None,
        companyId=None,
        analytics=None,
    ) -> None:
        """
        Arguments:
            rsid        : OPTIONAL : Report suite ID. Required when not loading from ``data``.
            name        : OPTIONAL : Workspace project name. Required when not loading from ``data``.
            description : OPTIONAL : Project description.
            rsid_name   : OPTIONAL : Human-readable report suite label.
            data        : OPTIONAL : Existing project definition to load from. Can be:

                * ``dict`` – a project definition as returned by the API.
                * ``str`` ending in ``.json`` – path to a JSON file.
                * raw JSON ``str``.

                When provided, ``rsid``, ``name``, ``description``, and ``rsid_name``
                override the values found in the definition when explicitly supplied.
            tags        : OPTIONAL : List of tags to associate with the project. Each item
                          may be:

                * ``int`` – tag ID only, e.g. ``[42, 99]``.
                * ``dict`` – full tag object, e.g. ``{"id": 42, "name": "prod"}``.

                When loading from ``data``, existing tags are preserved and these are
                added on top.
            shares      : OPTIONAL : List of share objects. Each dict must contain at
                          minimum ``"shareToType"`` (``"user"``, ``"group"``, or ``"all"``)
                          and, for ``"user"``/``"group"``, a ``"shareToId"`` (int). Example::

                              [{"shareToId": 622291, "shareToType": "user"}]
            analytics   : OPTIONAL : An :class:`aanalytics2.Analytics` instance used to
                          auto-resolve component names from IDs (and vice-versa).  When
                          provided, every ``add*`` method accepts a component ``id`` **or**
                          ``name`` — the missing half is looked up automatically via
                          ``getMetrics``, ``getCalculatedMetrics``, ``getDimensions``, or
                          ``getSegments``.  Lookups are cached so each API is called at most
                          once per session.  Without this parameter both ``id`` and ``name``
                          must be supplied.
            config      : OPTIONAL : Configuration object returned by ``importConfigFile`` or
                          ``configure`` when called with ``return_object=True``.  Carries
                          connection settings (company, endpoint, …) but does **not** expose
                          the Analytics API methods.
            companyId   : OPTIONAL : Company ID to associate with the Adobe Analytics.
                            Required if not using the "analytics" argument.
            
        """
        self._panels: List[dict] = []
        self._current_panel_index: int = -1
        self._workspace_id: str = _upper_uuid()
        self._workspace_name: str = ""
        self._definition_meta: dict = None
        self._tags: List[dict] = []
        self._shares: List[dict] = []
        if analytics is None and (config is None or companyId is None):
            raise ValueError("Either 'analytics' or both 'config' and 'companyId' must be provided.")
        self._config = config
        # Analytics instance for name/ID auto-resolution (optional)
        if analytics is None and config is not None:
            from aanalytics2 import Analytics
            self._analytics = Analytics(company_id=companyId,config=config)
        else:
            self._analytics = analytics
        self._dim_df = None        # lazily populated by _dim_list()
        self._metric_df = None     # lazily populated by _metric_list()
        self._calc_metric_df = None  # lazily populated by _calc_metric_list()
        self.users_cache = {}  # Cache for user ID → name/login lookups
        # Normalise the owner argument
        userMe = self._analytics.getUserMe() if self._analytics else None
        self.owner: dict = {"id": userMe["loginId"], "name": userMe.get("name", ""), "login": userMe.get("login", "")} if userMe else {"id": None, "name": "", "login": ""}
        # Normalise tags
        if tags:
            for t in tags:
                if isinstance(t, int):
                    self._tags.append({"id": t})
                else:
                    self._tags.append(dict(t))

        # Normalise shares
        if shares:
            for s in shares:
                self._shares.append(dict(s))

        if data is not None:
            if isinstance(data, str):
                if data.strip().endswith('.json'):
                    with open(data, 'r') as f:
                        data = json.load(f)
                else:
                    data = json.loads(data)
            data = deepcopy(data)
            self.rsid = rsid or data.get("rsid", "")
            self.name = name or data.get("name", "")
            self.description = description or data.get("description", "")
            self.rsid_name = rsid_name or data.get("reportSuiteName", self.rsid)
            # Preserve tags/shares from loaded project; constructor-supplied ones extend them
            for t in data.get("tags", []):
                if not any(existing.get("id") == t.get("id") for existing in self._tags):
                    self._tags.append(dict(t))
            for s in data.get("shares", []):
                self._shares.append(dict(s))
            defn = data.get("definition", {})
            workspaces = defn.get("workspaces", [{}])
            ws0 = workspaces[0] if workspaces else {}
            self._workspace_id = ws0.get("id", _upper_uuid())
            self._workspace_name = ws0.get("name", "")
            existing_panels = ws0.pop("panels", [])
            self._definition_meta = defn
            for p in existing_panels:
                try:
                    panel = deepcopy(p)
                    # A standard panel must have a position dict; if not, treat as opaque
                    if not isinstance(panel.get("position"), dict):
                        raise ValueError("Non-standard panel structure: missing 'position'.")
                    panel["_yOffset"] = sum(
                        sp.get("position", {}).get("autoHeight", 0)
                        for sp in panel.get("subPanels", [])
                    )
                    panel["_vizIndex"] = len(panel.get("subPanels", [])) + 1
                    # Ensure mutable keys exist even if absent in the source
                    panel.setdefault("subPanels", [])
                    panel.setdefault("segmentGroups", [])
                    self._panels.append(panel)
                except Exception:
                    # Unrecognised panel structure: preserve raw definition
                    self._panels.append({
                        "_opaque": True,
                        "_raw": deepcopy(p),
                        "_yOffset": 0,
                        "_vizIndex": 1,
                    })
            if self._panels:
                self._current_panel_index = len(self._panels) - 1
        else:
            if not rsid:
                raise ValueError("rsid is required when not loading from data.")
            if not name:
                raise ValueError("name is required when not loading from data.")
            self.rsid = rsid
            self.name = name
            self.description = description
            self.rsid_name = rsid_name or rsid

    # ── Panel management ───────────────────────────────────────────────────

    def addPanel(
        self,
        name: str,
        date_range_id: str = "thisMonth",
        date_range_name: str = "This month",
        position: int = None,
        collapsed: bool = False,
        description: str = "",
    ) -> "WorkspaceManager":
        """
        Add a new panel to the workspace.  All subsequent ``add*`` calls
        target the most recently added panel.

        Arguments:
            name            : REQUIRED : Panel title.
            date_range_id   : OPTIONAL : Date-range preset ID (default: ``"thisMonth"``) **or** a
                              custom ISO 8601 interval string such as
                              ``"2026-04-22T00:00:00/2026-05-07T23:59:59"``.
                              When the value contains ``"/"`` it is treated as a custom interval
                              and placed in ``__metaData__.definition`` instead of ``id``.
            date_range_name : OPTIONAL : Human-readable label for the date range.
            position        : OPTIONAL : Zero-based index at which to insert the panel.
                              Defaults to appending at the end.
            collapsed       : OPTIONAL : Whether the panel is collapsed (default ``False``).
            description     : OPTIONAL : Panel description (default ``""``).
        """
        # Custom date ranges (ISO 8601 intervals like "2026-04-22T00:00:00/2026-05-07T23:59:59")
        # must be expressed via __metaData__.definition rather than an id, because the API
        # tries to resolve an id as a stored resource and returns 404.
        if "/" in date_range_id:
            date_range_obj = {
                "__entity__": True,
                "type": "DateRange",
                "__metaData__": {"definition": date_range_id},
            }
        else:
            date_range_obj = {
                "id": date_range_id,
                "__entity__": True,
                "type": "DateRange",
                "__metaData__": {"name": date_range_name},
            }
        panel = {
            "collapsed": collapsed,
            "dateRange": date_range_obj,
            "datesAreRelativeToPanel": False,
            "description": description,
            "id": _upper_uuid(),
            "name": name,
            "position": {"autoHeight": 0, "autoSize": True, "width": 100, "x": 0, "y": 0},
            "reportSuite": {
                "id": self.rsid,
                "__entity__": True,
                "type": "ReportSuite",
                "__metaData__": {"name": self.rsid_name, "rsid": self.rsid},
            },
            "segmentGroups": [],
            "subPanels": [],
            "type": "panel",
            # internal tracking – stripped by to_dict()
            "_yOffset": 0,
            "_vizIndex": 1,
        }
        if position is None or position >= len(self._panels):
            self._panels.append(panel)
            self._current_panel_index = len(self._panels) - 1
        else:
            insert_at = max(0, position)
            self._panels.insert(insert_at, panel)
            self._current_panel_index = insert_at
        return self

    def _current_panel(self) -> dict:
        if not self._panels or self._current_panel_index < 0:
            raise ValueError("No panel exists. Call addPanel() first.")
        return self._panels[self._current_panel_index]

    def addSegmentFilter(
        self, segment_id: str = None, segment_name: str = ""
    ) -> "WorkspaceManager":
        """
        Add a segment (global filter) to the current panel.

        When *analytics* is set on the :class:`WorkspaceManager`, passing only
        one of the two arguments is sufficient — the other is resolved
        automatically.

        Arguments:
            segment_id   : OPTIONAL : Segment ID.  Required when *analytics* is not set.
            segment_name : OPTIONAL : Display name for the segment.
        """
        if not segment_id and not segment_name:
            raise ValueError("Either segment_id or segment_name must be provided.")
        segment_id, segment_name = self._resolve_seg_pair(segment_id or "", segment_name)
        panel = self._current_panel()
        if panel.get("_opaque"):
            raise ValueError(
                "The current panel has an unrecognised structure and cannot be modified. "
                "Call addPanel() to create a new panel first."
            )
        panel["segmentGroups"].append(
            {
                "componentOptions": [
                    {
                        "component": {
                            "id": segment_id,
                            "__entity__": True,
                            "type": "Segment",
                            "__metaData__": {"name": segment_name},
                        },
                        "isActive": True,
                    }
                ],
                "groupName": "",
                "hasNoFilter": False,
                "runAs": "segment",
                "showGroupName": True,
            }
        )
        return self

    # ── Sub-panel helpers ──────────────────────────────────────────────────

    def _next_swatch(self, panel: dict) -> str:
        if panel.get("_opaque"):
            raise ValueError(
                "The current panel has an unrecognised structure and cannot be modified. "
                "Call addPanel() to create a new panel first."
            )
        return _SWATCH_COLORS[len(panel["subPanels"]) % len(_SWATCH_COLORS)]

    def _append_subpanel(self, panel: dict, name: str, reportlet: dict, auto_height: int,
                         collapsed: bool = False, description: str = "") -> None:
        sub = _sub_panel(
            name=name,
            reportlet=reportlet,
            y_position=panel["_yOffset"],
            auto_height=auto_height,
            swatch_color=self._next_swatch(panel),
            viz_index=panel["_vizIndex"],
            collapsed=collapsed,
            description=description,
        )
        panel["subPanels"].append(sub)
        panel["_yOffset"] += auto_height
        panel["_vizIndex"] += 1

    def _find_last_freeform_table(
        self, panel: dict, source: Union[int, str] = None
    ) -> dict:
        """Return the mutable ``freeformTable`` dict of the last (or specified) ``FreeformReportlet``."""
        subpanels = panel.get("subPanels", [])
        if source is None:
            for sp in reversed(subpanels):
                r = sp.get("reportlet", {})
                if r.get("type") == "FreeformReportlet":
                    return r["freeformTable"]
        elif isinstance(source, int):
            r = subpanels[source].get("reportlet", {})
            if r.get("type") == "FreeformReportlet":
                return r["freeformTable"]
        elif isinstance(source, str):
            for sp in reversed(subpanels):
                r = sp.get("reportlet", {})
                if sp.get("name") == source and r.get("type") == "FreeformReportlet":
                    return r["freeformTable"]
        return None

    # ── Config resolution helpers ──────────────────────────────────────────

    def _metric_list(self):
        """Lazily load and cache the standard metrics for this report suite."""
        if self._metric_df is None:
            if self._analytics is None:
                raise ValueError(
                    "An Analytics instance is required to auto-resolve metric names. "
                    "Pass analytics=<Analytics instance> to WorkspaceManager."
                )
            self._metric_df = self._analytics.getMetrics(self.rsid)
        return self._metric_df

    def _calc_metric_list(self):
        """Lazily load and cache calculated metrics."""
        if self._calc_metric_df is None:
            if self._analytics is None:
                raise ValueError(
                    "An Analytics instance is required to auto-resolve calculated metric names. "
                    "Pass analytics=<Analytics instance> to WorkspaceManager."
                )
            self._calc_metric_df = self._analytics.getCalculatedMetrics()
        return self._calc_metric_df

    def _dim_list(self):
        """Lazily load and cache dimensions for this report suite."""
        if self._dim_df is None:
            if self._analytics is None:
                raise ValueError(
                    "An Analytics instance is required to auto-resolve dimension names. "
                    "Pass analytics=<Analytics instance> to WorkspaceManager."
                )
            self._dim_df = self._analytics.getDimensions(self.rsid)
        return self._dim_df

    def _resolve_metric_pair(self, mid: str, mname: str) -> tuple:
        """Return a resolved ``(id, name)`` tuple for a standard or calculated metric.

        Exactly one of *mid* / *mname* may be absent — the missing half is looked
        up via the Analytics API and cached locally.  When both are present the
        tuple is returned unchanged (no API call).
        """
        if mid and mname:
            return mid, mname
        if mid:
            df = self._metric_list() if mid.startswith("metrics/") else self._calc_metric_list()
            row = df[df["id"] == mid]
            return mid, (row["name"].iloc[0] if not row.empty else mid)
        # name-only: search standard metrics first, then calculated
        df = self._metric_list()
        row = df[df["name"].str.lower() == mname.lower()]
        if not row.empty:
            return row["id"].iloc[0], mname
        df = self._calc_metric_list()
        row = df[df["name"].str.lower() == mname.lower()]
        if not row.empty:
            return row["id"].iloc[0], mname
        raise ValueError(
            f"Metric '{mname}' not found in report suite '{self.rsid}'. "
            "Check the name spelling or provide the metric ID directly."
        )

    def _resolve_dim_pair(self, did: str, dname: str) -> tuple:
        """Return a resolved ``(id, name)`` tuple for a dimension."""
        if did and dname:
            return did, dname
        if did:
            df = self._dim_list()
            row = df[df["id"] == did]
            return did, (row["name"].iloc[0] if not row.empty else did)
        # name-only
        df = self._dim_list()
        row = df[df["name"].str.lower() == dname.lower()]
        if not row.empty:
            return row["id"].iloc[0], dname
        raise ValueError(
            f"Dimension '{dname}' not found in report suite '{self.rsid}'. "
            "Check the name spelling or provide the dimension ID directly."
        )

    def _resolve_seg_pair(self, sid: str, sname: str) -> tuple:
        """Return a resolved ``(id, name)`` tuple for a segment.

        When only *sname* is given the API is queried with a name filter.
        When only *sid* is given the name is not looked up — the ID is used as
        the display label, which is acceptable because Adobe resolves the real
        name at render time.
        When *analytics* is ``None`` the pair is returned as-is (no-op).
        """
        if sid and sname:
            return sid, sname
        if not sid and not sname:
            raise ValueError("Either a segment ID or segment name must be provided.")
        if self._analytics is None:
            return sid or "", sname or ""
        if sname and not sid:
            segs = self._analytics.getSegments(name=sname)
            if hasattr(segs, "empty") and not segs.empty:
                row = segs[segs["name"].str.lower() == sname.lower()]
                if not row.empty:
                    return row["id"].iloc[0], sname
            raise ValueError(
                f"Segment '{sname}' not found. "
                "Check the name spelling or provide the segment ID directly."
            )
        # id-only: use whatever name was given (Adobe resolves the real name at render time)
        return sid, sname or sid

    def _normalize_metrics(self, metrics: List[dict]) -> List[dict]:
        """Ensure every metric dict has both ``"id"`` and ``"name"``.

        When *analytics* is ``None`` the list is returned unchanged.
        """
        if not self._analytics:
            return metrics
        result = []
        for m in metrics:
            mid, mname = self._resolve_metric_pair(m.get("id") or "", m.get("name") or "")
            result.append({**m, "id": mid, "name": mname})
        return result

    def _normalize_item(self, item_id: str, item_name: str) -> tuple:
        """Resolve a ``(id, name)`` pair for any component (Dimension, DimensionItem, or Segment).

        Component type is auto-detected from the ID when present; when only a
        name is given dimensions are searched before segments.
        When *analytics* is ``None`` the pair is returned as-is (no-op).
        """
        if item_id and item_name:
            return item_id, item_name
        if not self._analytics:
            return item_id or "", item_name or ""
        comp_type = _detect_component_type(item_id) if item_id else None
        if comp_type == "Dimension":
            return self._resolve_dim_pair(item_id, item_name)
        if comp_type == "DimensionItem":
            # DimensionItem values cannot be looked up generically — graceful fallback
            return item_id, (item_name or item_id.split("::")[-1])
        if comp_type == "Segment" and item_id:
            return self._resolve_seg_pair(item_id, item_name)
        # No ID provided: search dimensions first, then segments
        try:
            return self._resolve_dim_pair("", item_name)
        except ValueError:
            return self._resolve_seg_pair("", item_name)

    def _normalize_items(self, items: List[dict]) -> List[dict]:
        """Resolve missing ``id``/``name`` in every item dict.

        When *analytics* is ``None`` the list is returned unchanged.
        """
        if not self._analytics:
            return items
        result = []
        for it in items:
            iid, iname = self._normalize_item(it.get("id") or "", it.get("name") or "")
            result.append({**it, "id": iid, "name": iname})
        return result

    # ── Public add* methods ────────────────────────────────────────────────

    def addTextFreeform(
        self,
        title: str,
        content: Union[str, "TextBuilder", dict],
        collapsed: bool = False,
        description: str = "",
    ) -> "WorkspaceManager":
        """
        Add a text freeform to the current panel.

        Arguments:
            title       : REQUIRED : Sub-panel title.
            content     : REQUIRED : Content as one of:

                * :class:`TextBuilder` instance  (recommended)
                * ``dict`` with ``"ops"`` key (raw Quill Delta)
                * JSON string (raw Quill Delta)
                * plain ``str`` (rendered as-is)

            collapsed   : OPTIONAL : Whether the sub-panel starts collapsed (default ``False``).
            description : OPTIONAL : Sub-panel description (default ``""``).
        """
        panel = self._current_panel()
        if isinstance(content, TextBuilder):
            text_content = content.to_json()
        elif isinstance(content, dict):
            text_content = json.dumps(content)
        elif isinstance(content, str):
            try:
                json.loads(content)
                text_content = content  # already valid Quill Delta JSON
            except json.JSONDecodeError:
                text_content = json.dumps({"ops": [{"insert": content + "\n"}]})
        else:
            text_content = json.dumps({"ops": [{"insert": str(content) + "\n"}]})

        self._append_subpanel(panel, title, _text_reportlet(title, text_content),
                              auto_height=321, collapsed=collapsed, description=description)
        return self

    def addSimpleFreeform(
        self,
        title: str,
        item_id: str = None,
        item_name: str = "",
        items: List[Dict] = None,
        metrics: List[Dict] = None,
        rows: int = 10,
        collapsed: bool = False,
        description: str = "",
    ) -> "WorkspaceManager":
        """
        Add a freeform table to the current panel.

        Pass either a single ``item_id`` + ``item_name`` **or** a list of ``items``:

        * **Single item** (``item_id`` / ``item_name``) — type is inferred from the ID:

          * ``"variables/..."`` (no ``"::"``) → **Dimension** → dynamic top-*N* table.
          * ``"variables/...::hash"`` (with ``"::"``) → **DimensionItem** → single static row.
          * Anything else → **Segment** → single static row.

        * **List of items** (``items``) — each dict needs ``"id"`` and ``"name"``; an optional
          ``"type"`` key (``"Segment"`` or ``"DimensionItem"``) overrides auto-detection.
          Produces a static-rows table (``totalsType="columnSum"``).

        To add a breakdown beneath the table, call :meth:`addBreakdownFreeform` immediately after.

        Arguments:
            title       : REQUIRED : Sub-panel title.
            item_id     : OPTIONAL : Single component ID.  One of ``item_id``, ``item_name``,
                          or ``items`` must be supplied.
            item_name   : OPTIONAL : Display label for ``item_id``.  When *analytics* is set,
                          passing only ``item_name`` (without ``item_id``) resolves the ID
                          automatically.
            items       : OPTIONAL : List of ``{"id": ..., "name": ..., "type"?: ...}`` dicts
                          to use as static rows.  When *analytics* is set, each dict may omit
                          ``"id"`` or ``"name"`` and the missing half is resolved automatically.
            metrics     : REQUIRED : List of metric dicts.  Each must have ``"id"`` and/or
                          ``"name"``.  When *analytics* is set, either key alone is sufficient
                          and the missing half is resolved automatically.
                          Supports ``"filters"`` — see :func:`_build_column_nodes`.
            rows        : OPTIONAL : Rows per page (default ``10``).
            collapsed   : OPTIONAL : Whether the sub-panel starts collapsed (default ``False``).
            description : OPTIONAL : Sub-panel description (default ``""``).

        Examples::

            # Dynamic dimension table — ID only, name resolved automatically
            wc.addSimpleFreeform(
                "Visits by Market",
                item_id="variables/evar8",
                metrics=[{"id": "metrics/visits"}],
            )

            # Name-only for both dimension and metric (requires analytics=)
            wc.addSimpleFreeform(
                "Visits by Market",
                item_name="Market Code",
                metrics=[{"name": "Visits"}],
            )

            # Segment rows — then chain a dimension breakdown
            wc.addSimpleFreeform(
                "Device Comparison",
                items=[
                    {"id": "s4222_mobile",  "name": "Mobile"},
                    {"id": "s4222_desktop", "name": "Desktop"},
                    {"id": "s4222_tablet",  "name": "Tablet"},
                ],
                metrics=[{"id": "metrics/occurrences", "name": "Occurrences"}],
            )
            wc.addBreakdownFreeform(breakdown_id="variables/evar8", breakdown_name="Market Code")

            # Single dimension item as a static row
            wc.addSimpleFreeform(
                "DE Market Only",
                item_id="variables/evar8::581609802",
                item_name="DE",
                metrics=[{"id": "metrics/visits", "name": "Visits"}],
            )
        """
        if not metrics:
            raise ValueError("At least one metric is required.")
        if not item_id and not item_name and items is None:
            raise ValueError("Either item_id, item_name, or items must be provided.")

        # Resolve missing IDs/names via Analytics API when available
        metrics = self._normalize_metrics(metrics)
        if items is not None:
            items = self._normalize_items(items)
        elif item_id or item_name:
            item_id, item_name = self._normalize_item(item_id or "", item_name or "")

        panel = self._current_panel()
        metric_nodes, first_metric_id = _build_column_nodes(metrics)
        col_tree = _column_tree(metric_nodes)

        if item_id:
            comp_type = _detect_component_type(item_id)
            if comp_type == "Dimension":
                ft = _build_freeform_table(
                    first_metric_id,
                    dim_id=item_id,
                    dim_name=item_name,
                    n_columns=len(metric_nodes) + 1,
                    view_by=rows,
                )
            else:
                row, _ = _make_static_row(item_id, item_name, comp_type)
                ft = _build_freeform_table(
                    first_metric_id,
                    static_rows=[row],
                    n_columns=len(metric_nodes) + 1,
                    view_by=rows,
                )
        else:
            static_rows = [
                _make_static_row(it["id"], it["name"], it.get("type"))[0]
                for it in items
            ]
            ft = _build_freeform_table(
                first_metric_id,
                static_rows=static_rows,
                n_columns=len(metric_nodes) + 1,
                view_by=rows,
            )

        self._append_subpanel(
            panel, title, _freeform_reportlet(title, col_tree, ft),
            auto_height=528, collapsed=collapsed, description=description,
        )
        return self

    def addBreakdownFreeform(
        self,
        breakdown_id: str = None,
        breakdown_name: str = "",
        breakdown_items: List[Dict] = None,
        breakdown_rows: int = 5,
        source: Union[int, str] = None,
    ) -> "WorkspaceManager":
        """
        Add a breakdown to the last (or specified) freeform table in the current panel.

        Can follow any :meth:`addSimpleFreeform` call, regardless of whether the table was
        built from a dimension, dimension items, or segment static rows.

        The breakdown component type is inferred from the ID — same rules as
        :meth:`addSimpleFreeform`:

        * ``"variables/..."`` → **Dimension** breakdown (each row expanded by that dimension).
        * ``"variables/...::hash"`` → **DimensionItem** static row in the breakdown.
        * Anything else → **Segment** static row in the breakdown.

        Use ``breakdown_items`` to supply a list of segments or dimension items as breakdown rows.

        Arguments:
            breakdown_id    : OPTIONAL : Single component ID for the breakdown.  When
                              *analytics* is set, ``breakdown_name`` alone is sufficient.
            breakdown_name  : OPTIONAL : Display label for ``breakdown_id``.  When *analytics*
                              is set, ``breakdown_id`` alone is sufficient.
            breakdown_items : OPTIONAL : List of ``{"id", "name", "type"?}`` dicts to use as
                              static rows in the breakdown.  When *analytics* is set, each dict
                              may omit ``"id"`` or ``"name"``.
            breakdown_rows  : OPTIONAL : Row count in the breakdown table (default ``5``).
            source          : OPTIONAL : Which freeform table to target.

                * ``None`` (default) — last ``FreeformReportlet`` in the panel.
                * ``int`` — zero-based sub-panel index.
                * ``str`` — sub-panel name.

        Examples::

            # Dimension table → add dimension breakdown
            wc.addSimpleFreeform("Activities", item_id="variables/targetraw.activity",
                                 item_name="Target Activities", metrics=[...])
            wc.addBreakdownFreeform(breakdown_id="variables/targetraw.experience",
                                    breakdown_name="Target Experiences")

            # Segment rows → add dimension breakdown
            wc.addSimpleFreeform("Devices", items=[{"id": "s4222_mobile", "name": "Mobile"}, ...],
                                 metrics=[...])
            wc.addBreakdownFreeform(breakdown_id="variables/evar8", breakdown_name="Market Code")

            # Any freeform → add segment rows as breakdown
            wc.addBreakdownFreeform(breakdown_items=[
                {"id": "s4222_mobile",  "name": "Mobile"},
                {"id": "s4222_desktop", "name": "Desktop"},
            ])
        """
        if breakdown_id is None and breakdown_items is None:
            raise ValueError("Either breakdown_id or breakdown_items must be provided.")

        # Resolve missing IDs/names via Analytics API when available
        if breakdown_items is not None:
            breakdown_items = self._normalize_items(breakdown_items)
        elif breakdown_id is not None or breakdown_name:
            breakdown_id, breakdown_name = self._normalize_item(
                breakdown_id or "", breakdown_name or ""
            )

        panel = self._current_panel()
        ft = self._find_last_freeform_table(panel, source)
        if ft is None:
            raise ValueError(
                "No FreeformReportlet found in the current panel. "
                "Call addSimpleFreeform (or similar) first."
            )

        first_metric_id = ft.get("sort", {}).get("columnId", "")
        parent_ids = [ft["staticRows"][0]["id"]] if ft.get("staticRows") else []

        if breakdown_id is not None:
            comp_type = _detect_component_type(breakdown_id)
            if comp_type == "Dimension":
                bd_ft = _build_freeform_table(
                    first_metric_id,
                    dim_id=breakdown_id,
                    dim_name=breakdown_name,
                    n_columns=1,
                    view_by=breakdown_rows,
                    parent_item_ids=parent_ids,
                )
            else:
                row, _ = _make_static_row(breakdown_id, breakdown_name, comp_type)
                bd_ft = _build_freeform_table(
                    first_metric_id,
                    static_rows=[row],
                    n_columns=1,
                    view_by=breakdown_rows,
                    parent_item_ids=parent_ids,
                )
        else:
            bd_rows = [
                _make_static_row(it["id"], it["name"], it.get("type"))[0]
                for it in breakdown_items
            ]
            bd_ft = _build_freeform_table(
                first_metric_id,
                static_rows=bd_rows,
                n_columns=1,
                view_by=breakdown_rows,
                parent_item_ids=parent_ids,
            )

        ft["breakdowns"].append(bd_ft)
        return self

    def addActivityBreakdownFreeform(
        self,
        title: str,
        activity_dim_item_id: str,
        activity_name: str,
        breakdown_dim_id: str,
        breakdown_dim_name: str,
        metrics: List[Dict[str, str]],
        rows: int = 50,
        breakdown_rows: int = 5,
        collapsed: bool = False,
        description: str = "",
    ) -> "WorkspaceManager":
        """
        Add a freeform table that pins a specific dimension item as a static row
        and breaks it down by a secondary dimension.

        This is the standard pattern for Target activity reports: the activity is
        a static row (``DimensionItem``) and each experience is shown as a
        breakdown row underneath it.

        Arguments:
            title                : REQUIRED : Sub-panel title.
            activity_dim_item_id : REQUIRED : Dimension item ID for the static row.
                                   Use double-colon notation,
                                   e.g. ``'variables/targetraw.activity::179567382'``.
            activity_name        : REQUIRED : Display name for the activity item.
            breakdown_dim_id     : REQUIRED : Breakdown dimension ID,
                                   e.g. ``'variables/targetraw.experience'``.
            breakdown_dim_name   : REQUIRED : Breakdown dimension label.
            metrics              : REQUIRED : List of ``{"id": ..., "name": ...}`` dicts.
            rows                 : OPTIONAL : Rows for the static-row table (default ``50``).
            breakdown_rows       : OPTIONAL : Rows for the breakdown (default ``5``).
            collapsed            : OPTIONAL : Whether the sub-panel starts collapsed.
            description          : OPTIONAL : Sub-panel description.
        """
        if not metrics:
            raise ValueError("At least one metric is required.")
        if ':::' in activity_dim_item_id:
            activity_dim_item_id = activity_dim_item_id.replace(":::", "::")
        # Resolve missing IDs/names via Analytics API when available
        metrics = self._normalize_metrics(metrics)
        breakdown_dim_id, breakdown_dim_name = self._resolve_dim_pair(
            breakdown_dim_id or "", breakdown_dim_name or ""
        ) if self._analytics and (breakdown_dim_id or breakdown_dim_name) else (breakdown_dim_id, breakdown_dim_name)
        panel = self._current_panel()
        metric_nodes, first_metric_id = _build_column_nodes(metrics)
        col_tree = _column_tree(metric_nodes)
        static_row, static_row_id = _make_static_row(activity_dim_item_id, activity_name, "DimensionItem")
        bd_ft = _build_freeform_table(
            first_metric_id,
            dim_id=breakdown_dim_id,
            dim_name=breakdown_dim_name,
            n_columns=1,
            view_by=breakdown_rows,
            parent_item_ids=[static_row_id],
        )
        ft = _build_freeform_table(
            first_metric_id,
            static_rows=[static_row],
            breakdowns=[bd_ft],
            n_columns=len(metric_nodes) + 1,
            view_by=rows,
        )
        self._append_subpanel(
            panel, title, _freeform_reportlet(title, col_tree, ft),
            auto_height=613, collapsed=collapsed, description=description,
        )
        return self

    def addSegmentAsDimensionFreeform(
        self,
        title: str,
        segments: List[Dict[str, str]],
        metrics: List[Dict[str, str]],
        breakdown_dim_id: str = None,
        breakdown_dim_name: str = "",
        breakdown_segments: List[Dict[str, str]] = None,
        breakdown_rows: int = 5,
        rows: int = 50,
        collapsed: bool = False,
        description: str = "",
    ) -> "WorkspaceManager":
        """
        Add a freeform table where **segments are used as rows** (no dimension).

        This is the "Segment as Dimension" Workspace pattern — each segment
        becomes a static row in the table, and the table reports metric values
        for each segment side-by-side.

        An optional breakdown can be applied beneath each segment row:

        * ``breakdown_dim_id`` / ``breakdown_dim_name`` — break each segment row
          down by a dimension (e.g. Market Code).  The breakdown table uses
          ``totalsType="allVisits"``.
        * ``breakdown_segments`` — break each segment row down by a second list
          of segments (nested segment rows).  The breakdown table also uses
          ``totalsType="columnSum"``.

        Only one breakdown type should be provided at a time.

        Arguments:
            title                : REQUIRED : Sub-panel title.
            segments             : REQUIRED : List of segment dicts, each with ``"id"`` and
                                   ``"name"``.  The order determines the row order.
            metrics              : REQUIRED : List of metric dicts.  Each must have ``"id"`` and
                                   ``"name"``.  The optional ``"filters"`` key is supported — see
                                   :func:`_build_column_nodes`.
            breakdown_dim_id     : OPTIONAL : Dimension ID to break each segment row down by
                                   (e.g. ``"variables/evar8"``).  Requires ``breakdown_dim_name``.
            breakdown_dim_name   : OPTIONAL : Display name for the breakdown dimension.
            breakdown_segments   : OPTIONAL : List of segment dicts (``"id"`` + ``"name"``) to
                                   use as nested segment rows in the breakdown.
            breakdown_rows       : OPTIONAL : Row count for the breakdown (default ``5``).
            rows                 : OPTIONAL : ``viewBy`` pagination size (default ``50``).
            collapsed            : OPTIONAL : Whether the sub-panel starts collapsed (default ``False``).
            description          : OPTIONAL : Sub-panel description (default ``""``).

        Examples::

            # Plain segment rows
            wc.addSegmentAsDimensionFreeform(
                "Device Comparison",
                segments=[
                    {"id": "s4222_mobile",  "name": "Mobile"},
                    {"id": "s4222_desktop", "name": "Desktop"},
                    {"id": "s4222_tablet",  "name": "Tablet"},
                ],
                metrics=[{"id": "metrics/occurrences", "name": "Occurrences"}],
            )

            # Segment rows broken down by a dimension
            wc.addSegmentAsDimensionFreeform(
                "Device × Market",
                segments=[{"id": "s4222_mobile", "name": "Mobile"}],
                metrics=[{"id": "metrics/visits", "name": "Visits"}],
                breakdown_dim_id="variables/evar8",
                breakdown_dim_name="Market Code",
            )

            # Segment rows broken down by further segments
            wc.addSegmentAsDimensionFreeform(
                "Device × Browser",
                segments=[{"id": "s4222_mobile", "name": "Mobile"}],
                metrics=[{"id": "metrics/visits", "name": "Visits"}],
                breakdown_segments=[
                    {"id": "s4222_chrome",  "name": "Chrome"},
                    {"id": "s4222_safari",  "name": "Safari"},
                ],
            )
        """
        if not segments:
            raise ValueError("At least one segment is required.")
        if not metrics:
            raise ValueError("At least one metric is required.")
        # Resolve missing IDs/names via Analytics API when available
        segments = self._normalize_items(segments)
        metrics = self._normalize_metrics(metrics)
        if self._analytics and (breakdown_dim_id or breakdown_dim_name):
            breakdown_dim_id, breakdown_dim_name = self._resolve_dim_pair(
                breakdown_dim_id or "", breakdown_dim_name or ""
            )
        if breakdown_segments is not None:
            breakdown_segments = self._normalize_items(breakdown_segments)
        panel = self._current_panel()
        metric_nodes, first_metric_id = _build_column_nodes(metrics)
        col_tree = _column_tree(metric_nodes)

        # Build static rows and remember the first row's ID for parentItemIds
        seg_rows_with_ids = [_make_static_row(s["id"], s["name"]) for s in segments]
        static_rows = [row for row, _ in seg_rows_with_ids]
        first_seg_id = seg_rows_with_ids[0][1] if seg_rows_with_ids else ""

        # Build breakdown if requested
        breakdowns = []
        if breakdown_dim_id:
            bd_ft = _build_freeform_table(
                first_metric_id,
                dim_id=breakdown_dim_id,
                dim_name=breakdown_dim_name,
                n_columns=1,
                view_by=breakdown_rows,
                parent_item_ids=[first_seg_id],
            )
            breakdowns = [bd_ft]
        elif breakdown_segments:
            bd_seg_rows = [_make_static_row(s["id"], s["name"])[0] for s in breakdown_segments]
            bd_ft = _build_freeform_table(
                first_metric_id,
                static_rows=bd_seg_rows,
                n_columns=1,
                view_by=breakdown_rows,
                parent_item_ids=[first_seg_id],
            )
            breakdowns = [bd_ft]

        ft = _build_freeform_table(
            first_metric_id,
            static_rows=static_rows,
            breakdowns=breakdowns,
            n_columns=len(metric_nodes) + 1,
            view_by=rows,
        )
        self._append_subpanel(
            panel, title, _freeform_reportlet(title, col_tree, ft),
            auto_height=290 if not breakdowns else 512, collapsed=collapsed, description=description,
        )
        return self

    def addDropdownFilter(
        self,
        group_name: str,
        components: List[Dict],
        has_no_filter: bool = True,
    ) -> "WorkspaceManager":
        """
        Add a **dropdown filter group** to the current panel.

        Dropdown filter groups appear as switchable dropdowns at the top of the
        panel in the Workspace UI.  They let viewers filter all visualisations in
        the panel by a segment or a specific dimension-item value — and are the
        mechanism behind "segment comparison"-style panels.

        Multiple groups can be added by calling this method repeatedly.  Each
        group becomes its own independent dropdown.

        Arguments:
            group_name    : REQUIRED : Label shown for the dropdown (e.g. ``"Brand"``).
            components    : REQUIRED : List of filter-option dicts.  Each dict must
                            contain at least ``"id"`` and ``"name"``.  Optional keys:

                * ``"type"``     – ``"Segment"`` *(default)* or ``"DimensionItem"``.
                * ``"isActive"`` – ``True`` / ``False`` (first component is active by
                  default; at most one item should be ``True`` per group).

            has_no_filter : OPTIONAL : Whether a *"No filter"* option is available
                            (default ``True``).  Set to ``False`` to force one
                            component to always be active.

        Examples::

            # Compare two brands using a DimensionItem dropdown
            wc.addDropdownFilter(
                "Brand",
                components=[
                    {"id": "variables/evar1::1111111111", "type": "DimensionItem", "name": "brand_a",  "isActive": True},
                    {"id": "variables/evar1::2222222222", "type": "DimensionItem", "name": "brand_b"},
                ],
                has_no_filter=True,
            )

            # Compare two segments (no "No filter" option)
            wc.addDropdownFilter(
                "Audience",
                components=[
                    {"id": "s1234_aaa", "type": "Segment", "name": "New Visitors", "isActive": True},
                    {"id": "s1234_bbb", "type": "Segment", "name": "Returning Visitors"},
                ],
                has_no_filter=False,
            )
        """
        panel = self._current_panel()
        if panel.get("_opaque"):
            raise ValueError(
                "The current panel has an unrecognised structure and cannot be modified. "
                "Call addPanel() to create a new panel first."
            )
        # Resolve missing IDs/names via Analytics API when available
        components = self._normalize_items(components)
        component_options = []
        for i, comp in enumerate(components):
            comp_type = comp.get("type", "Segment")
            component_options.append({
                "component": {
                    "id": comp["id"],
                    "__entity__": True,
                    "type": comp_type,
                    "__metaData__": {"name": comp.get("name", "")},
                },
                "isActive": bool(comp.get("isActive", i == 0)),
            })
        group: dict = {
            "componentOptions": component_options,
            "groupName": group_name,
            "hasNoFilter": has_no_filter,
            "showGroupName": True,
        }
        panel["segmentGroups"].append(group)
        return self

    def addSummaryNumber(
        self,
        title: str,
        metric_id: str = None,
        metric_name: str = None,
        show_change: bool = False,
        show_percent_change: bool = False,
        show_sparkline: bool = False,
        collapsed: bool = False,
        description: str = "",
    ) -> "WorkspaceManager":
        """
        Add a **Summary Number** (KPI card) visualization to the current panel.

        A Summary Number displays the grand-total value of a single metric.
        The Adobe API requires every ``SummaryNumberReportlet`` to be backed by a
        ``FreeformReportlet`` (linked via ``linkedSourceId``).  This method
        automatically creates that backing table — a day-grain freeform table
        containing the requested metric — immediately below the KPI card.

        Arguments:
            title               : REQUIRED : Sub-panel title.
            metric_id           : OPTIONAL : Metric ID (e.g. ``"metrics/visits"``).  Required
                                  when *analytics* is not set.
            metric_name         : OPTIONAL : Human-readable metric label.  Required when
                                  *analytics* is not set.
            show_change         : OPTIONAL : Reserved — not yet implemented for
                                  ``SummaryNumberReportlet`` (default ``False``).
            show_percent_change : OPTIONAL : Reserved — not yet implemented for
                                  ``SummaryNumberReportlet`` (default ``False``).
            show_sparkline      : OPTIONAL : Reserved — not yet implemented for
                                  ``SummaryNumberReportlet`` (default ``False``).
            collapsed           : OPTIONAL : Whether the KPI sub-panel starts collapsed
                                  (default ``False``).
            description         : OPTIONAL : KPI sub-panel description (default ``""``).

        Example::

            wc.addSummaryNumber(
                "Total Visits",
                metric_id="metrics/visits",
                metric_name="Visits",
            )

            # With analytics= set, name-only is enough:
            wc.addSummaryNumber("Total Visits", metric_name="Visits")
        """
        if not metric_id and not metric_name:
            raise ValueError("Either metric_id or metric_name must be provided.")
        metric_id, metric_name = self._resolve_metric_pair(metric_id or "", metric_name or "")
        panel = self._current_panel()
        if panel.get("_opaque"):
            raise ValueError(
                "The current panel has an unrecognised structure and cannot be modified. "
                "Call addPanel() to create a new panel first."
            )

        current_y = panel["_yOffset"]
        kpi_height = 200
        table_height = 300

        # ── backing freeform table (created first to capture its ID) ──────────
        metric_node = _metric_node(metric_id, metric_name)
        col_tree = _column_tree([metric_node])
        backing_reportlet = _freeform_reportlet(
            title=f"{title} (data)",
            column_tree=col_tree,
            freeform_table=_build_freeform_table(
                metric_node["id"],
                dim_id="daterangeday",
                dim_name="Day",
                n_columns=2,
            ),
        )
        # Pre-compute swatch indices before any append
        base_idx = len(panel["subPanels"])
        swatch_kpi   = _SWATCH_COLORS[base_idx % len(_SWATCH_COLORS)]
        swatch_table = _SWATCH_COLORS[(base_idx + 1) % len(_SWATCH_COLORS)]

        backing_sub = _sub_panel(
            name=f"{title} (data)",
            reportlet=backing_reportlet,
            y_position=current_y + kpi_height,
            auto_height=table_height,
            swatch_color=swatch_table,
            viz_index=panel["_vizIndex"] + 1,
        )
        backing_id = backing_sub["id"]

        # ── KPI card linked to the backing table ──────────────────────────────
        summary_sub = _sub_panel(
            name=title,
            reportlet=_summary_number_reportlet(title, metric_node["id"]),
            y_position=current_y,
            auto_height=kpi_height,
            swatch_color=swatch_kpi,
            viz_index=panel["_vizIndex"],
            collapsed=collapsed,
            description=description,
        )
        summary_sub["linkedSourceId"] = backing_id

        panel["subPanels"].append(summary_sub)
        panel["subPanels"].append(backing_sub)
        panel["_yOffset"] += kpi_height + table_height
        panel["_vizIndex"] += 2
        return self

    def addChart(
        self,
        viz_type: str,
        source: Union[str, int] = None,
        title: str = "",
        collapsed: bool = False,
        description: str = "",
    ) -> "WorkspaceManager":
        """
        Add a **chart visualization** to the current panel, linked to an existing
        freeform table.

        Chart visualizations (line, bar, donut, …) derive their data from a
        ``FreeformReportlet`` sub-panel via ``linkedSourceId``.

        Arguments:
            viz_type    : REQUIRED : Chart type shorthand.  Supported values:

                * ``"line"``
                * ``"bar"``
                * ``"bar_horizontal"``
                * ``"bar_stacked"``
                * ``"area"``
                * ``"area_stacked"``
                * ``"donut"``
                * ``"scatter"``
                * ``"treemap"``
                * ``"histogram"``
                * ``"bullet"``
                * ``"venn"``

            source      : OPTIONAL : The freeform table to link.

                * ``None`` *(default)* – links to the most recently added
                  ``FreeformReportlet`` in the current panel.
                * ``int`` – zero-based index of the sub-panel to link.
                * ``str`` – name of the sub-panel to link.

            title       : OPTIONAL : Sub-panel title (default ``""``).
            collapsed   : OPTIONAL : Whether the sub-panel starts collapsed
                          (default ``False``).
            description : OPTIONAL : Sub-panel description (default ``""``).

        Raises:
            ValueError : If ``viz_type`` is not a recognised shorthand.
            ValueError : If no matching source sub-panel can be found.

        Example::

            wc.addSimpleFreeform("Visits by Page", dimension_id="variables/page",
                                 dimension_name="Page", metrics=[{"id": "metrics/visits", "name": "Visits"}])
            wc.addChart("line", source="Visits by Page", title="Trend")
            wc.addChart("donut")   # links to the last freeform automatically
        """
        panel = self._current_panel()
        if panel.get("_opaque"):
            raise ValueError(
                "The current panel has an unrecognised structure and cannot be modified. "
                "Call addPanel() to create a new panel first."
            )

        reportlet_type = _VIZ_TYPE_MAP.get(viz_type.lower() if isinstance(viz_type, str) else "")
        if reportlet_type is None:
            raise ValueError(
                f"Unknown viz_type '{viz_type}'. Supported: {list(_VIZ_TYPE_MAP)}"
            )

        linked_id = ""
        if source is None:
            for sp in reversed(panel["subPanels"]):
                if sp.get("reportlet", {}).get("type") == "FreeformReportlet":
                    linked_id = sp["id"]
                    break
            if not linked_id:
                raise ValueError(
                    "No FreeformReportlet sub-panel found in the current panel to link to. "
                    "Add a freeform table first, or specify 'source' explicitly."
                )
        elif isinstance(source, int):
            if source < 0 or source >= len(panel["subPanels"]):
                raise IndexError(f"Sub-panel index {source} is out of range.")
            linked_id = panel["subPanels"][source]["id"]
        else:
            for sp in panel["subPanels"]:
                if sp.get("name") == source:
                    linked_id = sp["id"]
                    break
            if not linked_id:
                raise ValueError(f"No sub-panel named '{source}' found in the current panel.")

        reportlet = _chart_reportlet(reportlet_type, title)
        sub = _sub_panel(
            name=title,
            reportlet=reportlet,
            y_position=panel["_yOffset"],
            auto_height=300,
            swatch_color=self._next_swatch(panel),
            viz_index=panel["_vizIndex"],
            collapsed=collapsed,
            description=description,
        )
        sub["linkedSourceId"] = linked_id
        panel["subPanels"].append(sub)
        panel["_yOffset"] += 300
        panel["_vizIndex"] += 1
        return self

    def addBreakdownToDimension(
        self,
        breakdown_dim_id: str = None,
        breakdown_dim_name: str = None,
        target: Union[str, int] = "all",
        breakdown_rows: int = 5,
    ) -> "WorkspaceManager":
        """
        Inject a breakdown dimension into the ``freeformTable.breakdowns`` of one
        or all existing ``FreeformReportlet`` sub-panels in the current panel.

        This lets you enrich freeforms created with :meth:`addSimpleFreeform` (or
        loaded from an existing definition) without having to rebuild them.

        Arguments:
            breakdown_dim_id   : OPTIONAL : Breakdown dimension ID.  Required when *analytics*
                                 is not set.
            breakdown_dim_name : OPTIONAL : Breakdown dimension label.  Required when *analytics*
                                 is not set.  When *analytics* is set, either key alone is
                                 sufficient and the missing half is resolved automatically.
            target             : OPTIONAL : Which sub-panel(s) to target.

                * ``"all"`` *(default)* – apply to every ``FreeformReportlet`` in
                  the current panel.
                * ``int`` – zero-based index of the sub-panel to target.
                * ``str`` (other than ``"all"``) – name of the sub-panel to target.

            breakdown_rows : OPTIONAL : Rows per page for the breakdown (default ``5``).
        """
        if not breakdown_dim_id and not breakdown_dim_name:
            raise ValueError("Either breakdown_dim_id or breakdown_dim_name must be provided.")
        breakdown_dim_id, breakdown_dim_name = self._resolve_dim_pair(
            breakdown_dim_id or "", breakdown_dim_name or ""
        ) if self._analytics else (breakdown_dim_id or "", breakdown_dim_name or "")
        panel = self._current_panel()
        freeform_subs = [
            sp for sp in panel["subPanels"]
            if sp.get("reportlet", {}).get("type") == "FreeformReportlet"
        ]
        if not freeform_subs:
            raise ValueError("No FreeformReportlet sub-panels found in the current panel.")

        if target == "all":
            candidates = freeform_subs
        elif isinstance(target, int):
            if target < 0 or target >= len(panel["subPanels"]):
                raise IndexError(f"Sub-panel index {target} is out of range.")
            sp = panel["subPanels"][target]
            if sp.get("reportlet", {}).get("type") != "FreeformReportlet":
                raise ValueError(f"Sub-panel at index {target} is not a FreeformReportlet.")
            candidates = [sp]
        else:  # str name
            candidates = [sp for sp in panel["subPanels"]
                          if sp.get("name") == target
                          and sp.get("reportlet", {}).get("type") == "FreeformReportlet"]
            if not candidates:
                raise ValueError(f"No FreeformReportlet named '{target}' found in the current panel.")

        for sp in candidates:
            ft = sp["reportlet"]["freeformTable"]
            # derive first_metric_node_id from the existing columnTree
            nodes = sp["reportlet"]["columnTree"].get("nodes", [])
            first_metric_id = nodes[0]["id"] if nodes else "0"
            bd = _build_freeform_table(
                first_metric_id,
                dim_id=breakdown_dim_id,
                dim_name=breakdown_dim_name,
                n_columns=1,
                view_by=breakdown_rows,
                parent_item_ids=[],
            )
            ft["breakdowns"].append(bd)
        return self

    # ── Project metadata ───────────────────────────────────────────────────

    def setOwner(self, owner: Union[int, dict]) -> "WorkspaceManager":
        """
        Set (or replace) the project owner. Fluent — returns ``self``.

        Arguments:
            owner : REQUIRED : Either an ``int`` IMS user ID or a ``dict``
                    with keys ``"id"``, ``"name"``, ``"login"``.
        """
        if isinstance(owner, int):
            self._owner = {"id": owner, "name": "", "login": ""}
        else:
            self._owner = dict(owner)
        return self

    def addTag(self, tag: Union[int, dict]) -> "WorkspaceManager":
        """
        Add a tag to the project. Fluent — returns ``self``.

        Duplicate tag IDs are silently ignored.

        Arguments:
            tag : REQUIRED : Either an ``int`` tag ID or a ``dict`` with at
                  minimum an ``"id"`` key, e.g. ``{"id": 42, "name": "prod"}``.
        """
        if isinstance(tag, int):
            tag_dict = {"id": tag}
        else:
            tag_dict = dict(tag)
        if not any(t.get("id") == tag_dict.get("id") for t in self._tags):
            self._tags.append(tag_dict)
        return self

    def addShare(self, share_to_id: int = None, share_to_type: str = "user",
                 share_to_display_name: str = None) -> "WorkspaceManager":
        """
        Share the project with a user, group, or all users. Fluent — returns ``self``.

        Arguments:
            share_to_id           : OPTIONAL : Numeric ID of the user or group to share
                                    with. Not required when ``share_to_type`` is ``"all"``.
            share_to_type         : OPTIONAL : ``"user"`` (default), ``"group"``, or
                                    ``"all"``.
            share_to_display_name : OPTIONAL : Display name (informational only).
        """
        share: dict = {"shareToType": share_to_type}
        if share_to_id is not None:
            share["shareToId"] = share_to_id
        if share_to_display_name is not None:
            share["shareToDisplayName"] = share_to_display_name
        self._shares.append(share)
        return self

    # ── Serialisation ──────────────────────────────────────────────────────

    def to_dict(self) -> dict:
        """
        Return the full project definition as a ``dict`` ready to be passed
        to the Adobe Analytics API (e.g. ``createProject``).
        """
        clean_panels = []
        for p in self._panels:
            if p.get("_opaque"):
                clean_panels.append(deepcopy(p["_raw"]))
                continue
            cp = deepcopy(p)
            cp.pop("_yOffset", None)
            cp.pop("_vizIndex", None)
            cp["position"]["autoHeight"] = sum(
                sp.get("position", {}).get("autoHeight", 0) for sp in cp["subPanels"]
            )
            clean_panels.append(cp)

        if self._definition_meta is not None:
            definition = deepcopy(self._definition_meta)
            workspaces = definition.get("workspaces", [{}])
            if workspaces:
                workspaces[0]["panels"] = clean_panels
            else:
                definition["workspaces"] = [{"id": self._workspace_id,
                                              "name": self._workspace_name,
                                              "panels": clean_panels}]
        else:
            definition = {
                "additionalCuratedComponents": [],
                "colorScheme": {"id": "default", "label": "", "value": self.DEFAULT_COLOR_SCHEME},
                "countRepeatInstances": True,
                "currentWorkspaceIndex": 0,
                "customColorSchemes": [],
                "hideCommentRail": False,
                "internalAnnotations": [],
                "intrinsicPublicAccessLinkComponents": [],
                "isCurated": False,
                "showAnnotations": True,
                "version": "98",
                "viewDensity": "expanded",
                "workspaces": [{"id": self._workspace_id,
                                "name": self._workspace_name,
                                "panels": clean_panels}],
            }

        result = {
            "name": self.name,
            "description": self.description,
            "rsid": self.rsid,
            "type": "project",
            "definition": definition,
        }
        if self._owner is not None:
            result["owner"] = self._owner
        if self._tags:
            result["tags"] = list(self._tags)
        if self._shares:
            result["shares"] = list(self._shares)
        return result

    def __str__(self) -> str:
        return json.dumps(self.to_dict(), indent=4)

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