"""
cortex/core/temporal_scoring.py — Temporal hint parsing and proximity scoring.

Used by the retrieval pipeline to:
  1. Parse temporal references from a query string into a reference datetime.
  2. Compute a proximity boost score for a claim's event_date relative to
     that reference date.

Design goals:
  - Zero external dependencies (stdlib only: re, datetime).
  - Fast — called on every retrieve() when temporal_boost_weight > 0.
  - Conservative — when parsing fails, returns None (no boost, no harm).

Supported query patterns (case-insensitive):
  - ISO dates:         "2024-03-15", "2024-03"  (year-month treated as 1st)
  - "today" / "now"
  - "yesterday"
  - "last week" / "this week"
  - "last month" / "this month"
  - "last year" / "this year"
  - "in <month>" or "in <month> <year>":   "in March", "in March 2024"
  - "N days ago" / "N weeks ago" / "N months ago"
  - "N day" / "N week" / "N month"         (relaxed form)
"""

from __future__ import annotations

import logging
import math
import re
from datetime import datetime, timedelta, timezone
from typing import Optional

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Month name map
# ---------------------------------------------------------------------------

_MONTHS = {
    "january": 1, "jan": 1,
    "february": 2, "feb": 2,
    "march": 3, "mar": 3,
    "april": 4, "apr": 4,
    "may": 5,
    "june": 6, "jun": 6,
    "july": 7, "jul": 7,
    "august": 8, "aug": 8,
    "september": 9, "sep": 9, "sept": 9,
    "october": 10, "oct": 10,
    "november": 11, "nov": 11,
    "december": 12, "dec": 12,
}

# ---------------------------------------------------------------------------
# Compiled patterns (ordered most-specific → least-specific)
# ---------------------------------------------------------------------------

# ISO full date: 2024-03-15
_RE_ISO_DATE = re.compile(r'\b(\d{4})-(\d{2})-(\d{2})\b')

# ISO year-month: 2024-03
_RE_ISO_YEAR_MONTH = re.compile(r'\b(\d{4})-(\d{2})\b')

# "N days/weeks/months ago" (also without "ago")
_RE_N_AGO = re.compile(
    r'\b(\d+)\s*(day|days|week|weeks|month|months|year|years)\s*(?:ago)?\b',
    re.IGNORECASE,
)

# "in <month>" or "in <month> <year>"
_RE_IN_MONTH = re.compile(
    r'\bin\s+(' + '|'.join(_MONTHS.keys()) + r')'
    r'(?:\s+(\d{4}))?\b',
    re.IGNORECASE,
)

# Relative keywords
_RE_RELATIVE = re.compile(
    r'\b(today|now|yesterday|last\s+week|this\s+week|last\s+month|this\s+month|'
    r'last\s+year|this\s+year)\b',
    re.IGNORECASE,
)


def _now_utc() -> datetime:
    """Return the current UTC datetime (extracted for testability)."""
    return datetime.now(timezone.utc)


def parse_temporal_hints(query: str, _now: Optional[datetime] = None) -> Optional[datetime]:
    """Parse a natural-language query and return a reference datetime, if found.

    Returns the *most specific* temporal reference detected in the query, or
    ``None`` when no recognisable temporal hint is present.  Precision is to
    the day — time components are always midnight UTC.

    The ``_now`` parameter exists purely for unit-testing; callers should
    leave it as ``None`` so it defaults to the real wall clock.

    Args:
        query:  Natural-language retrieval query.
        _now:   Override for "now" (testing only).

    Returns:
        datetime (UTC midnight) representing the parsed reference, or None.
    """
    now = _now or _now_utc()
    text = query.lower()

    # 1. Full ISO date — highest confidence, try first
    m = _RE_ISO_DATE.search(query)
    if m:
        try:
            return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)),
                            tzinfo=timezone.utc)
        except ValueError:
            pass

    # 2. Year-month only: treat as 1st of that month
    m = _RE_ISO_YEAR_MONTH.search(query)
    if m:
        try:
            return datetime(int(m.group(1)), int(m.group(2)), 1, tzinfo=timezone.utc)
        except ValueError:
            pass

    # 3. "in <month> [year]"
    m = _RE_IN_MONTH.search(text)
    if m:
        month_str = m.group(1).lower()
        month_num = _MONTHS.get(month_str)
        if month_num is not None:
            year = int(m.group(2)) if m.group(2) else now.year
            try:
                return datetime(year, month_num, 1, tzinfo=timezone.utc)
            except ValueError:
                pass

    # 4. Relative keywords
    m = _RE_RELATIVE.search(text)
    if m:
        kw = re.sub(r'\s+', ' ', m.group(1).strip().lower())
        today = datetime(now.year, now.month, now.day, tzinfo=timezone.utc)
        _map = {
            "today":        today,
            "now":          today,
            "yesterday":    today - timedelta(days=1),
            "last week":    today - timedelta(days=7),
            "this week":    today - timedelta(days=today.weekday()),
            "last month":   datetime(now.year if now.month > 1 else now.year - 1,
                                     now.month - 1 if now.month > 1 else 12, 1,
                                     tzinfo=timezone.utc),
            "this month":   datetime(now.year, now.month, 1, tzinfo=timezone.utc),
            "last year":    datetime(now.year - 1, 1, 1, tzinfo=timezone.utc),
            "this year":    datetime(now.year, 1, 1, tzinfo=timezone.utc),
        }
        if kw in _map:
            return _map[kw]

    # 5. "N days/weeks/months/years ago"
    m = _RE_N_AGO.search(text)
    if m:
        n = int(m.group(1))
        unit = m.group(2).rstrip("s").lower()  # normalise plural
        today = datetime(now.year, now.month, now.day, tzinfo=timezone.utc)
        if unit == "day":
            return today - timedelta(days=n)
        if unit == "week":
            return today - timedelta(weeks=n)
        if unit == "month":
            # Approximate: 30 days per month
            return today - timedelta(days=n * 30)
        if unit == "year":
            return today - timedelta(days=n * 365)

    return None


