"""
Subscription tier definitions and enforcement utilities for DocuElevate SaaS.

All plans are priced per user per month (or per year with ~20 % discount).
Four tiers (prices ex-VAT; German customers +19 % MwSt):
  - free         $0/mo    — 50 lifetime docs, 150 lifetime OCR pages, 1 dest
  - starter      $2.99/mo — 50/mo, 300 OCR pp/mo, 2 dests, 1 mailbox
  - professional $5.99/mo — 150/mo, 750 OCR pp/mo, 5 dests, 3 mailboxes
  - power        $7.99/mo — 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes

Limits use 0 to represent "unlimited".
All paid tiers include a 30-day free trial (trial_days field).

--- Cost analysis at maximum usage (Hetzner Option-A infra, Azure Read + GPT-4o mini) ---
Infrastructure: CX32 (app+Redis €7.59) + CX22 (worker €3.79) + BX21 (storage €7.22) ≈ $24/mo
At 100 users infra share ≈ $0.24/user/mo.

  Starter     : OCR $0.45 + AI $0.012 + infra $0.24 + Stripe $0.34 = $1.04 → 65 % gross margin
  Professional: OCR $1.13 + AI $0.035 + infra $0.24 + Stripe $0.42 = $1.82 → 70 % gross margin
  Power       : OCR $2.25 + AI $0.069 + infra $0.24 + Stripe $0.48 = $3.04 → 62 % gross margin

After ~30 % German corporate tax: Starter 45 %, Professional 49 %, Power 43 %.
At average usage (~40 % of quota) margins improve to 55-65 % after tax.

⚠ If GPT-4o (not mini) is configured, Power AI cost at max rises to ~$1.92/user,
  reducing after-tax margin to ~33 %. Recommend GPT-4o mini as default in production.
"""

from __future__ import annotations

import logging
from datetime import date, datetime, timezone
from typing import Any

from sqlalchemy import func
from sqlalchemy.orm import Session

from app.config import settings

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Tier catalogue
# ---------------------------------------------------------------------------

