import json
from dataclasses import dataclass
from datetime import date, datetime
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Union

from .pit_enums import *
from .pit_formula_base import FormulaExpression, Literal

__all__ = [
    "InputType",
    "ScheduleType",
    "CompileResult",
    "CreditAsset",
    "Input",
    "Schedule",
    "Variable",
    "CellAccessor",
    "Payoff",
]

# ==================== Enums ====================


class InputType(Enum):
    """Enum for input types mapping SDK types to PIT types"""

    STRING = "string"
    DATE = "date"
    CURVE = "curve"
    CREDIT_BASKET = "basket"
    SCHEDULE = "schedule"
    DATE_TABLE = "datetable"
    STRING_MATRIX = "pilstringmatrix"


class ScheduleType(Enum):
    AT_DATE = "AtDate"
    ON_SCHEDULE = "OnSchedule"
    ON_SCHEDULE_PERIOD_END = "OnSchedule PeriodEnd"
    ON_SCHEDULE_PERIOD_START = "OnSchedule PeriodStart"
    ON_USER_SCHEDULE = "OnUserSchedule"
    ALL_THE_TIME = "AllTheTime"


@dataclass
class CompileResult:
    """Result of PIT compilation + HTML rendering."""

    success: bool
    has_compiler_error: bool
    message: str
    xml_path: str
    html_path: str

    def __str__(self) -> str:
        if not self.success:
            status = "ERROR"
        elif self.has_compiler_error:
            status = "WARNING"
        else:
            status = "OK"
        compiler_state = (
            "Compiler reported no GenSecError" if not self.has_compiler_error else "Compiler returned GenSecError"
        )
        return f"[{status}] {self.message}\n" f"{compiler_state}\n" f"XML: {self.xml_path}\n" f"HTML: {self.html_path}"


# ==================== Helper Classes ====================


@dataclass
class CreditAsset:
    """Represents a credit asset in a basket for PIT serialization"""

    code: str
    ccy: str
    notional: float
    alive: bool = True
    recovery_rate: Optional[float] = None

    def to_dict(self) -> Dict[str, Any]:
        """Convert to PIT JSON format"""
        result = {"code": self.code, "ccy": self.ccy, "notional": self.notional, "alive": self.alive}
        if self.recovery_rate is not None:
            result["recovery_rate"] = self.recovery_rate
        return result


# ==================== Core Classes ====================


