"""
Comparison chart implementation for synastry, transits, and progressions.

This module provides a unified interface for comparing two charts:
- Synastry: Two natal charts (relationship analysis)
- Transits: Natal chart + current sky positions (timing analysis)
- Progressions: Progressed chart + natal chart (symbolic timing)

The Comparison class mimics CalculatedChart's interface while providing
cross-chart analysis capabilities.

Configuration:
--------------
Uses AspectEngine + OrbEngine for aspect calculations:

**AspectEngine:**
- Determines which aspects to calculate (via AspectConfig)
- CrossChartAspectEngine for cross-chart aspects (chart1 × chart2)
- ModernAspectEngine for internal aspects (if charts lack them)

**OrbEngine:**
- Determines orb allowances for each aspect
- Defaults are comparison-type specific:
  - Synastry: 6°/4° (moderate - connections matter)
  - Transits: 3°/2° (tight - timing precision)
  - Progressions: 1° (very tight - symbolic timing)

Builder Methods:
----------------
**Cross-chart aspects:**
- .with_aspect_engine(engine) - Custom CrossChartAspectEngine
- .with_orb_engine(engine) - Custom orb allowances

**Internal (natal) aspects:**
- .with_internal_aspect_engine(engine) - Engine for chart1/chart2 internal aspects
- .with_internal_orb_engine(engine) - Orbs for internal aspects

**House overlays:**
- .without_house_overlays() - Disable house overlay calculation
"""

import datetime as dt
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Literal

from stellium.core.builder import ChartBuilder
from stellium.core.config import AspectConfig
from stellium.core.models import (
    Aspect,
    CalculatedChart,
    CelestialPosition,
    ChartDateTime,
    ChartLocation,
    ComparisonAspect,
    ComparisonType,
    HouseCusps,
    HouseOverlay,
    ObjectType,
)
from stellium.core.native import Native
from stellium.core.protocols import OrbEngine