TIER_DEFAULTS: dict[str, dict[str, Any]] = {
    "free": {
        "id": "free",
        "name": "Free",
        "tagline": "Try DocuElevate free — no credit card needed",
        "price_monthly": 0,
        "price_yearly": 0,
        "trial_days": 0,
        "highlight": False,
        # Hard caps — 0 = unlimited
        "lifetime_file_limit": 50,  # total docs ever processed (enforced at upload)
        "daily_upload_limit": 0,  # no per-day cap (lifetime cap applies instead)
        "monthly_upload_limit": 0,  # no per-month cap (lifetime cap applies instead)
        "max_storage_destinations": 1,
        "max_ocr_pages_monthly": 150,  # informational; enforced when OCR quota tracking lands
        "max_file_size_mb": 5,
        "max_mailboxes": 0,  # no email ingestion on free tier
        "api_access": False,
        # Marketing feature list (shown on pricing page)
        "features": [
            "50 documents — lifetime total",
            "150 OCR pages — lifetime total",
            "1 storage destination",
            "5 MB max file size",
            "Basic AI metadata extraction",
            "Community support",
        ],
        "cta": "Get started free",
        "badge": None,
    },
    "starter": {
        "id": "starter",
        "name": "Starter",
        # Use case: freelancer sending ~50 invoices, contracts, or scanned receipts a month
        "tagline": "Perfect for freelancers and side-project owners",
        "price_monthly": 2.99,
        "price_yearly": 28.99,  # ≈ 80 % of monthly × 12 — save ~19 % (≈ 2½ months free)
        "trial_days": 30,
        "highlight": False,
        "lifetime_file_limit": 0,
        "daily_upload_limit": 0,  # no daily cap
        "monthly_upload_limit": 50,
        "max_storage_destinations": 2,
        "max_ocr_pages_monthly": 300,
        "max_file_size_mb": 25,
        "max_mailboxes": 1,
        "api_access": True,
        "features": [
            "50 documents / month — invoices, contracts, receipts",
            "2 storage destinations",
            "300 OCR pages / month",
            "25 MB max file size",
            "Full AI metadata extraction",
            "1 email ingestion mailbox",
            "API access",
            "Email support",
        ],
        "cta": "Start free trial",
        "badge": None,
    },
    "professional": {
        "id": "professional",
        "name": "Professional",
        # Use case: consultant or knowledge worker handling ~150 docs/month across multiple platforms
        "tagline": "For knowledge workers managing documents daily",
        "price_monthly": 5.99,
        "price_yearly": 57.99,  # ≈ 80 % of monthly × 12 — save ~19 %
        "trial_days": 30,
        "highlight": True,  # shown as "Most popular"
        "lifetime_file_limit": 0,
        "daily_upload_limit": 0,  # no daily cap
        "monthly_upload_limit": 150,
        "max_storage_destinations": 5,
        "max_ocr_pages_monthly": 750,
        "max_file_size_mb": 100,
        "max_mailboxes": 3,
        "api_access": True,
        "features": [
            "150 documents / month — reports, contracts, invoices",
            "5 storage destinations",
            "750 OCR pages / month",
            "100 MB max file size",
            "Advanced AI workflows",
            "3 email ingestion mailboxes",
            "Email & URL ingestion",
            "Webhooks",
            "Priority email support",
        ],
        "cta": "Start free trial",
        "badge": "Most Popular",
    },
    "business": {
        "id": "business",
        "name": "Power",
        # Use case: power user — real estate agent, bookkeeper, or researcher processing ~10 docs/day
        "tagline": "For power users with high-volume document workflows",
        "price_monthly": 7.99,
        "price_yearly": 76.99,  # ≈ 80 % of monthly × 12 — save ~20 %
        "trial_days": 30,
        "highlight": False,
        "lifetime_file_limit": 0,
        "daily_upload_limit": 0,  # no daily cap
        "monthly_upload_limit": 300,
        "max_storage_destinations": 10,
        "max_ocr_pages_monthly": 1500,
        "max_file_size_mb": 0,  # unlimited file size
        "max_mailboxes": 0,  # unlimited mailboxes
        "api_access": True,
        "features": [
            "300 documents / month — ~10 documents per day",
            "10 storage destinations",
            "1,500 OCR pages / month",
            "Unlimited file size",
            "All AI processing steps",
            "Unlimited email ingestion mailboxes",
            "All ingestion methods",
            "Webhooks & full API access",
            "Priority support",
        ],
        "cta": "Start free trial",
        "badge": "Best Value",
    },
}

# Backward-compatible alias
TIERS = TIER_DEFAULTS

# Display order for the pricing page
TIER_ORDER = ["free", "starter", "professional", "business"]

# Default tier assigned to new users
DEFAULT_TIER = "free"


# ---------------------------------------------------------------------------
# DB → dict conversion
# ---------------------------------------------------------------------------


def _plan_to_dict(plan: Any) -> dict[str, Any]:
    """Convert a SubscriptionPlan ORM object to the same dict shape as TIER_DEFAULTS entries."""
    import json

    features: list[str] = []
    if plan.features:
        try:
            features = json.loads(plan.features)
        except (json.JSONDecodeError, TypeError):
            features = []
    return {
        "id": plan.plan_id,
        "name": plan.name,
        "tagline": plan.tagline or "",
        "price_monthly": plan.price_monthly,
        "price_yearly": plan.price_yearly,
        "trial_days": plan.trial_days,
        "highlight": plan.is_highlighted,
        "lifetime_file_limit": plan.lifetime_file_limit,
        "daily_upload_limit": plan.daily_upload_limit,
        "monthly_upload_limit": plan.monthly_upload_limit,
        "max_storage_destinations": plan.max_storage_destinations,
        "max_ocr_pages_monthly": plan.max_ocr_pages_monthly,
        "max_file_size_mb": plan.max_file_size_mb,
        "max_mailboxes": plan.max_mailboxes,
        "api_access": plan.api_access,
        "features": features,
        "cta": plan.cta_text or "Get started",
        "badge": plan.badge_text,
        "overage_percent": plan.overage_percent,
        "allow_overage_billing": plan.allow_overage_billing,
    }