class Input(FormulaExpression):
    """Represents an input that can be used directly in formulas"""

    def __init__(
        self,
        name: str,
        value: Any,
        payoff: "Payoff",
        type: InputType = InputType.STRING,
        date_format: str = "DD-MMM-YYYY",
    ):
        object.__setattr__(self, "_initialized", False)
        self.name = name
        self.value = value
        self.type = type
        self.date_format = date_format
        self._payoff = payoff
        object.__setattr__(self, "_initialized", True)

    def __setattr__(self, name: str, value: Any):
        """Prevent modification after initialization"""
        if hasattr(self, "_initialized") and self._initialized:
            raise AttributeError(
                f"Cannot modify Input object '{self.name}'. Input objects are immutable after creation."
            )
        object.__setattr__(self, name, value)

    def to_string(self) -> str:
        return self.name

    def _parse_date_string(self, date_str: str, row_idx: int, cell_idx: int) -> date:
        """
        Parse date string according to declared format with validation.

        Args:
            date_str: Date string to parse
            row_idx: Row index for error reporting (0-based)
            cell_idx: Cell index for error reporting (0-based)

        Returns:
            Parsed date object

        Raises:
            ValueError: If date string doesn't match format or is invalid
        """
        from datetime import datetime

        date_str = date_str.strip()

        # Try to parse based on declared format
        try:
            if self.date_format == "DD-MMM-YYYY":
                # Format: 22-OCT-2024
                return datetime.strptime(date_str, "%d-%b-%Y").date()
            elif self.date_format == "YYYY-MM-DD":
                # Format: 2024-10-22
                return datetime.strptime(date_str, "%Y-%m-%d").date()
            elif self.date_format == "DD/MM/YYYY":
                # Format: 22/10/2024
                return datetime.strptime(date_str, "%d/%m/%Y").date()
            else:
                raise ValueError(f"Unsupported date format: {self.date_format}")
        except ValueError as e:
            # Provide helpful error message
            format_examples = {"DD-MMM-YYYY": "22-OCT-2024", "YYYY-MM-DD": "2024-10-22", "DD/MM/YYYY": "22/10/2024"}
            example = format_examples.get(self.date_format, "unknown")
            raise ValueError(
                f"Schedule '{self.name}' validation failed at row {row_idx + 1}, cell {cell_idx + 1}:\n"
                f"  Value: '{date_str}'\n"
                f"  Expected format: {self.date_format}\n"
                f"  Example: {example}\n"
                f"  Error: {str(e)}"
            )

    def _serialize_schedule(self) -> List[List[str]]:
        """
        Convert schedule value to PIT format.

        PIT Schedule requirement: Exactly 4 columns per row
        - Column 1: Accrual Start Date
        - Column 2: Payment Date
        - Column 3: Reset Start Date
        - Column 4: Reset End Date

        Expected input formats:
        - List[List[str]]: Raw table data with dates as strings in declared format
        - List[List[date]]: Raw table data with Python date objects
        - List[Tuple]: Row tuples that will be converted to lists

        Date strings must match the declared date_format (default: DD-MMM-YYYY).
        Supported formats: DD-MMM-YYYY, YYYY-MM-DD, DD/MM/YYYY

        Returns:
            List of lists with dates formatted as DD-MMM-YYYY (PIT standard)
        """
        if not isinstance(self.value, list):
            raise ValueError(f"Schedule value must be a list, got {type(self.value)}")

        result = []
        for row_idx, row in enumerate(self.value):
            if isinstance(row, tuple):
                row = list(row)

            # PIT requirement: Schedules must have exactly 4 columns
            if len(row) != 4:
                raise ValueError(
                    f"Schedule '{self.name}' validation failed:\n"
                    f"  Row {row_idx} has {len(row)} column(s), but PIT requires exactly 4 columns.\n"
                    f"  Required columns:\n"
                    f"    Column 1: Accrual Start Date\n"
                    f"    Column 2: Payment Date\n"
                    f"    Column 3: Reset Start Date\n"
                    f"    Column 4: Reset End Date\n"
                    f"  Your row: {row}"
                )

            converted_row = []
            for cell_idx, cell in enumerate(row):
                # Handle date/datetime objects (always accepted)
                if isinstance(cell, (datetime, date)):
                    # PIT format: DD-MMM-YYYY
                    converted_row.append(cell.strftime("%d-%b-%Y").upper())

                # Handle string values
                elif isinstance(cell, str) and cell.strip():
                    # Only parse as date if it contains date separators (-, /)
                    if "-" in cell or "/" in cell:
                        # Parse and validate according to date_format
                        parsed_date = self._parse_date_string(cell, row_idx, cell_idx)
                        # Convert to PIT format
                        converted_row.append(parsed_date.strftime("%d-%b-%Y").upper())
                    else:
                        # Pass through non-date strings (numbers, text, etc.)
                        converted_row.append(cell)

                # Handle empty cells
                elif cell is None or cell == "":
                    converted_row.append("")

                # Handle other types (numbers, etc.)
                else:
                    converted_row.append(str(cell))

            result.append(converted_row)

        return result

    def _serialize_curve(self) -> List[List[str]]:
        """
        Convert curve value to PIT format.

        Expected input formats:
        - Dict[date, float]: Date -> value mapping
        - Dict[str, float]: Date string -> value mapping
        - List[Tuple[date, float]]: List of (date, value) pairs
        - List[List]: Raw table data [[date, value], ...]

        Returns:
            List of [date_string, value_string] pairs
        """
        if isinstance(self.value, dict):
            result = []
            for dt, val in self.value.items():
                if isinstance(dt, (datetime, date)):
                    date_str = dt.strftime("%d-%b-%Y").upper()
                else:
                    date_str = str(dt)
                result.append([date_str, str(val)])
            return result

        elif isinstance(self.value, list):
            result = []
            for item in self.value:
                if isinstance(item, (tuple, list)) and len(item) == 2:
                    dt, val = item
                    if isinstance(dt, (datetime, date)):
                        date_str = dt.strftime("%d-%b-%Y").upper()
                    else:
                        date_str = str(dt)
                    result.append([date_str, str(val)])
                else:
                    raise ValueError(f"Curve items must be [date, value] pairs, got {item}")
            return result

        else:
            raise ValueError(f"Curve value must be dict or list, got {type(self.value)}")

    def _serialize_basket(self) -> List[Dict[str, Any]]:
        """
        Convert credit basket to PIT format.

        Expected input formats:
        - List[CreditAsset]: List of CreditAsset objects
        - List[Dict]: List of asset dictionaries

        Returns:
            List of asset dictionaries
        """
        if not isinstance(self.value, list):
            raise ValueError(f"Basket value must be a list, got {type(self.value)}")

        result = []
        for asset in self.value:
            if isinstance(asset, CreditAsset):
                result.append(asset.to_dict())
            elif isinstance(asset, dict):
                result.append(asset)
            else:
                raise ValueError(f"Basket items must be CreditAsset or dict, got {type(asset)}")

        return result

    def _serialize_datetable(self) -> Dict[str, List[str]]:
        """
        Convert date table to PIT format.

        Expected input formats:
        - Dict[str, List[date]]: Column name -> list of dates
        - Dict[str, List[str]]: Column name -> list of date strings

        Returns:
            Dictionary with date strings in DD-MMM-YYYY format
        """
        if not isinstance(self.value, dict):
            raise ValueError(f"DateTable value must be a dict, got {type(self.value)}")

        result = {}
        for col_name, dates in self.value.items():
            converted_dates = []
            for dt in dates:
                if isinstance(dt, (datetime, date)):
                    converted_dates.append(dt.strftime("%d-%b-%Y").upper())
                elif isinstance(dt, str):
                    converted_dates.append(dt)
                else:
                    raise ValueError(f"DateTable values must be dates or strings, got {type(dt)}")
            result[col_name] = converted_dates

        return result

    def _serialize_string_matrix(self) -> List[List[str]]:
        """
        Convert string matrix to PIT format.

        Expected input formats:
        - List[List[str]]: 2D matrix of strings

        Returns:
            2D list of strings
        """
        if not isinstance(self.value, list):
            raise ValueError(f"StringMatrix value must be a list, got {type(self.value)}")

        result = []
        for row in self.value:
            if isinstance(row, (list, tuple)):
                result.append([str(cell) for cell in row])
            else:
                raise ValueError(f"StringMatrix rows must be list or tuple, got {type(row)}")

        return result

    def to_dict(self) -> dict:
        """
        Serialize Input to PIT JSON format.

        Returns:
            Dictionary ready for JSON serialization

        Raises:
            NotImplementedError: For input types not yet implemented
        """
        # Convert value to JSON-serializable format
        serialized_value = self.value

        # If value is a FormulaExpression, convert to string
        if isinstance(self.value, FormulaExpression):
            serialized_value = self.value.to_string()
        # If value is an Enum constant, convert to its string value
        elif isinstance(self.value, Enum):
            serialized_value = self.value.value
        # PIT compiler expects all numerical values as strings
        elif isinstance(self.value, (int, float)):
            serialized_value = str(self.value)

        out_dict = {"name": self.name, "type": self.type.value}
        # Route to appropriate serialization method based on input type
        if self.type == InputType.STRING or self.type == InputType.DATE:
            out_dict["value"] = serialized_value
        elif self.type == InputType.SCHEDULE:
            out_dict["value"] = self._serialize_schedule()
        elif self.type == InputType.CURVE:
            out_dict["value"] = self._serialize_curve()
        elif self.type == InputType.CREDIT_BASKET:
            out_dict["value"] = self._serialize_basket()
        elif self.type == InputType.DATE_TABLE:
            out_dict["value"] = self._serialize_datetable()
        elif self.type == InputType.STRING_MATRIX:
            out_dict["value"] = self._serialize_string_matrix()
        else:
            # Not yet implemented types (should not happen with current InputType enum)
            raise NotImplementedError(f"Serialization for input type '{str(self.type)}' " f"is not yet implemented.")
        return out_dict


