"""Chart type definitions, schemas, and colour palette for NL-to-SQL agents.

Single source of truth for all chart and highlight types.  Everything is
defined as a Pydantic model; the public exports below are derived from the
model registries so there is only one place to edit when adding a new type.

Public exports (backward-compatible):
  - CHART_TYPES              — ordered list of (key, description) pairs.
  - CHART_SCHEMAS            — prompt schema string per chart type.
  - KEY_HIGHLIGHT_TYPES      — ordered list of (key, description) for highlight blocks.
  - KEY_HIGHLIGHT_SCHEMAS    — prompt schema string per highlight type.
  - CHART_COLORS             — standard hex colours for series / slices.
  - VALID_CHART_KEYS         — O(1) validation set.
  - AnyChartContent          — discriminated Union for runtime validation.
  - AnyHighlightContent      — discriminated Union for runtime validation.
  - validate_chart_content   — parse & validate a raw dict against the correct chart model.
  - validate_highlight_content — parse & validate a raw dict against the correct highlight model.

To add a new chart type:
  1. Define item model(s) and a chart model subclassing _ChartBase.
  2. Append it to _CHART_MODEL_REGISTRY.
  3. Add it to the AnyChartContent Union.
  All other exports update automatically.
"""
from __future__ import annotations

import uuid
from typing import Annotated, Any, ClassVar, Literal, Optional, Union
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator


# ===========================================================================
# Internal base classes
# ===========================================================================

class _ChartBase(BaseModel):
    model_config = ConfigDict(extra="ignore")
    CHART_KEY: ClassVar[str] = ""
    CHART_DESCRIPTION: ClassVar[str] = ""
    PROMPT_SCHEMA: ClassVar[str] = ""


class _HighlightBase(BaseModel):
    model_config = ConfigDict(extra="ignore")
    HIGHLIGHT_KEY: ClassVar[str] = ""
    HIGHLIGHT_DESCRIPTION: ClassVar[str] = ""
    PROMPT_SCHEMA: ClassVar[str] = ""


class _ItemWithAutoId(BaseModel):
    """Base for chart item models that carry an `id` hit-zone key.

    Always assigns a server-generated UUID — the agent never produces `id`.
    """
    model_config = ConfigDict(extra="ignore")
    id: str = ""

    @model_validator(mode="before")
    @classmethod
    def _assign_id(cls, data: Any) -> Any:
        if not isinstance(data, dict):
            return data
        data["id"] = str(uuid.uuid4())
        return data



# ===========================================================================
# Chart models
# ===========================================================================

# ── stacked-horizontal-bar-chart ─────────────────────────────────────────────

class _StackedBarItem(_ItemWithAutoId):
    id: str = ""
    name: str
    base: float = Field(description="Solid bar segment width — REQUIRED.")
    total: float = Field(description="Full bar width and right-side label source — REQUIRED.")
    totalLabel: str = Field(description="Shown to the right of every bar — REQUIRED.")
    abbreviation: Optional[str] = None
    variation: Optional[float] = None      # draws the lighter segment; omit only if no variation exists
    baseLabel: Optional[str] = None        # shown in tooltip sublabel
    variationLabel: Optional[str] = None   # shown in tooltip sublabel

class _StackedBarTotals(BaseModel):
    model_config = ConfigDict(extra="ignore")
    total: float = Field(description="Shown in footer summary row — REQUIRED if a summary row is needed.")
    base: Optional[float] = None
    variation: Optional[float] = None

class _StackedBarData(BaseModel):
    model_config = ConfigDict(extra="ignore")
    items: list[_StackedBarItem]
    totals: Optional[_StackedBarTotals] = None

