"""
lakelogic.core.incremental
--------------------------
Resolves the time boundary for incremental layer-to-layer processing.

Given a source layer (e.g. Bronze) that may have accumulated multiple days of
partitions, this module answers: **which partitions have NOT yet been processed
into the target layer (Silver / Gold)?**

Four strategies — all produce the same ``Boundary(from_dt, to_dt)`` output:

  1. ``max_target``    — query MAX(watermark_field) from the target Delta table.
                         Processes everything newer than that high-water mark.

  2. ``pipeline_log``  — query a pipeline audit log table for the last successful run.
                         More reliable than watermark when the target table can be
                         partially overwritten.

  3. ``manifest``      — read a JSON manifest file listing already-processed partition
                         values.  Lightweight, no Spark required.

  4. ``date_range``    — explicit from/to dates or a human-readable lookback string
                         (e.g. ``"7 days"``, ``"3 hours"``, ``"1 month"``).
                         Useful for ad-hoc backfills and Databricks Widgets.

Usage
-----
::

    from lakelogic.core.incremental import IncrementalBoundary, Lookback

    # Strategy 1 — watermark on target table
    boundary = IncrementalBoundary.from_max_target(
        target_path="abfss://silver@.../listings",
        watermark_field="_snapshot_date",
    )

    # Strategy 2 — pipeline log
    boundary = IncrementalBoundary.from_pipeline_log(
        log_table="pipeline_runs",
        pipeline_name="bronze_to_silver_zoopla_listings",
    )

    # Strategy 3 — manifest file
    boundary = IncrementalBoundary.from_manifest(
        manifest_path="abfss://meta@.../manifests/bronze_to_silver.json",
        partition_field="_snapshot_date",
    )

    # Strategy 4a — explicit date range
    boundary = IncrementalBoundary.from_date_range(
        from_date="2024-03-01",
        to_date="2024-03-31",
    )

    # Strategy 4b — lookback string
    boundary = IncrementalBoundary.from_lookback("7 days")
    boundary = IncrementalBoundary.from_lookback("3 hours")
    boundary = IncrementalBoundary.from_lookback("1 month")

    # Use the boundary to filter source data
    bronze_df = (
        spark.read.format("delta").load(BRONZE_ROOT)
             .filter(boundary.spark_filter("_snapshot_date"))
    )
"""

from __future__ import annotations

import json
import re
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional, Union


# ---------------------------------------------------------------------------
# Boundary dataclass — unified output of all strategies
# ---------------------------------------------------------------------------