# ---------------------------------------------------------------------------
# Getters
# ---------------------------------------------------------------------------


def get_tier(tier_id: str, db: Session | None = None) -> dict[str, Any]:
    """Return plan config dict; DB-first when db is provided, falls back to TIER_DEFAULTS."""
    if db is not None:
        from app.models import SubscriptionPlan

        plan = (
            db.query(SubscriptionPlan)
            .filter(
                SubscriptionPlan.plan_id == tier_id,
                SubscriptionPlan.is_active.is_(True),
            )
            .first()
        )
        if plan is not None:
            return _plan_to_dict(plan)
    return TIER_DEFAULTS.get(tier_id, TIER_DEFAULTS["free"])


def get_all_tiers(db: Session | None = None) -> list[dict[str, Any]]:
    """Return plans in display order; DB-first when db is provided."""
    if db is not None:
        from app.models import SubscriptionPlan

        plans = (
            db.query(SubscriptionPlan)
            .filter(SubscriptionPlan.is_active.is_(True))
            .order_by(SubscriptionPlan.sort_order)
            .all()
        )
        if plans:
            return [_plan_to_dict(p) for p in plans]
    return [TIER_DEFAULTS[tid] for tid in TIER_ORDER]


def seed_default_plans(db: Session) -> int:
    """Seed subscription_plans table from TIER_DEFAULTS if the table is empty.

    Called at application startup. Returns the number of plans inserted (0 if already seeded).
    """
    import json

    from app.models import SubscriptionPlan

    try:
        if db.query(SubscriptionPlan).count() > 0:
            return 0
    except Exception:
        return 0  # table may not exist yet during first migration

    inserted = 0
    for sort_order, (_, tier) in enumerate(TIER_DEFAULTS.items()):
        plan = SubscriptionPlan(
            plan_id=tier["id"],
            name=tier["name"],
            tagline=tier.get("tagline", ""),
            price_monthly=tier["price_monthly"],
            price_yearly=tier["price_yearly"],
            trial_days=tier.get("trial_days", 0),
            is_highlighted=tier.get("highlight", False),
            badge_text=tier.get("badge"),
            cta_text=tier.get("cta", "Get started"),
            lifetime_file_limit=tier["lifetime_file_limit"],
            daily_upload_limit=tier["daily_upload_limit"],
            monthly_upload_limit=tier["monthly_upload_limit"],
            max_storage_destinations=tier["max_storage_destinations"],
            max_ocr_pages_monthly=tier["max_ocr_pages_monthly"],
            max_file_size_mb=tier["max_file_size_mb"],
            max_mailboxes=tier.get("max_mailboxes", 0),
            api_access=tier.get("api_access", False),
            features=json.dumps(tier.get("features", [])),
            overage_percent=20,
            allow_overage_billing=False,
            sort_order=sort_order,
            is_active=True,
        )
        db.add(plan)
        inserted += 1
    try:
        db.commit()
        logger.info("Seeded %d default subscription plans", inserted)
    except Exception as exc:
        db.rollback()
        logger.error("Failed to seed subscription plans: %s", exc)
        inserted = 0
    return inserted


# ---------------------------------------------------------------------------
# Usage queries
# ---------------------------------------------------------------------------


def _today_utc() -> date:
    return datetime.now(timezone.utc).date()


def _scalar_count(query: Any) -> int:
    """Execute a count query and return an int, defaulting to 0 for NULL."""
    return query.scalar() or 0