class StackedHorizontalBarChart(_ChartBase):
    CHART_KEY: ClassVar[str] = "stacked-horizontal-bar-chart"
    CHART_DESCRIPTION: ClassVar[str] = (
        "Horizontal stacked bars showing a base value and a variation per entity (e.g. base contract vs approved variations). "
        "Use when comparing two additive components across contractors, packages, or work areas. Sort by total descending."
    )
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "stacked-horizontal-bar-chart",
  "data": {
    "items": [
      // REQUIRED fields — chart breaks without these:
      { "name": str,           // shown as y-axis label
        "base": number,        // solid bar segment width (e.g. base contract amount)
        "total": number,       // full bar width (base + variation); drives right-side label
        "totalLabel": str,     // REQUIRED — shown to the right of every bar, e.g. "₹5.00Cr"

        // Strongly recommended — chart renders but is confusing without:
        "variation": number|null,    // lighter stacked segment; omit only when no variation exists
        "baseLabel": str|null,       // tooltip sublabel for base segment, e.g. "₹4.20Cr"
        "variationLabel": str|null,  // tooltip sublabel for variation, e.g. "₹0.80Cr"

        // Optional — not rendered in this chart:
        "abbreviation": str|null     // not rendered in this chart — can be omitted
      }
    ],
    "totals": { "total": number,              // REQUIRED if a footer summary row is needed
                "base": number|null,          // optional — not currently rendered
                "variation": number|null }|null // optional — not currently rendered
  }
}
CRITICAL — "data" wrapper is REQUIRED. Do NOT place "items" directly under the root.
CRITICAL — "base", "total", and "totalLabel" are REQUIRED on every item.
total = base + variation on every row (where variation exists).
"totals.total" = sum of ALL items' total — required for the portfolio footer row.

Concrete example (2 contractors, base contract vs variations):
  WRONG ❌ — items at root (missing "data" wrapper), totalLabel missing:
    {"type":"stacked-horizontal-bar-chart","items":[
      {"name":"CISDI","base":42000000,"variation":8000000,"total":50000000},
      {"name":"L&T",  "base":31500000,"variation":4500000,"total":36000000}]}

  CORRECT ✅ — items inside "data", required labels present:
    {"type":"stacked-horizontal-bar-chart","data":{"items":[
      {"name":"CISDI","base":42000000,"variation":8000000,"total":50000000,"totalLabel":"₹5.00Cr","baseLabel":"₹4.20Cr","variationLabel":"₹0.80Cr"},
      {"name":"L&T",  "base":31500000,"variation":4500000,"total":36000000,"totalLabel":"₹3.60Cr","baseLabel":"₹3.15Cr","variationLabel":"₹0.45Cr"}
    ],"totals":{"total":86000000,"base":73500000,"variation":12500000}}}
"""

    type: Literal["stacked-horizontal-bar-chart"]
    data: _StackedBarData


# ── progress-race-chart ───────────────────────────────────────────────────────

class _ProgressRaceItem(_ItemWithAutoId):
    id: str = ""
    name: str
    abbreviation: Optional[str] = None
    base: float = Field(description="GLOBAL MAX — MAX(total) across ALL items. Same on EVERY row.")
    total: float = Field(description="THIS entity's own value. Varies per row.")
    percentage: float = Field(description="round((total / base) * 100, 1). Top item = 100.")
    baseLabel: Optional[str] = None
    totalLabel: Optional[str] = None

class ProgressRaceChart(_ChartBase):
    CHART_KEY: ClassVar[str] = "progress-race-chart"
    CHART_DESCRIPTION: ClassVar[str] = (
        "Horizontal bars showing each entity's progress toward a shared target as a percentage. "
        "Use for 'who is on track vs falling behind?' — milestone completion, budget consumption, tasks closed."
    )
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "progress-race-chart",
  "items": [{ "name": str, "abbreviation": str|null,
              "base": number,       // GLOBAL MAX — MAX(value) across ALL items. Same on EVERY row.
              "total": number,      // THIS entity's own value (bar length). Varies per row.
              "percentage": number, // round((total / base) * 100, 1). Top item = 100, rest < 100.
              "baseLabel": str|null,  // Formatted global max, e.g. "₹6.00Cr"
              "totalLabel": str|null  // Formatted entity value, e.g. "₹3.15Cr"
            }]
}
CRITICAL — base is NOT the entity's own value. It is the shared ceiling for ALL bars — set to MAX(total) on every row.
"""

    type: Literal["progress-race-chart"]
    items: list[_ProgressRaceItem]

    @model_validator(mode="after")
    def _base_must_be_global_max(self) -> ProgressRaceChart:
        if len(self.items) < 2:
            return self
        bases = {item.base for item in self.items}
        if len(bases) > 1:
            raise ValueError(
                f"'base' must be the same global max on every row, but found "
                f"{len(bases)} distinct values: {bases}. "
                "Set base = MAX(total) across all items on every row."
            )
        return self