class Schedule:
    """Represents a schedule (row) in the payoff table"""

    def __init__(
        self, id: str, schedule_type: ScheduleType, description: Union[str, FormulaExpression], payoff: "Payoff"
    ):
        self.id = id
        self.schedule_type = schedule_type
        if isinstance(description, FormulaExpression):
            description = description.to_string()
        self.description = description
        self._payoff = payoff

    def __getitem__(self, variable: "Variable") -> "CellAccessor":
        """Enable schedule[variable] syntax to set a cell value"""
        return CellAccessor(self._payoff, variable, self)

    def __setitem__(self, variable: "Variable", formula: Union[str, FormulaExpression]):
        """Enable schedule[variable] = formula syntax"""
        if isinstance(formula, FormulaExpression):
            formula = formula.to_string()
        variable.values[self.id] = formula


class Variable(FormulaExpression):
    """Represents a variable (column) in the payoff table"""

    def __init__(self, name: str, payoff: "Payoff"):
        self.name = name
        self.values: Dict[str, str] = {}
        self._payoff = payoff

    def to_string(self) -> str:
        """When used in a formula, reference with [t] notation"""
        return f"{self.name}"

    def __getitem__(self, schedule: Schedule) -> "CellAccessor":
        """Enable variable[schedule] syntax to set a cell value"""
        return CellAccessor(self._payoff, self, schedule)

    def __getitem__(self, formula: Union[str, FormulaExpression]) -> FormulaExpression:
        """Enable variable[t] syntax to set a cell value"""
        return Literal(f"{self.name}[{formula.to_string() if isinstance(formula, FormulaExpression) else formula}]")

    def __setitem__(self, schedule: Schedule, formula: Union[str, FormulaExpression]):
        """Enable variable[schedule] = formula syntax"""
        if isinstance(formula, FormulaExpression):
            formula = formula.to_string()
        self.values[schedule.id] = formula