def get_lifetime_file_count(db: Session, owner_id: str) -> int:
    """Total files ever processed by this user (not counting duplicates)."""
    from app.models import FileRecord

    return _scalar_count(
        db.query(func.count(FileRecord.id)).filter(FileRecord.owner_id == owner_id, FileRecord.is_duplicate.is_(False))
    )


def get_today_file_count(db: Session, owner_id: str) -> int:
    """Files processed by this user today (UTC, not counting duplicates)."""
    from app.models import FileRecord

    today = _today_utc()
    return _scalar_count(
        db.query(func.count(FileRecord.id)).filter(
            FileRecord.owner_id == owner_id,
            FileRecord.is_duplicate.is_(False),
            func.date(FileRecord.created_at) == today,
        )
    )


def get_month_file_count(db: Session, owner_id: str) -> int:
    """Files processed by this user this calendar month (UTC, not counting duplicates)."""
    from app.models import FileRecord

    today = _today_utc()
    return _scalar_count(
        db.query(func.count(FileRecord.id)).filter(
            FileRecord.owner_id == owner_id,
            FileRecord.is_duplicate.is_(False),
            func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"),
        )
    )


def get_year_file_count(db: Session, owner_id: str, period_start: datetime) -> int:
    """Files processed since the start of the current annual subscription period."""
    from app.models import FileRecord

    return _scalar_count(
        db.query(func.count(FileRecord.id)).filter(
            FileRecord.owner_id == owner_id,
            FileRecord.is_duplicate.is_(False),
            FileRecord.created_at >= period_start,
        )
    )


def _months_elapsed(period_start: datetime, now: datetime) -> int:
    """Calendar months elapsed since *period_start*, clamped to [1, 12]."""
    elapsed = (now.year - period_start.year) * 12 + (now.month - period_start.month) + 1
    return max(1, min(elapsed, 12))


# ---------------------------------------------------------------------------
# Limit enforcement
# ---------------------------------------------------------------------------


class QuotaExceeded(Exception):
    """Raised when a user has hit a subscription limit."""

    def __init__(self, message: str, limit_type: str, limit_value: int, current_value: int) -> None:
        super().__init__(message)
        self.limit_type = limit_type
        self.limit_value = limit_value
        self.current_value = current_value