# ── proportional-band-chart ───────────────────────────────────────────────────

class _SeverityBand(BaseModel):
    model_config = ConfigDict(extra="ignore")
    severity: str
    count: int

class ProportionalBandChart(_ChartBase):
    CHART_KEY: ClassVar[str] = "proportional-band-chart"
    CHART_DESCRIPTION: ClassVar[str] = (
        "Proportional horizontal bands from highest to lowest severity. "
        "Use for risk or quality distribution — 'how many critical vs major vs minor issues?'"
    )
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "proportional-band-chart",
  "severities": [{ "severity": str, "count": number }],
  "title": str|null
}
CRITICAL — "severities" MUST be ordered most severe → least severe: "Critical" → "High" → "Medium" → "Low" → "Observation".
Omit levels with 0 count. Do NOT add a "pct" field — the component computes widths from counts.
"""

    type: Literal["proportional-band-chart"]
    severities: list[_SeverityBand]
    title: Optional[str] = None


# ── radial-fan-tree-chart ─────────────────────────────────────────────────────

class _RadialFanItem(_ItemWithAutoId):
    id: str = Field(default="", description="Unique hit-zone key — server-generated.")
    name: str = Field(description="Leaf label and tooltip text — REQUIRED.")
    count: float = Field(description="Arc width, dot size, and label source — REQUIRED.")
    abbreviation: Optional[str] = None

class RadialFanTreeChart(_ChartBase):
    CHART_KEY: ClassVar[str] = "radial-fan-tree-chart"
    CHART_DESCRIPTION: ClassVar[str] = (
        "Radial fan tree where arc width encodes proportion of the parent total. "
        "Use for hierarchical data where relative branch size is the insight (e.g. package → subpackage counts)."
    )
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "radial-fan-tree-chart",
  "total": number,     // MUST equal the sum of ALL items' count values
  "totalLabel": str,   // REQUIRED — formatted root total, e.g. "£8.70M" or "284 items"
  "items": [{ "name": str,            // REQUIRED — leaf label and tooltip text
               "count": number,        // REQUIRED — arc width, dot size, leaf label
               "abbreviation": str|null // optional — falls back to name[:6]
            }]
}
CRITICAL — "total" MUST equal sum of all item "count" values. "totalLabel" is REQUIRED (e.g. "284 items" or "£8.70M").
"""

    type: Literal["radial-fan-tree-chart"]
    total: float = Field(description="Must equal the sum of all items' count values")
    totalLabel: str = Field(description="Formatted root total — REQUIRED.")
    items: list[_RadialFanItem]

    @model_validator(mode="after")
    def _total_must_equal_sum_of_counts(self) -> RadialFanTreeChart:
        counts = [item.count for item in self.items if item.count is not None]
        if not counts:
            return self
        expected = sum(counts)
        tolerance = max(abs(expected) * 0.01, 1.0)
        if abs(self.total - expected) > tolerance:
            raise ValueError(
                f"'total' ({self.total}) must equal the sum of all item 'count' values "
                f"({expected}). Recompute: total = sum(item.count for item in items)."
            )
        return self


# ── semi-circular-gauge-chart ─────────────────────────────────────────────────