@dataclass
class Boundary:
    """
    Resolved incremental processing window.

    Attributes
    ----------
    from_dt : datetime
        Inclusive start of the window (UTC).
    to_dt : datetime
        Inclusive end of the window (UTC).
    strategy : str
        Name of the strategy that resolved this boundary.
    partition_filters : dict
        Static (non-temporal) partition values to AND into every filter.
        Example: ``{"country": "GB", "region": "south"}``
    metadata : dict
        Strategy-specific metadata (watermark value found, manifest entries, etc.)
    """

    from_dt: datetime
    to_dt: datetime
    strategy: str
    partition_filters: Dict[str, Any] = field(default_factory=dict)
    metadata: Dict[str, Any] = field(default_factory=dict)

    # Convenience properties
    @property
    def from_date(self) -> date:
        return self.from_dt.date()

    @property
    def to_date(self) -> date:
        return self.to_dt.date()

    @property
    def from_iso(self) -> str:
        return self.from_dt.isoformat()

    @property
    def to_iso(self) -> str:
        return self.to_dt.isoformat()

    # ------------------------------------------------------------------
    # Spark filter helpers
    # ------------------------------------------------------------------

    def spark_filter(
        self,
        field: Optional[str] = None,
        *,
        date_parts: Optional[Union[List[str], Dict[str, str]]] = None,
    ) -> str:
        """
        Return a Spark SQL filter string covering the resolved time window.

        Supports three field layouts — pick the one that matches your table:

        **Single date/timestamp column** (most common)::

            # Table has: _snapshot_date DATE
            df.filter(boundary.spark_filter("_snapshot_date"))
            # → "_snapshot_date >= '2024-03-01' AND _snapshot_date <= '2024-03-31'"

        **Split date-part columns as a list** (year, month, day)::

            # Table has: year INT, month INT, day INT
            df.filter(boundary.spark_filter(date_parts=["year", "month", "day"]))
            # → "MAKE_DATE(year, month, day) >= '2024-03-01'
            #    AND MAKE_DATE(year, month, day) <= '2024-03-31'"

        **Split date-part columns as a dict** (custom column names)::

            # Table has: partition_year INT, partition_month INT, partition_day INT
            df.filter(boundary.spark_filter(date_parts={
                "year": "partition_year",
                "month": "partition_month",
                "day": "partition_day",
            }))

        **Mixed: static + temporal partitions**::

            # source.partition_filters: {country: GB}
            boundary = IncrementalBoundary.from_lookback(
                "7 days",
                partition_filters={"country": "GB"},
            )
            df.filter(boundary.spark_filter("_snapshot_date"))
            # → "country = 'GB' AND _snapshot_date >= '2024-...' AND ..."

        Parameters
        ----------
        field : str, optional
            Single date/timestamp column name.
        date_parts : list of str or dict, optional
            Split date columns.  List → ``[year_col, month_col, day_col]``.
            Dict → ``{"year": col, "month": col, "day": col}``.
        """
        parts: List[str] = []

        # ── Static partition filters (non-temporal) ───────────────────────────
        for col, val in self.partition_filters.items():
            if isinstance(val, str):
                parts.append(f"{col} = '{val}'")
            else:
                parts.append(f"{col} = {val}")

        # ── Temporal filter ───────────────────────────────────────────────────
        if field is not None:
            parts.append(f"{field} >= '{self.from_date}'")
            parts.append(f"{field} <= '{self.to_date}'")

        elif date_parts is not None:
            # Normalise to dict
            if isinstance(date_parts, list):
                if len(date_parts) == 3:
                    dp = {
                        "year": date_parts[0],
                        "month": date_parts[1],
                        "day": date_parts[2],
                    }
                elif len(date_parts) == 2:
                    dp = {"year": date_parts[0], "month": date_parts[1]}
                else:
                    raise ValueError(
                        f"date_parts list must have 2 or 3 elements [year, month, day], "
                        f"got {len(date_parts)}: {date_parts}"
                    )
            else:
                dp = date_parts

            if "day" in dp:
                make_date = f"MAKE_DATE({dp['year']}, {dp['month']}, {dp['day']})"
                parts.append(f"{make_date} >= '{self.from_date}'")
                parts.append(f"{make_date} <= '{self.to_date}'")
            elif "month" in dp:
                # Year+month only — match whole months in range
                y_col, m_col = dp["year"], dp["month"]
                parts.append(
                    f"({y_col} > {self.from_date.year} OR "
                    f"({y_col} = {self.from_date.year} AND {m_col} >= {self.from_date.month}))"
                )
                parts.append(
                    f"({y_col} < {self.to_date.year} OR "
                    f"({y_col} = {self.to_date.year} AND {m_col} <= {self.to_date.month}))"
                )
            else:
                raise ValueError("date_parts must include at least 'year' and 'month' keys")

        else:
            raise ValueError("Provide either field='col_name' or date_parts=['year','month','day']")

        return " AND ".join(parts)

    # ------------------------------------------------------------------
    # Polars filter helpers
    # ------------------------------------------------------------------

    def polars_filter(
        self,
        field: Optional[str] = None,
        *,
        date_parts: Optional[Union[List[str], Dict[str, str]]] = None,
    ):
        """
        Return a Polars boolean expression covering the resolved time window.

        Mirrors :meth:`spark_filter` but returns a Polars expression rather than
        a SQL string.

        Examples::

            # Single date column
            df.filter(boundary.polars_filter("_snapshot_date"))

            # Split date parts
            df.filter(boundary.polars_filter(date_parts=["year", "month", "day"]))

            # Custom column names
            df.filter(boundary.polars_filter(date_parts={
                "year": "partition_year", "month": "partition_month", "day": "partition_day"
            }))
        """
        import polars as pl

        expr = pl.lit(True)

        # ── Static partition filters ──────────────────────────────────────────
        for col, val in self.partition_filters.items():
            expr = expr & (pl.col(col) == val)

        # ── Temporal filter ───────────────────────────────────────────────────
        if field is not None:
            expr = expr & (pl.col(field).cast(pl.Date) >= self.from_date)
            expr = expr & (pl.col(field).cast(pl.Date) <= self.to_date)

        elif date_parts is not None:
            if isinstance(date_parts, list):
                if len(date_parts) == 3:
                    dp = {
                        "year": date_parts[0],
                        "month": date_parts[1],
                        "day": date_parts[2],
                    }
                elif len(date_parts) == 2:
                    dp = {"year": date_parts[0], "month": date_parts[1]}
                else:
                    raise ValueError(f"date_parts list must have 2 or 3 elements, got {len(date_parts)}")
            else:
                dp = date_parts

            if "day" in dp:
                # Reconstruct date: year*10000 + month*100 + day as integer key
                # Polars doesn't have MAKE_DATE directly — use date arithmetic
                y, m, d = pl.col(dp["year"]), pl.col(dp["month"]), pl.col(dp["day"])
                # Integer key: YYYYMMDD — faithful date ordering without MAKE_DATE
                date_key = y * 10_000 + m * 100 + d
                from_key = self.from_date.year * 10_000 + self.from_date.month * 100 + self.from_date.day
                to_key = self.to_date.year * 10_000 + self.to_date.month * 100 + self.to_date.day
                expr = expr & (date_key >= from_key) & (date_key <= to_key)
            elif "month" in dp:
                y_col, m_col = pl.col(dp["year"]), pl.col(dp["month"])
                ym_key = y_col * 100 + m_col
                from_ym = self.from_date.year * 100 + self.from_date.month
                to_ym = self.to_date.year * 100 + self.to_date.month
                expr = expr & (ym_key >= from_ym) & (ym_key <= to_ym)
            else:
                raise ValueError("date_parts must include at least 'year' and 'month' keys")

        else:
            raise ValueError("Provide either field='col_name' or date_parts=['year','month','day']")

        return expr

    def __repr__(self) -> str:
        pf = f", partition_filters={self.partition_filters}" if self.partition_filters else ""
        return f"Boundary(strategy={self.strategy!r}, from={self.from_date}, to={self.to_date}{pf})"