@dataclass(frozen=True)
class Comparison:
    """
    Comparison between two charts (synastry or transits).

    This class mimics CalculatedChart's interface while providing
    cross-chart analysis. It holds two complete charts and calculates
    their interactions.
    """

    # Chart identification
    comparison_type: ComparisonType

    # The two charts being compared
    chart1: CalculatedChart  # Native/Person A/Inner circle
    chart2: CalculatedChart  # Transit/Person B/Outer circle

    # Calculated comparison data
    cross_aspects: tuple[ComparisonAspect, ...] = ()
    house_overlays: tuple[HouseOverlay, ...] = ()

    # Optional labels for reports
    chart1_label: str = "Native"
    chart2_label: str = "Other"

    # Metadata
    calculation_timestamp: datetime = field(
        default_factory=lambda: datetime.now(dt.UTC)
    )

    # ===== Chart1 (Native/Inner) Convenience Properties =====
    @property
    def datetime(self) -> ChartDateTime:
        """Primary chart datetime (chart1/native)."""
        return self.chart1.datetime

    @property
    def location(self) -> ChartLocation:
        """Primary chart location (chart1/native)."""
        return self.chart1.location

    @property
    def positions(self) -> tuple[CelestialPosition, ...]:
        """Primary chart positions (chart1/native)."""
        return self.chart1.positions

    @property
    def houses(self) -> HouseCusps:
        """Primary chart houses (chart1/native)."""
        return self.chart1.house_systems[self.chart1.default_house_system]

    @property
    def aspects(self) -> tuple[Aspect, ...]:
        """Primary chart's natal aspects (chart1 internal)."""
        return self.chart1.aspects

    # ===== Chart2 (Partner/Transit/Outer) Properties =====

    @property
    def chart2_datetime(self) -> ChartDateTime:
        """Secondary chart datetime."""
        return self.chart2.datetime

    @property
    def chart2_location(self) -> ChartLocation:
        """Secondary chart location."""
        return self.chart2.location

    @property
    def chart2_positions(self) -> tuple[CelestialPosition, ...]:
        """Secondary chart positions."""
        return self.chart2.positions

    @property
    def chart2_houses(self) -> HouseCusps:
        """Secondary chart houses."""
        return self.chart2.house_systems[self.chart2.default_house_system]

    @property
    def chart2_aspects(self) -> tuple[Aspect, ...]:
        """Secondary chart's internal aspects."""
        return self.chart2.aspects

    # ===== Query Methods (mimic CalculatedChart interface) =====

    def get_object(
        self, name: str, chart: Literal[1, 2] = 1
    ) -> CelestialPosition | None:
        """
        Get a celestial object by name from either chart.

        Args:
            name: Object name (e.g., "Sun", "Moon")
            from_chart: Which chart to get from

        Returns:
            CelestialPosition or None
        """
        retrieved_chart = self.chart1 if chart == 1 else self.chart2
        return retrieved_chart.get_object(name)

    def get_planets(self, chart: Literal[1, 2] = 1) -> list[CelestialPosition]:
        """Get all planetary objects from specified chart."""
        retrieved_chart = self.chart1 if chart == 1 else self.chart2
        return retrieved_chart.get_planets()

    def get_angles(self, chart: Literal[1, 2] = 1) -> list[CelestialPosition]:
        """Get all chart angles from specified chart."""
        retrieved_chart = self.chart1 if chart == 1 else self.chart2
        return retrieved_chart.get_angles()

    # ===== Comparison-Specific Query Methods =====

    def get_object_aspects(
        self, object_name: str, chart: Literal[1, 2] = 1
    ) -> list[ComparisonAspect]:
        """
        Get all cross-chart aspects involving a specific object.

        Args:
            object_name: Name of the object
            chart: Which chart the object belongs to

        Returns:
            List of ComparisonAspect objects
        """
        return [
            asp
            for asp in self.cross_aspects
            if (chart == "chart1" and asp.object1.name == object_name)
            or (chart == "chart2" and asp.object2.name == object_name)
        ]

    def get_object_houses(
        self, object_name: str, chart: Literal[1, 2] = 1
    ) -> list[HouseOverlay]:
        """
        Get house overlays for a specific planet.

        Args:
            planet_name: Planet name
            planet_owner: Which chart owns the planet

        Returns:
            List of HouseOverlay objects
        """
        return [
            overlay
            for overlay in self.house_overlays
            if overlay.planet_name == object_name
            and overlay.planet_owner == f"chart{chart}"
        ]

    def get_objects_in_house(
        self,
        house_number: int,
        house_owner: Literal[1, 2],
        planet_owner: Literal[1, 2, "both"] = "both",
    ) -> list[HouseOverlay]:
        """
        Get all planets falling in a specific house.

        Args:
            house_number: House number (1-12)
            house_owner: Whose house system to use
            planet_owner: Whose planets to check (or "both")

        Returns:
            List of HouseOverlay objects
        """
        overlays = [
            overlay
            for overlay in self.house_overlays
            if overlay.falls_in_house == house_number
            and overlay.house_owner == f"chart{house_owner}"
        ]

        if planet_owner != "both":
            overlays = [o for o in overlays if o.planet_owner == f"chart{house_owner}"]

        return overlays

    # ===== Compatibility Scoring (for synastry) =====
    def calculate_compatibility_score(
        self, weights: dict[str, float] | None = None
    ) -> float:
        """
        Calculate a simple compatibility score based on aspects.

        This is a basic implementation - users can implement their own
        weighting schemes.

        Args:
            weights: Optional custom weights for aspect types

        Returns:
            Compatibility score (0-100)
        """
        if weights is None:
            # Default weights: harmonious positive, challenging neutral/negative
            weights = {
                "Conjunction": 0.5,  # Neutral (depends on planets)
                "Sextile": 1.0,  # Harmonious
                "Square": -0.5,  # Challenging
                "Trine": 1.0,  # Harmonious
                "Opposition": -0.3,  # Challenging but connecting
            }

        total_score = 0.0
        max_possible = len(self.cross_aspects)  # Each aspect could be +1

        if max_possible == 0:
            return 50.0  # Neutral if no aspects

        for aspect in self.cross_aspects:
            weight = weights.get(aspect.aspect_name, 0.0)

            # Tighter orbs are stronger
            orb_strength = 1.0 - (aspect.orb / 10.0)  # Assume max 10° orb
            orb_strength = max(0.0, min(1.0, orb_strength))

            total_score += weight * orb_strength

        # Normalize to 0-100 scale
        # Assuming average score per aspect ranges from -0.5 to 1.0
        normalized = ((total_score / max_possible) + 0.5) / 1.5 * 100
        return max(0.0, min(100.0, normalized))

    def draw(self, filename: str = "synastry.svg") -> "ChartDrawBuilder":
        """
        Start building a comparison chart visualization with fluent API.

        This is a convenience method that creates a ChartDrawBuilder for
        easy, discoverable comparison chart visualization. It provides
        synastry-specific presets and a fluent interface for customization.

        Args:
            filename: Output filename for the SVG

        Returns:
            ChartDrawBuilder instance for chaining

        Example:
            # Simple synastry preset
            comparison.draw("synastry.svg").preset_synastry().save()

            # Custom configuration
            comparison.draw("custom.svg") \\
                .with_theme("celestial") \\
                .with_moon_phase(position="top-left") \\
                .with_chart_info(position="top-right") \\
                .save()
        """
        from stellium.visualization.builder import ChartDrawBuilder

        return ChartDrawBuilder(self).with_filename(filename)

    def to_dict(self) -> dict[str, Any]:
        """
        Serialize to dictionary for JSON export.

        Returns:
            Dictionary with full comparison data
        """
        return {
            "comparison_type": self.comparison_type.value,
            "chart1_label": self.chart1_label,
            "chart2_label": self.chart2_label,
            "chart1": self.chart1.to_dict(),
            "chart2": self.chart2.to_dict(),
            "cross_aspects": [
                {
                    "object1": asp.object1.name,
                    "object1_chart": "chart1",
                    "object2": asp.object2.name,
                    "object2_chart": "chart2",
                    "aspect": asp.aspect_name,
                    "orb": asp.orb,
                    "is_applying": asp.is_applying,
                    "in_chart1_house": asp.in_chart1_house,
                    "in_chart2_house": asp.in_chart2_house,
                }
                for asp in self.cross_aspects
            ],
            "house_overlays": [
                {
                    "planet": overlay.planet_name,
                    "planet_owner": overlay.planet_owner,
                    "house": overlay.falls_in_house,
                    "house_owner": overlay.house_owner,
                }
                for overlay in self.house_overlays
            ],
        }