class SemiCircularGaugeChart(_ChartBase):
    CHART_KEY: ClassVar[str] = "semi-circular-gauge-chart"
    CHART_DESCRIPTION: ClassVar[str] = (
        "Semi-circular gauge for a single headline rate or completion KPI (e.g. NCE confirmation rate). "
        "Pass raw counts — the component computes the percentage."
    )
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "semi-circular-gauge-chart",
  "confirmed": number, // Raw numerator — e.g. 15 confirmed NCEs
  "total":     number, // Raw denominator — e.g. 25 total NCEs
  "label":     str     // Context shown beneath dial, e.g. "NCEs confirmed as compensation events"
}
CRITICAL — "confirmed" and "total" are RAW COUNTS, not percentages. Never pass a pre-computed percentage.
"""

    type: Literal["semi-circular-gauge-chart"]
    confirmed: float = Field(ge=0, description="Raw numerator count")
    total: float = Field(gt=0, description="Raw denominator count — must be > 0")
    label: str

    @model_validator(mode="after")
    def _confirmed_must_not_exceed_total(self) -> "SemiCircularGaugeChart":
        if self.confirmed > self.total:
            raise ValueError(
                f"'confirmed' ({self.confirmed}) cannot exceed 'total' ({self.total})."
            )
        return self


# ── segmented-split-bar-chart ─────────────────────────────────────────────────

class _SegmentedSplitItem(_ItemWithAutoId):
    id: str = Field(default="", description="Unique identifier — server-generated.")
    name: str = Field(description="Full display name shown in tooltips")
    abbreviation: Optional[str] = None
    implemented: int = Field(description="Count for the primary segment (green bar)")
    unimplemented: int = Field(description="Count for the secondary segment (amber bar)")

class SegmentedSplitBarChart(_ChartBase):
    CHART_KEY: ClassVar[str] = "segmented-split-bar-chart"
    CHART_DESCRIPTION: ClassVar[str] = (
        "Split bars dividing each entity into two complementary segments (done vs remaining, closed vs open). "
        "Use for 'what portion is complete vs outstanding?' across a list of entities."
    )
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "segmented-split-bar-chart",
  "labelA": str|null, // Label for the primary segment, e.g. "Closed", "Paid", "Implemented"
  "labelB": str|null, // Label for the secondary segment, e.g. "Open", "Outstanding", "Unimplemented"
  "unit":   str|null, // Noun for tooltip total line, e.g. "variations", "items", "NCEs"
  "items": [{ "name": str, "abbreviation": str|null,
              "implemented":   number, // REQUIRED — Segment A count — done/closed/paid
              "unimplemented": number  // REQUIRED — Segment B count — remaining/open/outstanding
           }]
}
CRITICAL — item fields are ALWAYS "implemented" and "unimplemented" regardless of domain (done/closed/paid → "implemented"; remaining/open/outstanding → "unimplemented").
Set labelA/labelB to domain names. Do NOT add "pct". Sort least complete first.
"""

    type: Literal["segmented-split-bar-chart"]
    labelA: Optional[str] = None
    labelB: Optional[str] = None
    unit: Optional[str] = None
    items: list[_SegmentedSplitItem]


# ── balance-scale-chart ───────────────────────────────────────────────────────

class _ScaleSide(BaseModel):
    model_config = ConfigDict(extra="ignore")
    value: float = Field(description="Aggregate monetary/volume amount driving the beam tilt")
    count: int = Field(description="Number of contributing records on this side")
    label: str = Field(description="Formatted display value shown on the pan, e.g. '£28.4M' or '₹5.00Cr'")

class BalanceScaleChart(_ChartBase):
    CHART_KEY: ClassVar[str] = "balance-scale-chart"
    CHART_DESCRIPTION: ClassVar[str] = (
        "Balance beam comparing two aggregate totals — the heavier side tilts the beam. "
        "Use when the question is 'which side outweighs the other?' (accepted vs submitted quotations, claims raised vs approved)."
    )
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "balance-scale-chart",
  "leftTitle":  str,  // Name of the left pan, e.g. "Accepted", "Scope Added", "Claims Approved"
  "rightTitle": str,  // Name of the right pan, e.g. "Submitted", "Scope Removed", "Claims Raised"
  "unit":       str,  // Unit for the count on each side, e.g. "quotations", "claims", "NCEs"
  "left":  { "value": number, "count": number, "label": str },
  "right": { "value": number, "count": number, "label": str }
}
— "label" = formatted display value on the pan (e.g. "£28.4M") — NOT the side name.
The side with the higher "value" tilts the beam toward it.
"""

    type: Literal["balance-scale-chart"]
    leftTitle: str
    rightTitle: str
    unit: str
    left: _ScaleSide
    right: _ScaleSide


# ── trend-view ────────────────────────────────────────────────────────────────

class _TrendPoint(BaseModel):
    model_config = ConfigDict(extra="ignore")
    week: str = Field(description="Period label: '2024-W01' (week), 'Jan 2024' (month), 'Q1 2024' (quarter)")
    count: float

class TrendView(_ChartBase):
    CHART_KEY: ClassVar[str] = "trend-view"
    CHART_DESCRIPTION: ClassVar[str] = (
        "Time-series area chart for a single metric over time. "
        "Use for 'how has X changed over time?' questions. Sort points chronologically oldest-first."
    )
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "trend-view",
  "points": [{ "week": str,    // Period label — "2024-W01" (week), "Jan 2024" (month), "Q1 2024" (quarter)
               "count": number // Metric value for this period
            }]
}
CRITICAL — field is "count", NOT "value". Sort points chronologically oldest-first. Minimum 2 points.
"""

    type: Literal["trend-view"]
    points: list[_TrendPoint] = Field(min_length=2, description="Minimum 2 points required to render a line")