def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None) -> None:
    """Raise :class:`QuotaExceeded` if this user is not allowed to upload another file.

    Skipped entirely when *owner_id* or *tier_id* is ``None`` (single-user mode).

    Enforcement model
    -----------------
    * **Announced limit** — the quota shown on the pricing page
      (``monthly_upload_limit`` in the plan).
    * **Overage buffer** — each plan stores ``overage_percent`` (default 20).
      Enforcement = announced × (1 + overage_percent / 100). A 150-doc/month
      plan with 20 % buffer is enforced at 180 docs.
    * **Overage flag** — if ``UserProfile.allow_overage`` is ``True``, quota
      checks are bypassed entirely so usage can be billed retroactively.
      (Not yet exposed in the admin UI — baked in for future billing.)
    * **Yearly carry-over** — yearly subscribers have cumulative quota:
      effective limit = monthly_limit × months_elapsed × overage_factor.
      Unused quota from earlier months rolls forward automatically.
    * **No daily cap** — ``daily_upload_limit`` is kept for display purposes
      only; it is never enforced.
    """
    if owner_id is None or tier_id is None:
        return

    tier = get_tier(tier_id, db)

    # Per-plan overage_percent overrides global config default
    overage_percent: int = tier.get("overage_percent", settings.subscription_overage_percent)
    overage_factor: float = 1.0 + overage_percent / 100.0

    from app.models import UserProfile

    profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
    allow_overage: bool = bool(profile.allow_overage) if profile else False
    billing_cycle: str = (profile.subscription_billing_cycle if profile else None) or "monthly"
    period_start: datetime | None = profile.subscription_period_start if profile else None

    # 1. Lifetime file cap (free tier) — always enforced regardless of overage flag
    lifetime_limit: int = tier["lifetime_file_limit"]
    if lifetime_limit > 0:
        enforcement_limit = int(lifetime_limit * overage_factor)
        count = get_lifetime_file_count(db, owner_id)
        if count >= enforcement_limit:
            raise QuotaExceeded(
                f"Lifetime file limit of {lifetime_limit} reached for the {tier['name']} plan. "
                "Please upgrade to continue processing documents.",
                limit_type="lifetime",
                limit_value=lifetime_limit,
                current_value=count,
            )

    # 2. Monthly cap — bypassed when allow_overage is True (future billing)
    if allow_overage:
        return

    monthly_limit: int = tier["monthly_upload_limit"]
    if monthly_limit > 0:
        if billing_cycle == "yearly" and period_start is not None:
            now = datetime.now(timezone.utc)
            months = _months_elapsed(period_start, now)
            cumulative_budget = int(monthly_limit * months * overage_factor)
            cumulative_used = get_year_file_count(db, owner_id, period_start)
            if cumulative_used >= cumulative_budget:
                raise QuotaExceeded(
                    f"Annual document quota for the {tier['name']} plan has been reached. "
                    "Unused monthly quota carries forward — your limit resets on your annual "
                    "renewal date, or you can upgrade your plan.",
                    limit_type="monthly",
                    limit_value=monthly_limit,
                    current_value=cumulative_used,
                )
        else:
            count = get_month_file_count(db, owner_id)
            enforcement_limit = int(monthly_limit * overage_factor)
            if count >= enforcement_limit:
                raise QuotaExceeded(
                    f"Monthly file limit of {monthly_limit} reached for the {tier['name']} plan. "
                    "Please upgrade your plan for more documents this month.",
                    limit_type="monthly",
                    limit_value=monthly_limit,
                    current_value=count,
                )


def get_user_tier_id(db: Session, owner_id: str | None) -> str:
    """Return the subscription tier id for *owner_id*, defaulting to 'free'."""
    if owner_id is None:
        return DEFAULT_TIER
    from app.models import UserProfile

    profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
    if profile and profile.subscription_tier:
        return profile.subscription_tier
    return DEFAULT_TIER


def get_user_usage(db: Session, owner_id: str) -> dict[str, int]:
    """Return file counts for *owner_id*, including carry-over data for yearly plans."""
    from app.models import UserProfile

    profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
    result: dict[str, int] = {
        "lifetime": get_lifetime_file_count(db, owner_id),
        "today": get_today_file_count(db, owner_id),
        "month": get_month_file_count(db, owner_id),
    }
    if profile and (profile.subscription_billing_cycle or "monthly") == "yearly" and profile.subscription_period_start:
        result["year_to_date"] = get_year_file_count(db, owner_id, profile.subscription_period_start)
    return result


# ---------------------------------------------------------------------------
# Subscription change management
# ---------------------------------------------------------------------------


class SubscriptionChangeError(Exception):
    """Raised when a requested subscription change is not permitted."""


def _tier_rank(tier_id: str) -> int:
    """Return the numeric rank of *tier_id* (0 = free … 3 = business).

    Unknown tier IDs are treated as rank 0 (free).
    """
    try:
        return TIER_ORDER.index(tier_id)
    except ValueError:
        return 0