class CellAccessor:
    """Helper class to enable both variable[schedule] = value and schedule[variable] = value"""

    def __init__(self, payoff: "Payoff", variable: Variable, schedule: Schedule):
        self._payoff = payoff
        self._variable = variable
        self._schedule = schedule

    def __eq__(self, formula: Union[str, FormulaExpression]):
        """Enable assignment via == operator (for chaining)"""
        if isinstance(formula, FormulaExpression):
            formula = formula.to_string()
        self._variable.values[self._schedule.id] = formula
        return self


# ==================== Main Builder Class ====================


class Payoff:
    """Fluent API for building payoff tables"""

    # TODO: BREAKING CHANGE - Make name, product_id, and product_ccy mandatory parameters in v2.0
    # Requires team discussion before implementation
    def __init__(self, name: str = "", product_id: str = "", product_ccy: str = "USD"):
        """
        Initialize a Payoff builder.

        Args:
            name: Product name (e.g., "EUR 5Y Swap")
            product_id: Product identifier (e.g., "SWAP_001")
            product_ccy: Product currency code (e.g., "USD", "GBP", "EUR")

        Note:
            These parameters are currently optional but will become mandatory
            in a future release. Please provide explicit values.
        """
        self.name: str = name
        self.product_id: str = product_id
        self.product_ccy: str = product_ccy
        self.inputs: Dict[str, Input] = {}
        self.schedules: List[Schedule] = []
        self.variables: List[Variable] = []
        self._schedule_dict: Dict[str, Schedule] = {}
        self._variable_dict: Dict[str, Variable] = {}
        self._default_value: str = ""

    def add_input(
        self,
        name: str,
        value: Union[
            str,
            int,
            float,
            list,
            datetime,
            date,
            Frequency,
            DayCount,
            Calendar,
            ResetTiming,
            Stub,
            DateRule,
            PayTiming,
            RfrLag,
            Index,
            PayReceive,
        ],
        type: InputType = InputType.STRING,
        date_format: str = "DD-MMM-YYYY",
    ) -> Input:
        """
        Add an input definition and return the Input object.

        Args:
            name: Input name
            value: Input value (type is inferred from value, including enum types)
            type: InputType enum (e.g., InputType.STRING, InputType.CURVE)
            date_format: Date format for Schedule type validation (DD-MMM-YYYY, YYYY-MM-DD, DD/MM/YYYY)

        Returns:
            Input object that can be used directly in formulas

        Example:
            fixed_rate = payoff.add_input("FixedRate", 0.02)
            curve = payoff.add_input("DiscountCurve", "USD_SOFR", type=InputType.CURVE)
            # Use directly: fixed_rate * notional

        Warning:
            Do not reuse the same Python variable for different inputs:

            # ❌ BAD - loses reference to FixedRate
            rate = payoff.add_input("FixedRate", 0.02)
            rate = payoff.add_input("FloatRate", 0.01)  # Lost FixedRate reference!

            # ✅ GOOD - use different variables
            fixed_rate = payoff.add_input("FixedRate", 0.02)
            float_rate = payoff.add_input("FloatRate", 0.01)
        """
        # Check for duplicate input names
        if name in self.inputs:
            raise ValueError(f"Input '{name}' already exists. Cannot add the same input twice.")

        # Convert datetime/date objects to DD/MM/YYYY format (PIT standard)
        stored_value = value
        if isinstance(value, datetime):
            stored_value = value.strftime("%d/%m/%Y")
        elif isinstance(value, date):
            stored_value = value.strftime("%d/%m/%Y")
        elif isinstance(value, list):
            if value and isinstance(value[0], (datetime, date)):
                stored_value = [d.strftime("%d/%m/%Y") if isinstance(d, (datetime, date)) else d for d in value]

        # Create Input object
        input_obj = Input(name, stored_value, self, type, date_format)
        self.inputs[name] = input_obj

        return input_obj

    def add_input_frequency(self, name: str, value: Frequency) -> Input:
        """
        Add a Frequency input with IDE auto-completion support.

        Args:
            name: Input name
            value: Frequency enum value (enum required)

        Example:
            freq = payoff.add_input_frequency("FixedFrequency", Frequency.ANNUAL)
        """
        if hasattr(value, "value"):  # Handle Enum
            value = value.value
        return self.add_input(name, str(value))

    def add_input_daycount(self, name: str, value: DayCount) -> Input:
        """
        Add a DayCount input with IDE auto-completion support.

        Args:
            name: Input name
            value: DayCount enum value (enum required)

        Example:
            dc = payoff.add_input_daycount("FixedDayCount", DayCount.ACTUAL_365)
        """
        if hasattr(value, "value"):  # Handle Enum
            value = value.value
        return self.add_input(name, str(value))

    def add_input_calendar(self, name: str, value: Calendar) -> Input:
        """
        Add a Calendar input with IDE auto-completion support.

        Args:
            name: Input name
            value: Calendar enum value (enum required)

        Example:
            cal = payoff.add_input_calendar("Calendar", Calendar.TARGET)
        """
        if hasattr(value, "value"):  # Handle Enum
            value = value.value
        return self.add_input(name, str(value))

    def add_input_reset_timing(self, name: str, value: ResetTiming) -> Input:
        """
        Add a ResetTiming input with IDE auto-completion support.

        Args:
            name: Input name
            value: ResetTiming enum value (enum required)

        Example:
            rt = payoff.add_input_reset_timing("ResetTiming", ResetTiming.ADVANCE)
        """
        if hasattr(value, "value"):  # Handle Enum
            value = value.value
        return self.add_input(name, str(value))

    def add_input_stub(self, name: str, value: Stub) -> Input:
        """
        Add a Stub input with IDE auto-completion support.

        Args:
            name: Input name
            value: Stub enum value (enum required)

        Example:
            stub = payoff.add_input_stub("StubRule", Stub.SHORT_START)
        """
        if hasattr(value, "value"):  # Handle Enum
            value = value.value
        return self.add_input(name, str(value))

    def add_input_date_rule(self, name: str, value: DateRule) -> Input:
        """
        Add a DateRule input with IDE auto-completion support.

        Args:
            name: Input name
            value: DateRule enum value (enum required)

        Example:
            dr = payoff.add_input_date_rule("DateRule", DateRule.MODIFIED_FOLLOWING)
        """
        if hasattr(value, "value"):  # Handle Enum
            value = value.value
        return self.add_input(name, str(value))

    def add_input_pay_timing(self, name: str, value: PayTiming) -> Input:
        """
        Add a PayTiming input with IDE auto-completion support.

        Args:
            name: Input name
            value: PayTiming enum value (enum required)

        Example:
            pt = payoff.add_input_pay_timing("PayTiming", PayTiming.ARREARS)
        """
        if hasattr(value, "value"):  # Handle Enum
            value = value.value
        return self.add_input(name, str(value))

    def add_input_ir_index_type(self, name: str, value: Index) -> Input:
        """
        Add an Index input with IDE auto-completion support.

        Args:
            name: Input name
            value: Index enum value (enum required)

        Example:
            idx = payoff.add_input_ir_index_type("FloatType", Index.RFR)
        """
        if hasattr(value, "value"):  # Handle Enum
            value = value.value
        return self.add_input(name, str(value))

    def add_input_pay_receive(self, name: str, value: PayReceive) -> Input:
        """
        Add a PayReceive input with IDE auto-completion support.

        Args:
            name: Input name
            value: PayReceive enum value (enum required)

        Example:
            pr = payoff.add_input_pay_receive("PayReceive", PayReceive.PAY)
        """
        if hasattr(value, "value"):  # Handle Enum
            value = value.value
        return self.add_input(name, str(value))

    def add_input_schedule(self, name: str, value: List[List], date_format: str = "DD-MMM-YYYY") -> Schedule:
        """
        Add a hard-coded schedule input that can be referenced in formulas.

        This creates both:
        1. An Input (for JSON serialization with type="schedule")
        2. A Schedule object (for formula reference like DateTable schedules)

        PIT Requirement: A Schedule MUST have exactly 4 columns:
        - Column 1: Accrual Start Date
        - Column 2: Payment Date
        - Column 3: Reset Start Date
        - Column 4: Reset End Date

        Args:
            name: Schedule name (used for both Input and formula reference)
            value: List of lists containing dates (as Python date objects or formatted strings).
                   Each row must have exactly 4 date columns.
            date_format: Date string format - "DD-MMM-YYYY" (default), "YYYY-MM-DD", or "DD/MM/YYYY"

        Returns:
            Schedule object that can be used in formulas like DateTable schedules

        Example:
            # Create hard-coded schedule
            payment_schedule = payoff.add_input_schedule("PaymentDates", [
                ["01-JAN-2025", "01-FEB-2025", "01-JAN-2025", "01-FEB-2025"],
                ["01-JAN-2026", "01-FEB-2026", "01-JAN-2026", "01-FEB-2026"]
            ])

            # Use in formulas (same as DateTable schedules)
            cash_flow = payoff.add_variable("CashFlow")
            cash_flow[payment_schedule] = F.notional() * F.rate()

            # Or with Python date objects
            from datetime import date
            schedule = payoff.add_input_schedule("PaymentDates", [
                [date(2025, 1, 1), date(2025, 2, 1), date(2025, 1, 1), date(2025, 2, 1)],
                [date(2026, 1, 1), date(2026, 2, 1), date(2026, 1, 1), date(2026, 2, 1)]
            ])
        """
        # Create the Input (for JSON serialization)
        self.add_input(name, value, type=InputType.SCHEDULE, date_format=date_format)

        # Create a Schedule object (for formula reference)
        # Description is just the name - will serialize as "OnUserSchedule | name"
        schedule = Schedule(id=name, schedule_type=ScheduleType.ON_USER_SCHEDULE, description=name, payoff=self)

        # Add to schedules list
        if name in self._schedule_dict:
            raise ValueError(f"Schedule ID '{name}' already exists")

        self.schedules.append(schedule)
        self._schedule_dict[name] = schedule

        return schedule

    def add_input_curve(self, name: str, value: List[List]) -> Input:
        """
        Add a Curve input (matrix of tenor/value pairs).

        A Curve represents market data as tenor/value pairs, typically:
        - Column 1: Tenor (e.g., "1Y", "2Y", "5Y")
        - Column 2: Value (e.g., rate, spread, volatility)

        Args:
            name: Input name
            value: List of lists containing [tenor, value] pairs

        Example:
            curve = payoff.add_input_curve("VolCurve", [
                ["1Y", "0.15"],
                ["2Y", "0.18"],
                ["5Y", "0.22"]
            ])
        """
        return self.add_input(name, value, type=InputType.CURVE)

    def add_schedule(
        self,
        schedule_id: str,
        schedule_type: Optional[Union[ScheduleType, str]] = None,
        description: Optional[Union[str, FormulaExpression]] = None,
        dates: Optional[List[List]] = None,
        date_format: Optional[str] = "DD-MMM-YYYY",
    ) -> Schedule:
        """
        Add a schedule (row) and return the Schedule object.

        Args:
            schedule_id: Unique identifier for this schedule
            schedule_type: Type of schedule, optional if explict dates are provided
            description: Schedule description, optional if explicit dates are provided
            dates: Optional list of lists containing explicit schedule dates (same format as add_input_schedule).

        Returns:
            Schedule object that can be used to set cell values

        Example:
            strike = payoff.add_schedule("strike", ScheduleType.AT_DATE, "2025-01-15")
            # Use: variable[strike] = formula
        """
        if isinstance(schedule_type, str):
            schedule_type = ScheduleType(schedule_type)

        # Check for duplicate schedule ID
        if schedule_id in self._schedule_dict:
            raise ValueError(f"Schedule ID '{schedule_id}' already exists")

        if dates:
            # Create the Input (for JSON serialization)
            self.add_input(schedule_id, dates, type=InputType.SCHEDULE, date_format=date_format)
            schedule = Schedule(
                id=schedule_id, schedule_type=ScheduleType.ON_USER_SCHEDULE, description=schedule_id, payoff=self
            )
        else:
            if not schedule_type or not description:
                raise ValueError("schedule_type and description are required if explicit dates are not provided")
            schedule = Schedule(schedule_id, schedule_type, description, self)

        self.schedules.append(schedule)
        self._schedule_dict[schedule_id] = schedule

        # Initialize all existing variables with default value for this schedule
        for var in self.variables:
            if schedule_id not in var.values:
                var.values[schedule_id] = self._default_value

        return schedule

    def add_variable(self, name: str) -> Variable:
        """
        Add a variable (column) and return the Variable object.

        Args:
            name: Variable name

        Returns:
            Variable object that can be used to set cell values or in formulas

        Example:
            price = payoff.add_variable("Price")
            price[strike] = F.eq_spot("Underlying")
            # Or use in formula: price * 2
        """
        if name in self._variable_dict:
            raise ValueError(f"Variable '{name}' already exists")

        variable = Variable(name, self)

        # Initialize with default value for all existing schedules
        for schedule in self.schedules:
            variable.values[schedule.id] = self._default_value

        self.variables.append(variable)
        self._variable_dict[name] = variable

        return variable

    def __setitem__(self, key: tuple, formula: Union[str, FormulaExpression]):
        """
        Enable payoff[schedule, variable] = formula syntax

        Args:
            key: Tuple of (schedule, variable) or (variable, schedule)
            formula: Formula to assign
        """
        if not isinstance(key, tuple) or len(key) != 2:
            raise ValueError("Expected payoff[schedule, variable] or payoff[variable, schedule]")

        obj1, obj2 = key

        # Determine which is schedule and which is variable
        if isinstance(obj1, Schedule) and isinstance(obj2, Variable):
            schedule, variable = obj1, obj2
        elif isinstance(obj1, Variable) and isinstance(obj2, Schedule):
            variable, schedule = obj1, obj2
        else:
            raise ValueError("Expected Schedule and Variable objects")

        if isinstance(formula, FormulaExpression):
            formula = formula.to_string()

        variable.values[schedule.id] = formula

    def build_payoff(self) -> List[List[str]]:
        """Build the final payoff_description table"""
        result = []

        # Header row
        header = ["Schedule Type", "Schedule Description"] + [v.name for v in self.variables]
        result.append(header)

        # Data rows
        for schedule in self.schedules:
            row = [schedule.schedule_type.value, schedule.description]
            for var in self.variables:
                row.append(var.values.get(schedule.id, ""))
            result.append(row)

        return result

    def to_description_json(self, file_path: str) -> None:
        """
        Write the payoff table to a JSON file.

        Creates the file if it doesn't exist, overwrites if it does.

        Args:
            file_path: Path to the JSON file to write

        Example:
            payoff.to_description_json("output/my_product.json")
        """
        result = self.build_payoff()

        # Create parent directories if they don't exist
        path = Path(file_path)
        path.parent.mkdir(parents=True, exist_ok=True)

        # Write JSON with proper object structure
        with open(path, "w", encoding="utf-8") as f:
            json.dump({"description": result}, f, indent=2, ensure_ascii=False)

    def build_inputs(self) -> List[dict]:
        """
        Serialize all inputs to PIT JSON format.

        Returns:
            List of input dictionaries ready for JSON serialization

        Raises:
            NotImplementedError: If any input uses an unimplemented type
        """
        return [input_obj.to_dict() for input_obj in self.inputs.values()]

    def to_json(
        self,
        file_path: str,
        ccy: Optional[str] = None,
        valuation_date: Optional[str] = None,
        header: Optional[dict] = None,
    ) -> None:
        """
        Serialize the entire payoff to PIT compiler JSON format.

        Args:
            file_path: Path to output JSON file
            ccy: Currency code (default: uses self.product_ccy)
            valuation_date: Valuation date in DD/MM/YYYY format (default: today)
            header: Optional header dict (default: basic header with Verbose=Yes)

        Example:
            payoff.to_json("output/product.json")
            payoff.to_json("output/product.json", ccy="GBP")  # Override currency

        Raises:
            ValueError: If required fields (product_id, product_ccy) are missing
            NotImplementedError: If any input uses an unimplemented type
        """
        # Validate required fields for PIT compiler
        if not self.product_id:
            raise ValueError(
                "product_id cannot be empty. It is required by the PIT compiler. "
                "Please set it in the Payoff constructor: Payoff(product_id='...')"
            )

        # Determine final ccy value
        final_ccy = ccy if ccy is not None else self.product_ccy
        if not final_ccy:
            raise ValueError(
                "product_ccy cannot be empty. It is required by the PIT compiler. "
                "Please set it in the Payoff constructor: Payoff(product_ccy='USD') "
                "or pass it as parameter: to_json('file.json', ccy='USD')"
            )

        # Build inputs array
        inputs_array = self.build_inputs()

        # Build description table
        description_table = self.build_payoff()

        # Use product_ccy if ccy not specified
        if ccy is None:
            ccy = self.product_ccy

        # Default valuation date
        if valuation_date is None:
            valuation_date = datetime.now().strftime("%d/%m/%Y")

        # Default header
        if header is None:
            header = {
                "Verbose": "Yes",
                "VaR": "No",
                "Cashflow": "No",
                "CashflowByColumn": "No",
                "Version": "Yes",
                "Greeks": "No",
            }

        # Construct full PIT JSON structure
        pit_request = {
            "valuationDate": {"value": valuation_date, "dateFormat": ""},
            "instrumentType": "Derivatives",
            "header": header,
            "instrumentDefinition": {
                "ccy": ccy,
                "Id": self.product_id,
                "inputs": inputs_array,
                "description": description_table,
            },
        }

        # Write to file
        path = Path(file_path)
        path.parent.mkdir(parents=True, exist_ok=True)

        with open(path, "w", encoding="utf-8") as f:
            json.dump(pit_request, f, indent=2, ensure_ascii=False)

    def compile(
        self,
        input_json: str,
        exe_path: Optional[str] = None,
        open_html: bool = False,
        title: str = "Object Browser",
        html_only: bool = False,
    ) -> Union[str, CompileResult]:
        """
        Temporary QPS-pending compile entrypoint.

        Local binary compilation is intentionally disabled for extension compatibility.

        Args:
            input_json: Path to PIT JSON input file.
            exe_path: Reserved for future use.
            open_html: Reserved for future use.
            title: Reserved for future use.
            html_only: Reserved for future use.

        Returns:
            CompileResult indicating that compile will be provided via QPS.
        """
        return CompileResult(
            success=False,
            has_compiler_error=False,
            message="Compiling will be available via QPS request.",
            xml_path="",
            html_path="",
        )

    def dump(
        self,
        input_json: str,
        exe_path: Optional[str] = None,
        open_html: bool = False,
        title: str = "Object Browser",
        html_only: bool = False,
    ) -> CompileResult:
        """
        Temporary QPS-pending dump entrypoint.

        Local binary dump is intentionally disabled for extension compatibility.

        Signature intentionally matches compile() for API consistency.

        Args:
            input_json: Path to PIT JSON input file.
            exe_path: Reserved for future use.
            open_html: Reserved for future use.
            title: Reserved for future use.
            html_only: Reserved for future use.

        Returns:
            CompileResult indicating that dump will be provided via QPS.
        """
        return CompileResult(
            success=False,
            has_compiler_error=False,
            message="Dumping will be available via QPS request.",
            xml_path="",
            html_path="",
        )

    def preview(self) -> str:
        """Preview the table in a readable format"""
        table = self.build_payoff()

        if not table:
            return "Empty table"

        # Calculate column widths
        col_widths = [max(len(str(row[i])) for row in table) + 2 for i in range(len(table[0]))]

        lines = []
        lines.append("=" * sum(col_widths))

        for i, row in enumerate(table):
            line = " | ".join(str(cell).ljust(col_widths[j]) for j, cell in enumerate(row))
            lines.append(line)
            if i == 0:  # After header
                lines.append("-" * sum(col_widths))

        lines.append("=" * sum(col_widths))
        return "\n".join(lines)