# ── weekly-flow ───────────────────────────────────────────────────────────────

class _WeeklyFlowItem(_ItemWithAutoId):
    id: str = ""
    name: str
    abbreviation: Optional[str] = None
    base: Optional[float] = None
    variation: Optional[float] = None
    total: Optional[float] = None
    percentage: Optional[float] = None
    baseLabel: Optional[str] = None
    variationLabel: Optional[str] = None
    totalLabel: Optional[str] = None

class WeeklyFlow(_ChartBase):
    CHART_KEY: ClassVar[str] = "weekly-flow"
    CHART_DESCRIPTION: ClassVar[str] = (
        "Sankey flow diagram showing how volume moves through pipeline stages (submitted → reviewed → approved). "
        "Use for 'where does volume go at each stage?' questions."
    )
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "weekly-flow",
  "items": [{ "name": str, "abbreviation": str|null,
              "base": number|null,        // Total input volume of the FIRST stage — same on every row
              "variation": number|null,   // Drop-off at this stage (negative = lost volume)
              "total": number|null,       // Volume remaining after this stage (base + variation)
              "percentage": number|null,  // round(total / items[0].total * 100, 1) — share of initial flow
              "baseLabel": str|null, "variationLabel": str|null, "totalLabel": str|null }]
}
Items are flow stages in order. First item: variation=0, total=base, percentage=100.
Each subsequent stage: variation=drop-off (negative), percentage=round(total/items[0].total*100,1).
"""

    type: Literal["weekly-flow"]
    items: list[_WeeklyFlowItem]


# ===========================================================================
# Chart registry — append here to add a new chart type
# ===========================================================================

_CHART_MODEL_REGISTRY: list[type[_ChartBase]] = [
    ProgressRaceChart,
    ProportionalBandChart,
    RadialFanTreeChart,
    SemiCircularGaugeChart,
    SegmentedSplitBarChart,
    BalanceScaleChart,
    TrendView,
    WeeklyFlow,
]

AnyChartContent = Annotated[
    Union[
        StackedHorizontalBarChart,
        ProgressRaceChart,
        ProportionalBandChart,
        RadialFanTreeChart,
        SemiCircularGaugeChart,
        SegmentedSplitBarChart,
        BalanceScaleChart,
        TrendView,
        WeeklyFlow,
    ],
    Field(discriminator="type"),
]

_chart_adapter: TypeAdapter[AnyChartContent] = TypeAdapter(AnyChartContent)


def validate_chart_content(data: dict) -> AnyChartContent:
    """Parse and validate a raw content dict; raises ValidationError on bad input."""
    return _chart_adapter.validate_python(data)


# ===========================================================================
# Key highlight models
# ===========================================================================

# ── stats ─────────────────────────────────────────────────────────────────────

class _StatItem(BaseModel):
    model_config = ConfigDict(extra="ignore")
    value: str
    label: str
    icon: Optional[str] = None  # icon key rendered next to the stat card

class StatsHighlight(_HighlightBase):
    HIGHLIGHT_KEY: ClassVar[str] = "stats"
    HIGHLIGHT_DESCRIPTION: ClassVar[str] = "a few key KPI numbers (total count, sum, average)."
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "stats",
  "items": [{ "value": str, "label": str, "icon": str|null }]
}
icon rules — apply the first matching rule:
  "pound"          — value starts with £, $, or €  (e.g. "£342M")
  "percent"        — value contains %               (e.g. "47%")
  "shapes"         — everything else: plain numbers, decimals, day values, ranges, any value with a unit (e.g. "150", "45.7", "14 Days", "40-60 Days", "52.0 days"). Never use null.
NEVER set a contractor icon when value is a number, amount, or percentage."""

    type: Literal["stats"]
    items: list[_StatItem]