def apply_pending_subscription_changes(db: Session, owner_id: str) -> bool:
    """Apply any pending subscription change that is now due.

    Checks whether the scheduled change date has arrived and, if so, applies
    the new tier immediately.

    Args:
        db: Database session.
        owner_id: Stable user identifier.

    Returns:
        ``True`` if a pending change was applied, ``False`` otherwise.
    """
    from app.models import UserProfile

    profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
    if not profile:
        return False

    pending_tier = profile.subscription_change_pending_tier
    pending_date = profile.subscription_change_pending_date
    if not pending_tier or not pending_date:
        return False

    now = datetime.now(timezone.utc)
    # Normalise pending_date to UTC-aware for comparison
    if pending_date.tzinfo is None:
        pending_date = pending_date.replace(tzinfo=timezone.utc)

    if now < pending_date:
        return False  # Not yet due

    old_tier = profile.subscription_tier or DEFAULT_TIER
    profile.subscription_tier = pending_tier
    profile.subscription_period_start = pending_date  # New period started at change date
    profile.subscription_change_pending_tier = None
    profile.subscription_change_pending_date = None
    try:
        db.commit()
        logger.info(
            "Applied pending subscription change for %s: %s → %s",
            owner_id,
            old_tier,
            pending_tier,
        )
    except Exception as exc:
        db.rollback()
        logger.error("Failed to apply pending subscription change for %s: %s", owner_id, exc)
        return False
    return True