def temporal_proximity_boost(
    event_date_str: str,
    reference_date: datetime,
    half_life_days: float = 30.0,
) -> float:
    """Compute a proximity boost in [0, 1] based on distance between dates.

    Uses an exponential decay with the given half-life.  A claim whose
    ``event_date`` exactly matches the reference gets boost = 1.0; a claim
    30 days away (with default half_life=30) gets boost ≈ 0.5.

    Args:
        event_date_str:  ISO date string from SemanticClaim.event_date
                         (``"YYYY-MM-DD"`` or ``"YYYY-MM"``).
        reference_date:  The reference datetime returned by parse_temporal_hints.
        half_life_days:  Days for the boost to halve (default 30).

    Returns:
        Float in [0.0, 1.0].  Returns 0.0 on any parse error.
    """
    try:
        parts = event_date_str.strip().split("-")
        if len(parts) == 3:
            claim_dt = datetime(int(parts[0]), int(parts[1]), int(parts[2]),
                                tzinfo=timezone.utc)
        elif len(parts) == 2:
            claim_dt = datetime(int(parts[0]), int(parts[1]), 1, tzinfo=timezone.utc)
        else:
            return 0.0
    except (ValueError, AttributeError):
        return 0.0

    delta_days = abs((claim_dt - reference_date).total_seconds()) / 86400.0
    # Exponential decay: boost = 2^(-delta / half_life)
    return math.pow(2.0, -delta_days / half_life_days)


def filter_by_date(
    event_date_str: Optional[str],
    before_date: Optional[str],
    after_date: Optional[str],
) -> bool:
    """Return True if event_date_str passes the before/after filters.

    Items with no event_date pass through (we only filter claims that *have*
    a date — unknown dates are never excluded).

    Args:
        event_date_str: Claim's event_date (ISO string) or None.
        before_date:    ISO date string upper bound (inclusive), or None.
        after_date:     ISO date string lower bound (inclusive), or None.

    Returns:
        True if the item should be included.
    """
    if not event_date_str:
        # No date on the claim → pass through (never exclude undated items)
        return True
    if not before_date and not after_date:
        return True

    try:
        parts = event_date_str.strip().split("-")
        if len(parts) == 3:
            claim_dt = datetime(int(parts[0]), int(parts[1]), int(parts[2]),
                                tzinfo=timezone.utc)
        elif len(parts) == 2:
            claim_dt = datetime(int(parts[0]), int(parts[1]), 1, tzinfo=timezone.utc)
        else:
            return True  # unparseable → pass through
    except (ValueError, AttributeError):
        return True

    if after_date:
        try:
            parts = after_date.strip().split("-")
            after_dt = datetime(int(parts[0]), int(parts[1]),
                                int(parts[2]) if len(parts) == 3 else 1,
                                tzinfo=timezone.utc)
            if claim_dt < after_dt:
                return False
        except (ValueError, AttributeError):
            pass  # bad after_date → ignore filter

    if before_date:
        try:
            parts = before_date.strip().split("-")
            before_dt = datetime(int(parts[0]), int(parts[1]),
                                 int(parts[2]) if len(parts) == 3 else 1,
                                 tzinfo=timezone.utc)
            if claim_dt > before_dt:
                return False
        except (ValueError, AttributeError):
            pass  # bad before_date → ignore filter

    return True