# ── ranked ────────────────────────────────────────────────────────────────────

class _RankedItem(BaseModel):
    model_config = ConfigDict(extra="ignore")
    name: str
    value: str
    kpiLabel: Optional[str] = None

class RankedHighlight(_HighlightBase):
    HIGHLIGHT_KEY: ClassVar[str] = "ranked"
    HIGHLIGHT_DESCRIPTION: ClassVar[str] = "a ranked list with values and colours."
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "ranked",
  "items": [{ "name": str, "value": str, "kpiLabel": str|null }]
}"""

    type: Literal["ranked"]
    items: list[_RankedItem]


# ── proportion ────────────────────────────────────────────────────────────────

class _ProportionChip(BaseModel):
    model_config = ConfigDict(extra="ignore")
    value: str
    label: str
    color: Optional[str] = None

class ProportionHighlight(_HighlightBase):
    HIGHLIGHT_KEY: ClassVar[str] = "proportion"
    HIGHLIGHT_DESCRIPTION: ClassVar[str] = "two-part proportion split (e.g. open vs closed)."
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "proportion",
  "leftPct": number, "leftLabel": str, "leftValue": str, "leftColor": str,
  "rightPct": number, "rightLabel": str, "rightValue": str, "rightColor": str,
  "chips": [{ "value": str, "label": str, "color": str|null }]|null
}"""

    type: Literal["proportion"]
    leftPct: float
    leftLabel: str
    leftValue: str
    leftColor: str
    rightPct: float
    rightLabel: str
    rightValue: str
    rightColor: str
    chips: Optional[list[_ProportionChip]] = None


# ── ring ──────────────────────────────────────────────────────────────────────

class _RingChip(BaseModel):
    model_config = ConfigDict(extra="ignore")
    value: str
    label: str
    color: Optional[str] = None

class RingHighlight(_HighlightBase):
    HIGHLIGHT_KEY: ClassVar[str] = "ring"
    HIGHLIGHT_DESCRIPTION: ClassVar[str] = (
        "a single percentage ring (e.g. commitment %). Accepts exactly ONE pct value (0–100 integer). "
        "Never pass two pct values or an array."
    )
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "ring",
  "pct": number,
  "label": str, "color": str,
  "chips": [{ "value": str, "label": str, "color": str|null }]|null
}
IMPORTANT: "pct" is a single integer 0–100 representing one percentage.
Do NOT send two pct fields, a second percentage, or an array. If the data has
two complementary percentages (e.g. 45% implemented, 55% pending), use
"proportion" instead of "ring"."""

    type: Literal["ring"]
    pct: float = Field(ge=0.0, le=100.0)
    label: str
    color: str
    chips: Optional[list[_RingChip]] = None


# ── badges ────────────────────────────────────────────────────────────────────

class _BadgeItem(BaseModel):
    model_config = ConfigDict(extra="ignore")
    text: str
    severity: Literal["red", "amber", "green"]

class BadgesHighlight(_HighlightBase):
    HIGHLIGHT_KEY: ClassVar[str] = "badges"
    HIGHLIGHT_DESCRIPTION: ClassVar[str] = "severity or status labels."
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "badges",
  "items": [{ "text": str, "severity": "red"|"amber"|"green" }],
  "textSize": number|null
}"""

    type: Literal["badges"]
    items: list[_BadgeItem]
    textSize: Optional[float] = None


# ── dot-strip ─────────────────────────────────────────────────────────────────

class _DotStripDot(BaseModel):
    model_config = ConfigDict(extra="ignore")
    val: float
    color: str
    name: str

class _DotStripChip(BaseModel):
    model_config = ConfigDict(extra="ignore")
    value: str
    label: str