def request_subscription_change(
    db: Session,
    owner_id: str,
    new_tier_id: str,
    billing_cycle: str = "monthly",
) -> dict[str, Any]:
    """Process a user-initiated subscription change request.

    Upgrade rules
    -------------
    Upgrades (moving to a higher-ranked tier) take effect **immediately**:
    the tier is switched and the period start is reset to *now*.  Any
    previously scheduled downgrade is cancelled.

    Downgrade rules
    ---------------
    Downgrades (moving to a lower-ranked tier) are **always scheduled** for
    the end of the current billing period:

    * If ``subscription_period_start`` is set and the period end is in the
      future, the change is queued for that date.
    * If there is no period start (e.g. admin-assigned tier), the period start
      is treated as *now* and the change is scheduled one month out.
    * If the period has already elapsed the change is applied immediately.

    Cancelling a pending downgrade
    --------------------------------
    Requesting the *current* tier when there is a pending change cancels that
    pending change.

    Args:
        db: Database session.
        owner_id: Stable user identifier.
        new_tier_id: Target plan ID (e.g. ``"starter"``).
        billing_cycle: ``"monthly"`` or ``"yearly"`` — stored on upgrade.

    Returns:
        A dict with keys ``immediate`` (bool), ``effective_date`` (ISO-8601 str
        or ``None``), ``old_tier``, ``new_tier``, ``message``.

    Raises:
        SubscriptionChangeError: If the requested change is not allowed.
    """
    from app.models import UserProfile

    now = datetime.now(timezone.utc)

    # Validate target tier
    valid_ids = [t["id"] for t in get_all_tiers(db)]
    if new_tier_id not in valid_ids:
        raise SubscriptionChangeError(f"Unknown subscription plan: {new_tier_id!r}")

    # Ensure profile row exists
    profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
    if not profile:
        profile = UserProfile(user_id=owner_id)
        db.add(profile)
        db.flush()

    old_tier_id = profile.subscription_tier or DEFAULT_TIER

    # Cancel pending change when user re-selects their current active tier
    if new_tier_id == old_tier_id:
        if profile.subscription_change_pending_tier:
            profile.subscription_change_pending_tier = None
            profile.subscription_change_pending_date = None
            db.commit()
            return {
                "immediate": True,
                "effective_date": None,
                "old_tier": old_tier_id,
                "new_tier": old_tier_id,
                "message": "Pending subscription change cancelled.",
            }
        raise SubscriptionChangeError("You are already on this plan.")

    old_rank = _tier_rank(old_tier_id)
    new_rank = _tier_rank(new_tier_id)
    is_upgrade = new_rank > old_rank

    if is_upgrade:
        # Apply immediately — reset period start
        profile.subscription_tier = new_tier_id
        profile.subscription_billing_cycle = billing_cycle
        profile.subscription_period_start = now
        # Cancel any previously scheduled downgrade
        profile.subscription_change_pending_tier = None
        profile.subscription_change_pending_date = None
        try:
            db.commit()
        except Exception as exc:
            db.rollback()
            raise SubscriptionChangeError("Failed to apply subscription upgrade.") from exc
        logger.info("Immediate upgrade for %s: %s → %s", owner_id, old_tier_id, new_tier_id)
        return {
            "immediate": True,
            "effective_date": None,
            "old_tier": old_tier_id,
            "new_tier": new_tier_id,
            "message": f"You have been upgraded to {get_tier(new_tier_id, db)['name']}. "
            "Your new limits are active immediately.",
        }

    # --- Downgrade path ---
    # Determine end of the *first* billing period for the current plan.
    # Rule: a downgrade is immediate if the user has completed at least one
    # full month on the current plan; otherwise it is scheduled for the
    # end of that first month.  This prevents gaming: a user who just
    # upgraded cannot immediately downgrade to avoid paying the first month.
    import calendar

    period_start: datetime | None = profile.subscription_period_start
    if period_start is None:
        # No recorded start → treat today as start; schedule for one month out
        period_start = now
        profile.subscription_period_start = period_start

    if period_start.tzinfo is None:
        period_start = period_start.replace(tzinfo=timezone.utc)

    # End of the first billing month (same day next month, clamped to valid day)
    next_month_num = period_start.month % 12 + 1
    next_year = period_start.year + (1 if period_start.month == 12 else 0)
    max_day = calendar.monthrange(next_year, next_month_num)[1]
    next_day = min(period_start.day, max_day)
    change_date = period_start.replace(year=next_year, month=next_month_num, day=next_day)

    if change_date <= now:
        profile.subscription_tier = new_tier_id
        profile.subscription_billing_cycle = billing_cycle
        profile.subscription_period_start = now
        profile.subscription_change_pending_tier = None
        profile.subscription_change_pending_date = None
        try:
            db.commit()
        except Exception as exc:
            db.rollback()
            raise SubscriptionChangeError("Failed to apply subscription downgrade.") from exc
        logger.info("Immediate downgrade for %s: %s → %s (period elapsed)", owner_id, old_tier_id, new_tier_id)
        return {
            "immediate": True,
            "effective_date": None,
            "old_tier": old_tier_id,
            "new_tier": new_tier_id,
            "message": f"Your subscription has been changed to {get_tier(new_tier_id, db)['name']}.",
        }

    # Schedule the downgrade
    profile.subscription_change_pending_tier = new_tier_id
    profile.subscription_change_pending_date = change_date
    try:
        db.commit()
    except Exception as exc:
        db.rollback()
        raise SubscriptionChangeError("Failed to schedule subscription downgrade.") from exc

    logger.info(
        "Scheduled downgrade for %s: %s → %s on %s",
        owner_id,
        old_tier_id,
        new_tier_id,
        change_date.isoformat(),
    )
    return {
        "immediate": False,
        "effective_date": change_date.isoformat(),
        "old_tier": old_tier_id,
        "new_tier": new_tier_id,
        "message": (
            f"Your downgrade to {get_tier(new_tier_id, db)['name']} has been scheduled for "
            f"{change_date.strftime('%B')} {change_date.day}, {change_date.year}. "
            "You will continue to have access to your current plan until then."
        ),
    }


def cancel_pending_subscription_change(db: Session, owner_id: str) -> bool:
    """Cancel a pending subscription change for *owner_id*.

    Args:
        db: Database session.
        owner_id: Stable user identifier.

    Returns:
        ``True`` if a pending change was cancelled, ``False`` if there was nothing to cancel.
    """
    from app.models import UserProfile

    profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
    if not profile or not profile.subscription_change_pending_tier:
        return False

    profile.subscription_change_pending_tier = None
    profile.subscription_change_pending_date = None
    try:
        db.commit()
        logger.info("Cancelled pending subscription change for %s", owner_id)
    except Exception as exc:
        db.rollback()
        logger.error("Failed to cancel pending subscription change for %s: %s", owner_id, exc)
        return False
    return True