# ===== Builder Class with Fluent Interface =====
class ComparisonBuilder:
    """
    Fluent builder for creating Comparison objects.

    Provides convenient construction methods for both synastry and transits:

    For synastry:
        comp = ComparisonBuilder.from_native(chart1) \\
            .with_partner(chart2) \\
            .calculate()

    For transits:
        comp = ComparisonBuilder.from_native(natal_chart) \\
            .with_transit(transit_datetime, transit_location) \\
            .calculate()
    """

    def __init__(
        self,
        chart1: CalculatedChart,
        comparison_type: ComparisonType,
        chart1_label: str = "Native",
    ):
        """
        Initialize builder with the primary chart.

        Args:
            chart1: The native/primary chart (inner circle)
            comparison_type: Type of comparison
            chart1_label: Label for chart1 in reports
        """
        self._chart1 = chart1
        self._comparison_type = comparison_type
        self._chart1_label = chart1_label

        self._chart2: CalculatedChart | None = None
        self._chart2_label: str = "Other"

        # Cross-chart aspect configuration
        self._aspect_engine = None  # CrossChartAspectEngine or custom
        self._orb_engine: OrbEngine = self._get_default_comparison_orbs()

        # Internal (natal) aspect configuration for chart1/chart2
        # Used if charts don't already have aspects calculated
        self._internal_aspect_engine = None  # Default: ModernAspectEngine
        self._internal_orb_engine: OrbEngine | None = None  # Default: registry orbs

        # Other options
        self._make_house_overlays: bool = True

    # ===== Convenience Constructors =====
    @classmethod
    def from_native(
        cls, native_chart: CalculatedChart, native_label: str = "Native"
    ) -> "ComparisonBuilder":
        """
        Start building a comparison from a native chart.

        Use this when you have a CalculatedChart already.
        Chain with .with_partner() or .with_transit()

        Args:
            native_chart: The native/primary chart
            native_label: Label for the native chart

        Returns:
            ComparisonBuilder instance
        """
        # We don't know the type yet - will be set by with_partner/with_transit
        return cls(
            native_chart,
            ComparisonType.SYNASTRY,  # Default, will be overridden
            native_label,
        )

    @classmethod
    def compare(
        cls,
        data1: CalculatedChart | Native | tuple[str | dt.datetime | dict, str | tuple[float, float] | dict],
        data2: CalculatedChart | Native | tuple[str | dt.datetime | dict, str | tuple[float, float] | dict],
        comparison_type: str,
        chart1_label: str = "Chart 1",
        chart2_label: str = "Chart 2",
    ) -> "ComparisonBuilder":
        """
        General method for creating any type of comparison.

        This is the flexible method that accepts any combination of inputs and any
        comparison type. Convenience methods (.synastry(), .transit(), .progression())
        are thin wrappers that call this method with appropriate defaults.

        Args:
            data1: First chart data (CalculatedChart, Native, or (datetime, location) tuple)
            data2: Second chart data (CalculatedChart, Native, or (datetime, location) tuple)
            comparison_type: Type of comparison ("synastry", "transit", "progression")
            chart1_label: Label for first chart (default: "Chart 1")
            chart2_label: Label for second chart (default: "Chart 2")

        Returns:
            ComparisonBuilder instance ready to configure and calculate

        Examples:
            >>> # With Native objects
            >>> native1 = Native("1994-01-06 11:47", "Palo Alto, CA")
            >>> native2 = Native("2000-01-01 17:00", "Seattle, WA")
            >>> comparison = ComparisonBuilder.compare(native1, native2, "synastry").calculate()
            >>>
            >>> # With (datetime, location) tuples
            >>> comparison = ComparisonBuilder.compare(
            ...     ("1994-01-06 11:47", "Palo Alto, CA"),
            ...     ("2000-01-01 17:00", "Seattle, WA"),
            ...     "synastry"
            ... ).calculate()
            >>>
            >>> # Mixed inputs
            >>> comparison = ComparisonBuilder.compare(
            ...     native1,
            ...     ("2024-11-24 14:30", None),  # Uses chart1's location for transits
            ...     "transit"
            ... ).calculate()
        """
        # Convert comparison_type string to ComparisonType enum
        type_map = {
            "synastry": ComparisonType.SYNASTRY,
            "transit": ComparisonType.TRANSIT,
            "progression": ComparisonType.PROGRESSION,
        }
        comp_type = type_map.get(comparison_type.lower())
        if comp_type is None:
            raise ValueError(
                f"Invalid comparison type: '{comparison_type}'. "
                f"Must be one of: {', '.join(type_map.keys())}"
            )

        # Helper to convert data input to CalculatedChart
        def to_chart(data, location_fallback=None) -> CalculatedChart:
            if isinstance(data, CalculatedChart):
                return data
            elif isinstance(data, Native):
                return ChartBuilder.from_native(data).calculate()
            elif isinstance(data, tuple) and len(data) == 2:
                datetime_input, location_input = data
                # Handle None location (use fallback for transits)
                if location_input is None:
                    if location_fallback is None:
                        raise ValueError(
                            "Location cannot be None unless comparing to an existing chart "
                            "(e.g., for transits)"
                        )
                    location_input = location_fallback
                # Create Native internally (handles all parsing)
                native = Native(datetime_input, location_input)
                return ChartBuilder.from_native(native).calculate()
            else:
                raise TypeError(
                    f"Invalid data type: {type(data)}. "
                    f"Expected CalculatedChart, Native, or (datetime, location) tuple"
                )

        # Convert data1 first
        chart1 = to_chart(data1)

        # Convert data2 (with chart1's location as fallback for transits)
        chart2 = to_chart(data2, location_fallback=chart1.location)

        # Create builder with both charts configured
        builder = cls(chart1, comp_type, chart1_label)
        builder._chart2 = chart2
        builder._chart2_label = chart2_label

        return builder

    @classmethod
    def synastry(
        cls,
        data1: CalculatedChart | Native | tuple[str | dt.datetime | dict, str | tuple[float, float] | dict],
        data2: CalculatedChart | Native | tuple[str | dt.datetime | dict, str | tuple[float, float] | dict],
        chart1_label: str = "Person 1",
        chart2_label: str = "Person 2",
    ) -> "ComparisonBuilder":
        """
        Create a synastry comparison between two natal charts.

        Synastry analyzes the relationship between two people by comparing their
        birth charts. This is a convenience method that calls .compare() with
        comparison_type="synastry".

        Args:
            data1: First person's chart data (Native, CalculatedChart, or (datetime, location) tuple)
            data2: Second person's chart data (Native, CalculatedChart, or (datetime, location) tuple)
            chart1_label: Label for first person (default: "Person 1")
            chart2_label: Label for second person (default: "Person 2")

        Returns:
            ComparisonBuilder instance ready to configure and calculate

        Examples:
            >>> # Simple string inputs
            >>> comparison = ComparisonBuilder.synastry(
            ...     ("1994-01-06 11:47", "Palo Alto, CA"),
            ...     ("2000-01-01 17:00", "Seattle, WA")
            ... ).calculate()
            >>>
            >>> # With Native objects
            >>> native1 = Native("1994-01-06 11:47", "Palo Alto, CA")
            >>> native2 = Native("2000-01-01 17:00", "Seattle, WA")
            >>> comparison = ComparisonBuilder.synastry(native1, native2).calculate()
            >>>
            >>> # With custom labels
            >>> comparison = ComparisonBuilder.synastry(
            ...     ("1994-01-06 11:47", "Palo Alto, CA"),
            ...     ("2000-01-01 17:00", "Seattle, WA"),
            ...     chart1_label="Kate",
            ...     chart2_label="Partner"
            ... ).calculate()
        """
        return cls.compare(data1, data2, "synastry", chart1_label, chart2_label)

    @classmethod
    def transit(
        cls,
        natal_data: CalculatedChart | Native | tuple[str | dt.datetime | dict, str | tuple[float, float] | dict],
        transit_data: CalculatedChart | Native | tuple[str | dt.datetime | dict, str | tuple[float, float] | dict | None],
        natal_label: str = "Natal",
        transit_label: str = "Transit",
    ) -> "ComparisonBuilder":
        """
        Create a transit comparison (natal chart vs current sky positions).

        Transits analyze how current planetary positions interact with a natal chart
        for timing and prediction. This is a convenience method that calls .compare()
        with comparison_type="transit".

        Args:
            natal_data: Natal chart data (Native, CalculatedChart, or (datetime, location) tuple)
            transit_data: Transit time data. Can be:
                - (datetime, location) tuple
                - (datetime, None) to use natal location
                - Native or CalculatedChart
            natal_label: Label for natal chart (default: "Natal")
            transit_label: Label for transit chart (default: "Transit")

        Returns:
            ComparisonBuilder instance ready to configure and calculate

        Examples:
            >>> # Transit using natal location
            >>> comparison = ComparisonBuilder.transit(
            ...     ("1994-01-06 11:47", "Palo Alto, CA"),
            ...     ("2024-11-24 14:30", None)  # Uses Palo Alto
            ... ).calculate()
            >>>
            >>> # Transit with different location
            >>> comparison = ComparisonBuilder.transit(
            ...     ("1994-01-06 11:47", "Palo Alto, CA"),
            ...     ("2024-11-24 14:30", "New York, NY")
            ... ).calculate()
            >>>
            >>> # With Native object
            >>> natal = Native("1994-01-06 11:47", "Palo Alto, CA")
            >>> comparison = ComparisonBuilder.transit(
            ...     natal,
            ...     ("2024-11-24 14:30", None)
            ... ).calculate()
        """
        return cls.compare(natal_data, transit_data, "transit", natal_label, transit_label)

    @classmethod
    def progression(
        cls,
        natal_data: CalculatedChart | Native | tuple[str | dt.datetime | dict, str | tuple[float, float] | dict],
        progressed_data: CalculatedChart | Native | tuple[str | dt.datetime | dict, str | tuple[float, float] | dict],
        natal_label: str = "Natal",
        progressed_label: str = "Progressed",
    ) -> "ComparisonBuilder":
        """
        Create a progression comparison (progressed chart vs natal chart).

        Progressions use symbolic timing (1 day = 1 year) to analyze long-term
        development and growth. This is a convenience method that calls .compare()
        with comparison_type="progression".

        Args:
            natal_data: Natal chart data (Native, CalculatedChart, or (datetime, location) tuple)
            progressed_data: Progressed chart data (Native, CalculatedChart, or (datetime, location) tuple)
            natal_label: Label for natal chart (default: "Natal")
            progressed_label: Label for progressed chart (default: "Progressed")

        Returns:
            ComparisonBuilder instance ready to configure and calculate

        Examples:
            >>> # Simple progression (30 days after birth = age 30)
            >>> import datetime
            >>> from datetime import timedelta
            >>> birth_date = datetime.datetime(1994, 1, 6, 11, 47)
            >>> progressed_date = birth_date + timedelta(days=30)
            >>>
            >>> comparison = ComparisonBuilder.progression(
            ...     (birth_date, "Palo Alto, CA"),
            ...     (progressed_date, "Palo Alto, CA")
            ... ).calculate()
            >>>
            >>> # With string dates
            >>> comparison = ComparisonBuilder.progression(
            ...     ("1994-01-06 11:47", "Palo Alto, CA"),
            ...     ("1994-02-05 11:47", "Palo Alto, CA")  # 30 days later
            ... ).calculate()
        """
        return cls.compare(natal_data, progressed_data, "progression", natal_label, progressed_label)

    # ===== Configuration Methods =====
    def with_partner(
        self,
        partner_chart_or_datetime_or_native: CalculatedChart | datetime | Native,
        location: ChartLocation | None = None,
        partner_label: str = "Partner",
    ) -> "ComparisonBuilder":
        """
        Add partner chart for synastry comparison.

        Args:
            partner_chart_or_datetime: Either a CalculatedChart or datetime
            location: Required if providing datetime
            partner_label: Label for the partner chart

        Returns:
            Self for chaining
        """
        self._comparison_type = ComparisonType.SYNASTRY
        self._chart2_label = partner_label

        if isinstance(partner_chart_or_datetime_or_native, CalculatedChart):
            self._chart2 = partner_chart_or_datetime_or_native
        elif isinstance(partner_chart_or_datetime_or_native, Native):
            self._chart2 = ChartBuilder.from_native(
                partner_chart_or_datetime_or_native
            ).calculate()
        else:
            if location is None:
                raise ValueError(
                    "Location required when providing datetime for partner"
                )

            native2 = Native(partner_chart_or_datetime_or_native, location)
            self._chart2 = ChartBuilder.from_native(native2).calculate()

        return self

    def with_other(
        self,
        other_input: CalculatedChart | datetime | Native,
        location: ChartLocation | str | None = None,
        other_label: str = "Other",
        comparison_type: ComparisonType | None = None,
    ) -> "ComparisonBuilder":
        """
        Generic method to add second chart.

        This is a flexible alternative to with_partner() and with_transit().

        Args:
            other_input: Either a CalculatedChart, Native or datetime
            location: Required if providing datetime. ChartLocation or str place name
            other_label: Label for the other chart
            comparison_type: Optional comparison type (default: SYNASTRY)

        Returns:
            Self for chaining
        """
        if comparison_type is not None:
            self._comparison_type = comparison_type

        self._chart2_label = other_label

        if isinstance(other_input, CalculatedChart):
            self._chart2 = other_input
        elif isinstance(other_input, Native):
            self._chart2 = ChartBuilder.from_native(other_input).calculate()
        else:
            if location is None:
                # For transits, use native's location
                location = self._chart1.location

            native2 = Native(other_input, location)
            self._chart2 = ChartBuilder.from_native(native2).calculate()

        return self

    def with_transit(
        self, transit_datetime: datetime, location: ChartLocation | None = None
    ) -> "ComparisonBuilder":
        """
        Add transit chart for transit comparison.

        Convenience method that calls with_other() with appropriate settings.

        Args:
            transit_datetime: Transit datetime
            location: Optional location (defaults to native's location)

        Returns:
            Self for chaining
        """
        return self.with_other(
            transit_datetime,
            location or self._chart1.location,
            other_label="Transit",
            comparison_type=ComparisonType.TRANSIT,
        )

    def with_aspect_engine(self, engine) -> "ComparisonBuilder":
        """
        Set the aspect engine for cross-chart aspects.

        Args:
            engine: AspectEngine instance

        Returns:
            Self for chaining
        """
        self._aspect_engine = engine
        return self

    def with_orb_engine(self, engine) -> "ComparisonBuilder":
        """
        Set the orb calculation engine for dynamic orb calculation.

        OrbEngine will be used to calculate orbs for each planet pair
        dynamically (e.g., wider orbs for Sun/Moon, tighter for fast planets).

        If provided, OrbEngine takes precedence over AspectConfig.orbs.

        Examples:
            from stellium.engines.orbs import SimpleOrbEngine, LuminariesOrbEngine

            # Simple engine with fixed orbs per aspect
            simple = SimpleOrbEngine({'Conjunction': 8.0, 'Trine': 8.0})
            builder.with_orb_engine(simple)

            # Luminaries engine (wider orbs for Sun/Moon)
            lum = LuminariesOrbEngine()
            builder.with_orb_engine(lum)

        Args:
            engine: OrbEngine instance implementing get_orb_allowance()

        Returns:
            Self for chaining
        """
        self._orb_engine = engine
        return self

    def with_aspect_config(self, aspect_config: AspectConfig) -> "ComparisonBuilder":
        """
        Set aspect configuration (orbs, which aspects, etc.).

        Args:
            aspect_config: AspectConfig instance

        Returns:
            Self for chaining
        """
        self._aspect_config = aspect_config
        return self

    def without_house_overlays(self) -> "ComparisonBuilder":
        """
        Disable house overlay calculation.

        Returns:
            Self for chaining
        """
        self._make_house_overlays = False
        return self

    def with_internal_aspect_engine(self, engine) -> "ComparisonBuilder":
        """
        Set aspect engine for calculating internal (natal) aspects.

        This engine will be used to calculate aspects within chart1 and chart2
        if they don't already have aspects calculated. If not set, defaults
        to ModernAspectEngine().

        Args:
            engine: AspectEngine instance for internal aspects

        Returns:
            Self for chaining
        """
        self._internal_aspect_engine = engine
        return self

    def with_internal_orb_engine(self, engine: OrbEngine) -> "ComparisonBuilder":
        """
        Set orb engine for calculating internal (natal) aspects.

        This engine will be used for orb allowances when calculating
        internal aspects in chart1/chart2. If not set, defaults to
        SimpleOrbEngine with registry defaults.

        Args:
            engine: OrbEngine instance for internal aspect orbs

        Returns:
            Self for chaining
        """
        self._internal_orb_engine = engine
        return self

    def calculate(self) -> "Comparison":
        """
        Execute all calculations and return the final Comparison.

        This method ensures that both charts have their internal aspects
        calculated before computing cross-chart aspects. If a chart doesn't
        already have aspects, they will be calculated using the internal
        aspect engine configuration.

        Returns:
            Comparison object with all calculated data
        """
        if self._chart2 is None:
            raise ValueError(
                "Must set chart2 via with_partner(), with_transit(), or with_other()"
            )

        # Ensure chart1 has internal aspects calculated
        if not self._chart1.aspects:
            self._chart1 = self._ensure_internal_aspects(self._chart1)

        # Ensure chart2 has internal aspects calculated
        if not self._chart2.aspects:
            self._chart2 = self._ensure_internal_aspects(self._chart2)

        # Calculate cross-chart aspects
        cross_aspects = self._calculate_cross_aspects()

        # Calculate house overlays (if enabled)
        house_overlays = ()
        if self._make_house_overlays:
            house_overlays = self._calculate_house_overlays()

        return Comparison(
            comparison_type=self._comparison_type,
            chart1=self._chart1,
            chart2=self._chart2,
            cross_aspects=tuple(cross_aspects),
            house_overlays=house_overlays,
            chart1_label=self._chart1_label,
            chart2_label=self._chart2_label,
        )

    # ===== Private Calculation Methods =====

    def _get_default_comparison_orbs(self) -> OrbEngine:
        """
        Get default orb engine for cross-chart aspects.

        Comparison charts typically use different orb allowances than natal charts,
        depending on the type of comparison:

        - **Synastry (6°/4°):** Moderate orbs for finding strong connections
          between two people. We care about meaningful aspects, not just exact ones.

        - **Transits (3°/2°):** Tight orbs for precise timing. When does the
          transit actually perfect? Timing precision matters for prediction.

        - **Progressions (1°):** Very tight orbs for symbolic timing. In
          progressions, 1° of motion = 1 year of life, so precision is crucial.

        Returns:
            OrbEngine configured with appropriate defaults for this comparison type

        Note:
            These are defaults. Users can override with .with_orb_engine()
            for custom orb allowances.
        """
        from stellium.engines.orbs import SimpleOrbEngine

        if self._comparison_type == ComparisonType.SYNASTRY:
            # Synastry: Moderate orbs (connections matter, not super tight)
            orb_map = {
                "Conjunction": 6.0,
                "Sextile": 4.0,
                "Square": 6.0,
                "Trine": 6.0,
                "Opposition": 6.0,
                # Minor aspects
                "Semisextile": 2.0,
                "Semisquare": 2.0,
                "Sesquisquare": 2.0,
                "Quincunx": 3.0,
            }
        elif self._comparison_type == ComparisonType.TRANSIT:
            # Transits: Tight orbs (timing precision matters)
            orb_map = {
                "Conjunction": 3.0,
                "Sextile": 2.0,
                "Square": 3.0,
                "Trine": 3.0,
                "Opposition": 3.0,
                # Minor aspects (rarely used for transits, very tight)
                "Semisextile": 1.0,
                "Semisquare": 1.0,
                "Sesquisquare": 1.0,
                "Quincunx": 1.5,
            }
        elif self._comparison_type == ComparisonType.PROGRESSION:
            # Progressions: Very tight orbs (symbolic timing, 1° = 1 year)
            orb_map = {
                "Conjunction": 1.0,
                "Sextile": 1.0,
                "Square": 1.0,
                "Trine": 1.0,
                "Opposition": 1.0,
                # Minor aspects (rarely used in progressions)
                "Semisextile": 0.5,
                "Semisquare": 0.5,
                "Sesquisquare": 0.5,
                "Quincunx": 0.5,
            }
        else:
            # Fallback: moderate orbs
            orb_map = {
                "Conjunction": 6.0,
                "Sextile": 4.0,
                "Square": 6.0,
                "Trine": 6.0,
                "Opposition": 6.0,
            }

        return SimpleOrbEngine(orb_map=orb_map)

    def _get_orb_for_pair(
        self,
        obj1: CelestialPosition,
        obj2: CelestialPosition,
        aspect_name: str,
    ) -> float:
        """
        Get orb allowance for a specific planet pair and aspect.

        The orb engine is always present (initialized with comparison-type
        specific defaults), so we simply delegate to it.

        Args:
            obj1: First celestial position (from chart1)
            obj2: Second celestial position (from chart2)
            aspect_name: Name of aspect (e.g., "Trine", "Square")

        Returns:
            Orb allowance in degrees
        """
        # OrbEngine is always present (see __init__)
        return self._orb_engine.get_orb_allowance(obj1, obj2, aspect_name)

    def _ensure_internal_aspects(self, chart: CalculatedChart) -> CalculatedChart:
        """
        Ensure a chart has internal aspects calculated.

        If the chart doesn't have aspects, calculates them using the
        internal aspect engine and orb engine configuration.

        Args:
            chart: Chart to ensure has aspects

        Returns:
            Chart with aspects calculated (new instance if aspects were added)
        """
        from stellium.engines.aspects import ModernAspectEngine
        from stellium.engines.orbs import SimpleOrbEngine

        # Determine which engine to use
        aspect_engine = self._internal_aspect_engine or ModernAspectEngine()
        orb_engine = self._internal_orb_engine or SimpleOrbEngine()

        # Calculate aspects
        internal_aspects = aspect_engine.calculate_aspects(
            list(chart.positions), orb_engine
        )

        # Create new chart with aspects
        return CalculatedChart(
            datetime=chart.datetime,
            location=chart.location,
            positions=chart.positions,
            house_systems=chart.house_systems,
            house_placements=chart.house_placements,
            aspects=tuple(internal_aspects),
            metadata=chart.metadata,
        )

    def _calculate_cross_aspects(self) -> list[ComparisonAspect]:
        """
        Calculate aspects between chart1 and chart2.

        Uses CrossChartAspectEngine to find aspects where one object
        is from chart1 and the other is from chart2. Then enhances
        each aspect with house placement information.

        Returns:
            List of ComparisonAspect objects
        """
        from stellium.engines.aspects import CrossChartAspectEngine

        # Use configured engine or create default
        if self._aspect_engine:
            engine = self._aspect_engine
        else:
            # Default cross-chart engine
            engine = CrossChartAspectEngine()

        # Calculate cross-chart aspects
        cross_aspects = engine.calculate_cross_aspects(
            list(self._chart1.positions),
            list(self._chart2.positions),
            self._orb_engine,
        )

        # Enhance Aspect → ComparisonAspect with house metadata
        comparison_aspects = []
        for asp in cross_aspects:
            # Find which house each object falls into
            obj1_house = self._chart1.get_house(asp.object1.name)
            obj2_house = self._chart2.get_house(asp.object2.name)

            comp_asp = ComparisonAspect(
                object1=asp.object1,
                object2=asp.object2,
                aspect_name=asp.aspect_name,
                aspect_degree=asp.aspect_degree,
                orb=asp.orb,
                is_applying=asp.is_applying,
                chart1_to_chart2=True,  # Always chart1→chart2 in our iteration
                in_chart1_house=obj1_house,
                in_chart2_house=obj2_house,
            )
            comparison_aspects.append(comp_asp)

        return comparison_aspects

    def _calculate_house_overlays(self) -> tuple[HouseOverlay, ...]:
        """
        Calculate which houses each chart's planets fall into.

        This calculates house overlays in both directions:
        - Chart1 planets in Chart2 houses
        - Chart2 planets in Chart1 houses

        Returns:
            Tuple of HouseOverlay objects
        """
        from stellium.utils.houses import find_house_for_longitude

        overlays = []

        # Chart1 planets in Chart2 houses
        chart2_cusps = self._chart2.get_houses().cusps
        for pos in self._chart1.positions:
            house_num = find_house_for_longitude(pos.longitude, chart2_cusps)
            overlay = HouseOverlay(
                planet_name=pos.name,
                planet_owner="chart1",
                falls_in_house=house_num,
                house_owner="chart2",
                planet_position=pos,
            )
            overlays.append(overlay)

        # Chart2 planets in Chart1 houses
        chart1_cusps = self._chart1.get_houses().cusps
        for pos in self._chart2.positions:
            house_num = find_house_for_longitude(pos.longitude, chart1_cusps)
            overlay = HouseOverlay(
                planet_name=pos.name,
                planet_owner="chart2",
                falls_in_house=house_num,
                house_owner="chart1",
                planet_position=pos,
            )
            overlays.append(overlay)

        return tuple(overlays)