# ---------------------------------------------------------------------------
# Lookback parser
# ---------------------------------------------------------------------------

_LOOKBACK_UNITS = {
    "second": 1,
    "seconds": 1,
    "sec": 1,
    "secs": 1,
    "minute": 60,
    "minutes": 60,
    "min": 60,
    "mins": 60,
    "hour": 3600,
    "hours": 3600,
    "hr": 3600,
    "hrs": 3600,
    "day": 86400,
    "days": 86400,
    "week": 604800,
    "weeks": 604800,
    # Months and years are approximate
    "month": 86400 * 30,
    "months": 86400 * 30,
    "year": 86400 * 365,
    "years": 86400 * 365,
}


def _parse_lookback(lookback: str) -> timedelta:
    """
    Parse a human-readable lookback string into a ``timedelta``.

    Supported formats::

        "15 minutes"  "3 hours"  "7 days"  "2 weeks"  "1 month"  "1 year"
        "30mins"      "6hrs"     "90"      (bare integer → seconds)

    Parameters
    ----------
    lookback : str
        Human-readable duration string.

    Returns
    -------
    timedelta
    """
    lookback = lookback.strip().lower()

    # Bare integer → seconds
    if lookback.isdigit():
        return timedelta(seconds=int(lookback))

    m = re.match(r"^(\d+(?:\.\d+)?)\s*([a-z]+)$", lookback)
    if not m:
        raise ValueError(
            f"Cannot parse lookback string: {lookback!r}. Expected e.g. '7 days', '3 hours', '30 mins', '1 month'."
        )
    amount = float(m.group(1))
    unit = m.group(2)

    if unit not in _LOOKBACK_UNITS:
        raise ValueError(
            f"Unknown time unit {unit!r} in lookback {lookback!r}. Supported: {sorted(set(_LOOKBACK_UNITS.values()))}."
        )
    return timedelta(seconds=amount * _LOOKBACK_UNITS[unit])


