"""Data model definitions for file-based context."""

from __future__ import annotations

import copy
from dataclasses import dataclass, field
from typing import Any


@dataclass(frozen=True)
class StrategyEntry:
    """Immutable data model representing a single section in STRATEGY.md."""

    context_type: str
    title: str
    content: str


@dataclass(frozen=True)
class CampaignSnapshot:
    """Campaign state snapshot.

    frozen=True prevents field reassignment, but dict/list contents are still mutable,
    so __post_init__ takes defensive copies.
    """

    campaign_id: str
    campaign_name: str
    status: str
    bidding_strategy_type: str | None = None
    bidding_details: dict[str, Any] | None = None
    daily_budget: float | None = None
    device_targeting: tuple[dict[str, Any], ...] | None = None
    campaign_goal: str | None = None
    notes: str | None = None
    # Optional performance metrics for the read-only reporting dashboard.
    # Validation stays loose (free-form dict); intended keys are:
    #   spend, impressions, clicks, conversions, cpa, ctr,
    #   result_indicator (Meta: whether "results" are clicks vs leads),
    #   period (e.g. "LAST_30_DAYS"), fetched_at (ISO 8601).
    # Optional with a None default so old STATE.json files parse unchanged.
    metrics: dict[str, Any] | None = None

    def __post_init__(self) -> None:
        """Take defensive copies of mutable fields."""
        if self.bidding_details is not None:
            object.__setattr__(
                self, "bidding_details", copy.deepcopy(self.bidding_details)
            )
        if self.device_targeting is not None:
            # Convert lists to tuples and deepcopy contents
            copied = tuple(copy.deepcopy(item) for item in self.device_targeting)
            object.__setattr__(self, "device_targeting", copied)
        if self.metrics is not None:
            object.__setattr__(self, "metrics", copy.deepcopy(self.metrics))


@dataclass(frozen=True)
class ActionLogEntry:
    """Immutable record of a single action performed on a campaign.

    metrics_at_action: Key metrics at the time of action (e.g., {"cpa": 5200, "conversions": 45}).
        Used by the agent to evaluate the outcome of the action after the observation window.
    observation_due: ISO 8601 date when the agent should evaluate the outcome (e.g., "2026-04-15").
        Typical windows: budget changes 7 days, keyword/creative changes 14 days.
    reversible_params: Structured hint describing how to reverse this action. Shape:
        ``{"operation": "<tool_name>", "params": {...}, "caveats": [...]}``.
        Agents set this when making reversible changes (budget update, status toggle,
        etc.) so ``mureo.rollback.plan_rollback()`` can build a concrete reversal plan.
        ``None`` means the action was not marked reversible (read-only query, create,
        delete, or simply not annotated). The ``operation`` value must be in the
        rollback planner's allow-list (see ``mureo.rollback.planner._ALLOWED_OPERATIONS``);
        values outside it — including destructive verbs like ``.delete`` — are refused
        so a prompt-injected or buggy agent cannot smuggle a privileged call through
        the rollback path.
    """

    timestamp: str
    action: str
    platform: str
    campaign_id: str | None = None
    summary: str | None = None
    command: str | None = None
    metrics_at_action: dict[str, Any] | None = None
    observation_due: str | None = None
    reversible_params: dict[str, Any] | None = None
    rollback_of: int | None = None

    def __post_init__(self) -> None:
        """Take defensive copies of mutable dict fields."""
        if self.metrics_at_action is not None:
            object.__setattr__(
                self, "metrics_at_action", copy.deepcopy(self.metrics_at_action)
            )
        if self.reversible_params is not None:
            object.__setattr__(
                self, "reversible_params", copy.deepcopy(self.reversible_params)
            )


@dataclass(frozen=True)
class PlatformState:
    """Per-platform state snapshot (Google Ads, Meta Ads, etc.).

    frozen=True prevents field reassignment; __post_init__ takes defensive
    copies of mutable inner contents.
    """

    account_id: str
    campaigns: tuple[CampaignSnapshot, ...] = field(default_factory=tuple)
    # Optional platform-level metric rollup (e.g. {"spend": ..., "clicks": ...})
    # and the period those totals cover (e.g. "LAST_30_DAYS"). Both default to
    # None so legacy platform entries parse unchanged and emit no extra keys.
    # ``totals`` / ``metrics_period`` carry a SINGLE rollup (the most recent
    # window a sync wrote); ``periods`` carries one rollup PER window so the
    # dashboard can offer a period toggle.
    totals: dict[str, Any] | None = None
    metrics_period: str | None = None
    # Optional per-period rollups keyed by canonical period token
    # ({"YESTERDAY": {<totals>}, "LAST_30_DAYS": {<totals>}}). Each value is a
    # totals-shaped dict (same canonical keys as ``totals``). None by default so
    # legacy entries parse unchanged and emit no extra key. sync-state writes
    # LAST_30_DAYS; daily-check writes YESTERDAY.
    periods: dict[str, dict[str, Any]] | None = None
    # Optional operator-declared conversion ``action_type`` allow-list (#342).
    # When set (non-None), the Meta conversion counters treat EXACTLY these
    # action_types as this account's conversions — overriding the default
    # deduped generic set — so a custom-event advertiser
    # (``offsite_conversion.custom.<id>``) or a component-only account is
    # counted correctly. None (the default) keeps the built-in generic set, so
    # legacy entries parse unchanged and emit no extra key.
    conversion_action_types: tuple[str, ...] | None = None

    def __post_init__(self) -> None:
        """Ensure campaigns is a tuple (defensive copy)."""
        if not isinstance(self.campaigns, tuple):
            object.__setattr__(self, "campaigns", tuple(self.campaigns))
        if self.totals is not None:
            object.__setattr__(self, "totals", copy.deepcopy(self.totals))
        if self.periods is not None:
            object.__setattr__(self, "periods", copy.deepcopy(self.periods))
        if self.conversion_action_types is not None and not isinstance(
            self.conversion_action_types, tuple
        ):
            # A bare str must NOT be char-split into a tuple of letters; wrap
            # it as a single action_type. Other iterables tuple-ify normally.
            normalized = (
                (self.conversion_action_types,)
                if isinstance(self.conversion_action_types, str)
                else tuple(self.conversion_action_types)
            )
            object.__setattr__(self, "conversion_action_types", normalized)


@dataclass(frozen=True)
class StateDocument:
    """Root document of STATE.json."""

    version: str = "1"
    last_synced_at: str | None = None
    customer_id: str | None = None  # Kept for backward compatibility (v1)
    campaigns: tuple[CampaignSnapshot, ...] = field(
        default_factory=tuple
    )  # Kept for v1
    platforms: dict[str, PlatformState] | None = None  # v2: per-platform state
    action_log: tuple[ActionLogEntry, ...] = field(
        default_factory=tuple
    )  # v2: action log
    # Forward-ready section for stage-c analysis summaries (e.g.
    # {"daily": ..., "weekly": ..., "goal": ...}). Round-tripped now; no
    # skill writes it yet. Optional with a None default so old STATE.json
    # files parse unchanged and emit no extra key.
    reports: dict[str, Any] | None = None

    def __post_init__(self) -> None:
        """Defensive copies for mutable fields."""
        if self.platforms is not None:
            object.__setattr__(self, "platforms", dict(self.platforms))
        if not isinstance(self.action_log, tuple):
            object.__setattr__(self, "action_log", tuple(self.action_log))
        if self.reports is not None:
            object.__setattr__(self, "reports", copy.deepcopy(self.reports))