class DotStripHighlight(_HighlightBase):
    HIGHLIGHT_KEY: ClassVar[str] = "dot-strip"
    HIGHLIGHT_DESCRIPTION: ClassVar[str] = "a min/max range with named dots on a strip."
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "dot-strip",
  "min": number, "max": number, "unit": str,
  "dots": [{ "val": number, "color": str, "name": str }],
  "chips": [{ "value": str, "label": str, }]|null
}"""

    type: Literal["dot-strip"]
    min: float
    max: float
    unit: str
    dots: list[_DotStripDot]
    chips: Optional[list[_DotStripChip]] = None


# ── scorecard-rows ────────────────────────────────────────────────────────────

class _ScorecardRow(BaseModel):
    model_config = ConfigDict(extra="ignore")
    name: str
    value: str
    pct: float
    color: str
    badge: Optional[str] = None
    badgeSeverity: Optional[Literal["green", "amber", "red"]] = None
    sublabel: Optional[str] = None

class ScorecardRowsHighlight(_HighlightBase):
    HIGHLIGHT_KEY: ClassVar[str] = "scorecard-rows"
    HIGHLIGHT_DESCRIPTION: ClassVar[str] = "contractor/item scorecard with inline bar per row."
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "scorecard-rows",
  "items": [{ "name": str, "value": str, "pct": number, "color": str,
               "badge": str|null, "badgeSeverity": "green"|"amber"|"red"|null,
               "sublabel": str|null }]
}"""

    type: Literal["scorecard-rows"]
    items: list[_ScorecardRow]


# ── comparison-rows ───────────────────────────────────────────────────────────

class _ComparisonRow(BaseModel):
    model_config = ConfigDict(extra="ignore")
    label: str
    cells: list[str]

class ComparisonRowsHighlight(_HighlightBase):
    HIGHLIGHT_KEY: ClassVar[str] = "comparison-rows"
    HIGHLIGHT_DESCRIPTION: ClassVar[str] = "a side-by-side comparison table."
    PROMPT_SCHEMA: ClassVar[str] = """\
{ "type": "comparison-rows",
  "columns": [str],
  "rows": [{ "label": str, "cells": [str],}]
}"""

    type: Literal["comparison-rows"]
    columns: list[str]
    rows: list[_ComparisonRow]


# ===========================================================================
# Highlight registry — append here to add a new highlight type
# ===========================================================================

_HIGHLIGHT_MODEL_REGISTRY: list[type[_HighlightBase]] = [
    StatsHighlight,
    RankedHighlight,
    ProportionHighlight,
    RingHighlight,
    BadgesHighlight,
    DotStripHighlight,
    ScorecardRowsHighlight,
    ComparisonRowsHighlight,
]

AnyHighlightContent = Annotated[
    Union[
        StatsHighlight,
        RankedHighlight,
        ProportionHighlight,
        RingHighlight,
        BadgesHighlight,
        DotStripHighlight,
        ScorecardRowsHighlight,
        ComparisonRowsHighlight,
    ],
    Field(discriminator="type"),
]

_highlight_adapter: TypeAdapter[AnyHighlightContent] = TypeAdapter(AnyHighlightContent)


def validate_highlight_content(data: dict) -> AnyHighlightContent:
    """Parse and validate a raw content dict; raises ValidationError on bad input."""
    return _highlight_adapter.validate_python(data)


# ===========================================================================
# Public exports — all derived from registries (fully backward-compatible)
# ===========================================================================

CHART_TYPES: list[tuple[str, str]] = [
    (m.CHART_KEY, m.CHART_DESCRIPTION) for m in _CHART_MODEL_REGISTRY
]

CHART_SCHEMAS: dict[str, str] = {
    m.CHART_KEY: m.PROMPT_SCHEMA for m in _CHART_MODEL_REGISTRY
}

VALID_CHART_KEYS: frozenset[str] = frozenset(m.CHART_KEY for m in _CHART_MODEL_REGISTRY)

KEY_HIGHLIGHT_TYPES: list[tuple[str, str]] = [
    (m.HIGHLIGHT_KEY, m.HIGHLIGHT_DESCRIPTION) for m in _HIGHLIGHT_MODEL_REGISTRY
]

KEY_HIGHLIGHT_SCHEMAS: dict[str, str] = {
    m.HIGHLIGHT_KEY: m.PROMPT_SCHEMA for m in _HIGHLIGHT_MODEL_REGISTRY
}

# ---------------------------------------------------------------------------
# Colour palette
# Standard hex colours the frontend uses for series / slices in order.
# ---------------------------------------------------------------------------
CHART_COLORS: list[str] = ['#4C93D9', '#EC772A', '#5DA537', '#818FF8', '#EEBF3B']