# ---------------------------------------------------------------------------
# IncrementalBoundary — factory class for all 4 strategies
# ---------------------------------------------------------------------------


class IncrementalBoundary:
    """
    Factory class that resolves incremental processing windows.

    All ``from_*`` class methods return a :class:`Boundary` object.

    Design decision — why a single ``Boundary`` output?
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    Every strategy answers the same question: "process source data where
    ``watermark_field`` is between X and Y".  By normalising to a ``Boundary``
    dataclass, the pipeline code stays the same regardless of strategy:

    ::

        boundary  = IncrementalBoundary.from_max_target(...)   # or any other
        source_df = spark.read.format("delta").load(BRONZE_PATH) \\
                         .filter(boundary.spark_filter("_snapshot_date"))
    """

    # ── Strategy 1: Max watermark on the target table ─────────────────────────

    @classmethod
    def from_max_target(
        cls,
        target_path: str,
        watermark_field: str,
        *,
        to_dt: Optional[datetime] = None,
        default_from: Optional[Union[datetime, str]] = None,
    ) -> Boundary:
        """
        Strategy: query ``MAX(watermark_field)`` from the target Delta table.

        Processes source data where ``watermark_field > max_value_in_target``.
        If the target table is empty or doesn't exist, uses ``default_from``
        (or 90 days ago as a safe fallback).

        Parameters
        ----------
        target_path : str
            Delta table path (ADLS / local).
        watermark_field : str
            Column name to take MAX of (e.g. ``_snapshot_date``).
        to_dt : datetime, optional
            Upper bound — defaults to NOW (UTC).
        default_from : datetime or ISO string, optional
            Fallback lower bound when target is empty. Default: 90 days ago.

        Example
        -------
        ::

            boundary = IncrementalBoundary.from_max_target(
                target_path="abfss://silver@acct.dfs.core.windows.net/zoopla/listings",
                watermark_field="_snapshot_date",
            )
            # If Silver has data up to 2024-03-05 →
            # boundary.from_date == date(2024, 3, 6)
            # boundary.to_date   == date.today()
        """
        try:
            from pyspark.sql import SparkSession
            import pyspark.sql.functions as F

            spark = SparkSession.getActiveSession()
            if spark is None:
                raise RuntimeError("No active Spark session")

            df = spark.read.format("delta").load(target_path)
            max_val = df.agg(F.max(watermark_field)).collect()[0][0]

            if max_val is None:
                raise ValueError("Target table is empty")

            # Convert to datetime
            if isinstance(max_val, datetime):
                from_dt = max_val + timedelta(seconds=1)
            elif isinstance(max_val, date):
                from_dt = datetime(max_val.year, max_val.month, max_val.day) + timedelta(days=1)
            else:
                from_dt = datetime.fromisoformat(str(max_val)) + timedelta(days=1)

            meta = {"watermark_value": str(max_val), "target_path": target_path}

        except Exception as exc:
            # Target missing / empty — use default_from
            if default_from is not None:
                from_dt = datetime.fromisoformat(default_from) if isinstance(default_from, str) else default_from
            else:
                from_dt = datetime.utcnow() - timedelta(days=90)
            meta = {"fallback_reason": str(exc), "target_path": target_path}

        _to = to_dt or datetime.utcnow()
        return Boundary(from_dt=from_dt, to_dt=_to, strategy="max_target", metadata=meta)

    # ── Strategy 2: Pipeline log / audit table ────────────────────────────────

    @classmethod
    def from_pipeline_log(
        cls,
        pipeline_name: str,
        *,
        log_table: str = "pipeline_runs",
        to_dt: Optional[datetime] = None,
        default_from: Optional[Union[datetime, str]] = None,
    ) -> Boundary:
        """
        Strategy: query a pipeline audit log for the last successful run.

        Reads``MAX(processed_through)`` from ``log_table`` WHERE
        ``pipeline_name = :name AND status = 'success'``.

        The log table schema::

            pipeline_name   STRING
            status          STRING    -- 'success' | 'failed' | 'running'
            started_at      TIMESTAMP
            completed_at    TIMESTAMP
            processed_from  TIMESTAMP -- lower bound of data processed
            processed_through TIMESTAMP -- upper bound of data processed

        Parameters
        ----------
        pipeline_name : str
            Registered name of the pipeline (e.g. ``"bronze_to_silver_zoopla"``).
        log_table : str
            Spark/Databricks table name.
        to_dt : datetime, optional
            Upper bound — defaults to NOW (UTC).
        default_from : datetime or ISO string, optional
            Fallback when no successful run exists.

        Example
        -------
        ::

            boundary = IncrementalBoundary.from_pipeline_log(
                pipeline_name="bronze_to_silver_zoopla_listings",
                log_table="meta.pipeline_runs",
            )
        """
        try:
            from pyspark.sql import SparkSession
            import pyspark.sql.functions as F

            spark = SparkSession.getActiveSession()
            if spark is None:
                raise RuntimeError("No active Spark session")

            row = (
                spark.table(log_table)
                .filter((F.col("pipeline_name") == pipeline_name) & (F.col("status") == "success"))
                .agg(F.max("processed_through").alias("last_success"))
                .collect()[0]
            )
            last_success = row["last_success"]
            if last_success is None:
                raise ValueError(f"No successful run found for {pipeline_name!r}")

            from_dt = (
                last_success + timedelta(seconds=1)
                if isinstance(last_success, datetime)
                else datetime(last_success.year, last_success.month, last_success.day) + timedelta(days=1)
            )
            meta = {"last_success": str(last_success), "pipeline_name": pipeline_name}

        except Exception as exc:
            if default_from is not None:
                from_dt = datetime.fromisoformat(default_from) if isinstance(default_from, str) else default_from
            else:
                from_dt = datetime.utcnow() - timedelta(days=90)
            meta = {"fallback_reason": str(exc), "pipeline_name": pipeline_name}

        _to = to_dt or datetime.utcnow()
        return Boundary(from_dt=from_dt, to_dt=_to, strategy="pipeline_log", metadata=meta)

    # ── Strategy 3: Manifest file ─────────────────────────────────────────────

    @classmethod
    def from_manifest(
        cls,
        manifest_path: str,
        partition_field: str = "_snapshot_date",
        *,
        to_dt: Optional[datetime] = None,
        default_from: Optional[Union[datetime, str]] = None,
    ) -> Boundary:
        """
        Strategy: read a JSON manifest file listing already-processed partition values.

        The manifest is a JSON file with the schema::

            {
              "pipeline": "bronze_to_silver_zoopla_listings",
              "processed_partitions": ["2024-03-01", "2024-03-02", "2024-03-03"],
              "last_updated": "2024-03-03T02:15:00Z"
            }

        The boundary ``from_dt`` is set to the day AFTER the latest processed partition.

        Parameters
        ----------
        manifest_path : str
            Path to the manifest JSON file (local path, ADLS path, or S3 URI).
            For cloud paths: pass a local temp path after downloading with your
            cloud SDK, or use ``dbutils.fs.cp`` on Databricks.
        partition_field : str
            For documentation only — not used at runtime (manifest stores the values).
        to_dt : datetime, optional
            Upper bound — defaults to NOW (UTC).

        Example manifest file
        ---------------------
        ::

            {
              "pipeline": "bronze_to_silver_zoopla",
              "processed_partitions": ["2024-03-01", "2024-03-02"],
              "last_updated": "2024-03-02T03:00:00Z"
            }

        Example usage
        -------------
        ::

            boundary = IncrementalBoundary.from_manifest(
                manifest_path="/dbfs/mnt/meta/manifests/bronze_to_silver_zoopla.json"
            )
            # Manifest shows last processed = '2024-03-02'
            # boundary.from_date == date(2024, 3, 3)
        """
        try:
            p = Path(manifest_path)
            if not p.exists():
                raise FileNotFoundError(f"Manifest not found: {manifest_path}")

            data = json.loads(p.read_text(encoding="utf-8"))
            partitions: List[str] = data.get("processed_partitions", [])

            if not partitions:
                raise ValueError("Manifest has no processed_partitions")

            last_partition = max(partitions)  # ISO string comparison works for dates
            last_dt = (
                datetime.fromisoformat(last_partition)
                if "T" in last_partition
                else datetime.strptime(last_partition, "%Y-%m-%d")
            )
            from_dt = last_dt + timedelta(days=1)
            meta = {
                "manifest_path": manifest_path,
                "last_partition": last_partition,
                "total_processed": len(partitions),
            }

        except Exception as exc:
            if default_from is not None:
                from_dt = datetime.fromisoformat(default_from) if isinstance(default_from, str) else default_from
            else:
                from_dt = datetime.utcnow() - timedelta(days=90)
            meta = {"fallback_reason": str(exc), "manifest_path": manifest_path}

        _to = to_dt or datetime.utcnow()
        return Boundary(from_dt=from_dt, to_dt=_to, strategy="manifest", metadata=meta)

    @classmethod
    def update_manifest(
        cls,
        manifest_path: str,
        pipeline: str,
        new_partitions: List[str],
    ) -> None:
        """
        Append newly-processed partition values to a manifest file.

        Call this AFTER a successful pipeline run to record what was processed.

        Example
        -------
        ::

            IncrementalBoundary.update_manifest(
                manifest_path="/dbfs/mnt/meta/manifests/bronze_to_silver_zoopla.json",
                pipeline="bronze_to_silver_zoopla",
                new_partitions=["2024-03-03", "2024-03-04"],
            )
        """
        p = Path(manifest_path)
        if p.exists():
            data = json.loads(p.read_text(encoding="utf-8"))
        else:
            data = {"pipeline": pipeline, "processed_partitions": []}

        existing = set(data.get("processed_partitions", []))
        existing.update(new_partitions)
        data["processed_partitions"] = sorted(existing)
        data["last_updated"] = datetime.utcnow().isoformat() + "Z"

        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(json.dumps(data, indent=2), encoding="utf-8")

    # ── Strategy 4a: Explicit date range ─────────────────────────────────────

    @classmethod
    def from_date_range(
        cls,
        from_date: Union[str, date, datetime],
        to_date: Optional[Union[str, date, datetime]] = None,
        *,
        partition_filters: Optional[Dict[str, Any]] = None,
    ) -> Boundary:
        """
        Strategy: explicit from/to date range.

        Ideal for Databricks Widget parameters or ad-hoc backfills.

        Parameters
        ----------
        from_date : str, date, or datetime
            Inclusive start (e.g. ``"2024-03-01"`` or a ``datetime`` object).
        to_date : str, date, or datetime, optional
            Inclusive end. Defaults to NOW (UTC).
        partition_filters : dict, optional
            Static (non-temporal) partition values to AND into every filter.
            Example: ``{"country": "GB"}``

        Example
        -------
        ::

            # Databricks Widget pattern
            from_date = dbutils.widgets.get("from_date")   # "2024-03-01"
            to_date   = dbutils.widgets.get("to_date")     # "2024-03-31"
            boundary  = IncrementalBoundary.from_date_range(from_date, to_date)

            # Backfill March 2024, GB only
            boundary = IncrementalBoundary.from_date_range(
                "2024-03-01", "2024-03-31",
                partition_filters={"country": "GB"},
            )
            df.filter(boundary.spark_filter(date_parts=["year", "month", "day"]))
            # → "country = 'GB' AND MAKE_DATE(year,month,day) BETWEEN ..."
        """

        def _to_dt(v) -> datetime:
            if isinstance(v, datetime):
                return v
            if isinstance(v, date):
                return datetime(v.year, v.month, v.day)
            return datetime.fromisoformat(str(v))

        from_dt = _to_dt(from_date)
        to_dt = _to_dt(to_date) if to_date is not None else datetime.utcnow()
        return Boundary(
            from_dt=from_dt,
            to_dt=to_dt,
            strategy="date_range",
            partition_filters=partition_filters or {},
            metadata={"from_date": str(from_date), "to_date": str(to_date or "now")},
        )

    # ── Strategy 4b: Lookback string ─────────────────────────────────────────

    @classmethod
    def from_lookback(
        cls,
        lookback: str,
        *,
        reference_dt: Optional[datetime] = None,
        partition_filters: Optional[Dict[str, Any]] = None,
    ) -> Boundary:
        """
        Strategy: human-readable lookback from NOW (or a reference point).

        Parameters
        ----------
        lookback : str
            Duration string. Examples:

            ============= ===========
            String        Window
            ============= ===========
            ``"15 mins"`` last 15 minutes
            ``"3 hours"`` last 3 hours
            ``"7 days"``  last 7 days
            ``"2 weeks"`` last 2 weeks
            ``"1 month"`` last ~30 days
            ``"1 year"``  last ~365 days
            ============= ===========

        reference_dt : datetime, optional
            Anchor point. Defaults to ``datetime.utcnow()``.
        partition_filters : dict, optional
            Static (non-temporal) partition values to AND into every filter.
            Example: ``{"country": "GB", "region": "south"}``

        Examples
        --------
        ::

            # Last 7 days, all partitions
            boundary = IncrementalBoundary.from_lookback("7 days")

            # Last 7 days, GB only (table partitioned by country, year, month, day)
            boundary = IncrementalBoundary.from_lookback(
                "7 days",
                partition_filters={"country": "GB"},
            )
            df.filter(boundary.spark_filter(date_parts=["year", "month", "day"]))
            # → "country = 'GB' AND MAKE_DATE(year, month, day) >= '...' AND ..."

            # Near-real-time micro-batch
            boundary = IncrementalBoundary.from_lookback("30 mins")
        """
        delta = _parse_lookback(lookback)
        to_dt = reference_dt or datetime.utcnow()
        from_dt = to_dt - delta
        return Boundary(
            from_dt=from_dt,
            to_dt=to_dt,
            strategy="lookback",
            partition_filters=partition_filters or {},
            metadata={"lookback": lookback, "delta_seconds": delta.total_seconds()},
        )

    # ── Convenience: resolve from contract SourceConfig ────────────────────────

    @classmethod
    def from_contract(
        cls,
        contract_path: str,
        *,
        from_date: Optional[str] = None,
        to_date: Optional[str] = None,
        lookback: Optional[str] = None,
        target_path: Optional[str] = None,
        pipeline_name: Optional[str] = None,
        manifest_path: Optional[str] = None,
        partition_filters: Optional[Dict[str, Any]] = None,
    ) -> Boundary:
        """
        Resolve boundary from a LakeLogic contract ``source`` block.

        Runtime overrides always take precedence, enabling Databricks Widget /
        CLI parameterisation and the **per-partition pipeline pattern**.

        Partition filter merging
        ------------------------
        ``partition_filters`` at runtime is **merged on top of** any
        ``partition_filters`` declared in the contract.  Runtime values win.
        This lets you bake defaults into the contract and add the country/
        region at runtime, OR put nothing in the contract and supply
        everything at runtime.

        Per-country pipeline patterns
        -----------------------------
        ::

            # Pattern A — country-specific contract (one YAML per country)
            #   bronze_listings_gb.yaml: partition_filters: {country: GB}
            boundary = IncrementalBoundary.from_contract(
                "contracts/bronze_listings_gb.yaml",
                lookback="7 days",
            )

            # Pattern B — generic contract, country at runtime
            for country in ["GB", "DE", "FR", "ES"]:
                boundary = IncrementalBoundary.from_contract(
                    "contracts/bronze_listings.yaml",
                    lookback="7 days",
                    partition_filters={"country": country},
                )
                (bronze_df
                    .filter(boundary.spark_filter(date_parts=["year", "month", "day"]))
                    .write.format("delta")
                    .save(f"{SILVER_ROOT}/{country}")
                )

            # Pattern C — Databricks Widgets
            boundary = IncrementalBoundary.from_contract(
                CONTRACT_PATH,
                lookback=dbutils.widgets.get("lookback"),
                partition_filters={"country": dbutils.widgets.get("country")},
            )
        """
        import yaml as _yaml

        data = _yaml.safe_load(Path(contract_path).read_text(encoding="utf-8"))
        src = data.get("source") or {}

        strategy = src.get("watermark_strategy", "max_target")
        wm_field = src.get("watermark_field", "_snapshot_date")
        contract_lookback = src.get("lookback")

        # Merge: contract is base, runtime partition_filters wins on conflicts
        merged_pf: Dict[str, Any] = {**src.get("partition_filters", {})}
        if partition_filters:
            merged_pf.update(partition_filters)

        if lookback:
            return cls.from_lookback(lookback, partition_filters=merged_pf or None)

        if from_date:
            return cls.from_date_range(from_date, to_date, partition_filters=merged_pf or None)

        if strategy == "lookback":
            lb = contract_lookback or "7 days"
            return cls.from_lookback(lb, partition_filters=merged_pf or None)

        if strategy == "pipeline_log":
            log_table = src.get("pipeline_log_table", "pipeline_runs")
            p_name = pipeline_name or src.get("pipeline_name", Path(contract_path).stem)
            b = cls.from_pipeline_log(p_name, log_table=log_table)
            b.partition_filters = merged_pf
            return b

        if strategy == "manifest":
            mp = manifest_path or src.get("manifest_path", "")
            b = cls.from_manifest(mp, partition_field=wm_field)
            b.partition_filters = merged_pf
            return b

        if strategy == "date_range":
            fd = from_date or src.get("from_date")
            td = to_date or src.get("to_date")
            return cls.from_date_range(fd, td, partition_filters=merged_pf or None)

        tp = target_path or src.get("target_path", "")
        b = cls.from_max_target(tp, watermark_field=wm_field)
        b.partition_filters = merged_pf
        return b

    # ── Convenience: resolve from SourceConfig model ────────────────────────

    @classmethod
    def from_source_config(cls, source_config, **overrides) -> Boundary:
        """
        Resolve boundary from a ``SourceConfig`` Pydantic model instance.

        Supports a ``partition_filters`` override kwarg using the same merge
        semantics as :meth:`from_contract` (runtime wins over contract).

        Examples
        --------
        ::

            from lakelogic.core.models import DataContract
            contract = DataContract.from_yaml("bronze_listings.yaml")

            # Per-country pipeline
            for country in ["GB", "DE", "FR"]:
                boundary = IncrementalBoundary.from_source_config(
                    contract.source,
                    lookback="7 days",
                    partition_filters={"country": country},
                )
                bronze_df.filter(
                    boundary.spark_filter(date_parts=["year", "month", "day"])
                ).write.format("delta").save(f"{SILVER_ROOT}/{country}")
        """
        cfg = source_config.model_dump() if hasattr(source_config, "model_dump") else vars(source_config)

        # Extract partition_filters separately — needs merge, not plain replace
        runtime_pf: Dict[str, Any] = overrides.pop("partition_filters", None) or {}
        cfg.update({k: v for k, v in overrides.items() if v is not None})

        merged_pf: Dict[str, Any] = {**cfg.get("partition_filters", {})}
        merged_pf.update(runtime_pf)

        lookback = cfg.get("lookback")
        from_date = cfg.get("from_date")
        to_date = cfg.get("to_date")
        strategy = cfg.get("watermark_strategy", "max_target")
        wm_field = cfg.get("watermark_field", "_snapshot_date")

        if lookback:
            return cls.from_lookback(lookback, partition_filters=merged_pf or None)
        if from_date:
            return cls.from_date_range(from_date, to_date, partition_filters=merged_pf or None)
        if strategy == "lookback":
            return cls.from_lookback(
                cfg.get("lookback_default", "7 days"),
                partition_filters=merged_pf or None,
            )
        if strategy == "pipeline_log":
            b = cls.from_pipeline_log(
                cfg.get("pipeline_name", "unknown"),
                log_table=cfg.get("pipeline_log_table", "pipeline_runs"),
            )
            b.partition_filters = merged_pf
            return b
        if strategy == "manifest":
            b = cls.from_manifest(cfg.get("manifest_path", ""), partition_field=wm_field)
            b.partition_filters = merged_pf
            return b
        if strategy == "date_range":
            return cls.from_date_range(
                cfg.get("from_date"),
                cfg.get("to_date"),
                partition_filters=merged_pf or None,
            )
        b = cls.from_max_target(cfg.get("target_path", ""), watermark_field=wm_field)
        b.partition_filters = merged_pf
        return b
