"""Simple adaptive rate limiting — ~15 lines of core logic.

From the spec: "That's it. Not 2-3 weeks. ~15 lines. Ships Day 1."

Logic:
- Start at 15 invitations/day
- If acceptance rate >30%: increase by 5 (cap 50)
- If acceptance rate <15%: decrease by 5 (floor 10)
- If blocked: halve (floor 5)
- Hard weekly cap: 100 (200 for Sales Navigator)

Runs 24/7 — autonomous bot has no working hours restriction.
Randomized delays: 15-45 minutes between sends.
"""

from __future__ import annotations

import logging
import random
import time
from datetime import date, datetime, timedelta
from typing import Any

from .. import constants
from ..config import load_config
from ..db import aio as db
from ..db.async_bridge import run_db

logger = logging.getLogger(__name__)

# Block type constants — classify WHY sends are blocked
BLOCK_NONE = ""        # Not blocked
BLOCK_TIME = "time"    # Outside working hours/days — both channels wait
BLOCK_DAILY = "daily"  # Daily LinkedIn limit hit — email can take over
BLOCK_WEEKLY = "weekly"  # Weekly LinkedIn cap hit — email can take over
BLOCK_PENDING = "pending"  # LinkedIn pending invitation cap hit — account-level constraint


async def _get_effective_caps() -> tuple[int, int]:
    """Return (weekly_cap, daily_max) based on account type (Sales Nav or regular)."""
    if await db.get_setting("has_sales_navigator", False):
        return (
            constants.WEEKLY_INVITATION_CAP_SALES_NAV,
            constants.MAX_DAILY_INVITATIONS_SALES_NAV,
        )
    return constants.WEEKLY_INVITATION_CAP, constants.MAX_DAILY_INVITATIONS


async def adjust_daily_limit(current: int, stats: dict[str, Any]) -> int:
    """Adaptive daily limit based on acceptance rate and error rate.

    Args:
        current: Current daily limit
        stats: Dict with 'sent', 'accepted', 'blocked', 'errors' keys

    Returns:
        New daily limit
    """
    if stats.get("blocked"):
        return max(constants.MIN_DAILY_LIMIT, current // 2)

    sent = stats.get("sent", 0)
    accepted = stats.get("accepted", 0)
    errors = stats.get("errors", 0)

    if sent == 0:
        return current

    rate = accepted / sent
    error_rate = errors / sent if sent > 0 else 0

    _, daily_max = await _get_effective_caps()

    # High error rate (>20%) — aggressive pullback
    if error_rate > 0.20 and sent >= 5:
        reduction = max(constants.RAMP_STEP, int(current * 0.25))
        return max(constants.MIN_DAILY_LIMIT, current - reduction)

    if (
        rate > constants.ACCEPTANCE_RATE_RAMP_UP
        and error_rate < 0.05
        and current < daily_max
    ):
        return current + constants.RAMP_STEP

    if rate < constants.ACCEPTANCE_RATE_PULL_BACK:
        return max(constants.MIN_DAILY_LIMIT, current - constants.RAMP_STEP)

    return current


async def can_send_now(
    *, skip_time_check: bool = False
) -> tuple[bool, str, str]:
    """Check if we can send a LinkedIn invitation right now.

    Args:
        skip_time_check: If True, skip working hours/days check.
            Used when a campaign has send_in_business_hours=off.

    Returns (can_send, reason_if_not, block_type).
    block_type is one of: BLOCK_NONE, BLOCK_TIME, BLOCK_DAILY, BLOCK_WEEKLY.
    """
    now = datetime.now()

    if not skip_time_check:
        cfg = load_config()
        working_hours = cfg.get("working_hours", {})

        start_hour = working_hours.get("start", constants.DEFAULT_START_HOUR)
        end_hour = working_hours.get("end", constants.DEFAULT_END_HOUR)
        active_days = working_hours.get("days", constants.DEFAULT_ACTIVE_DAYS)

        # Check day of week (0=Monday)
        if now.weekday() not in active_days:
            return (
                False,
                f"Outside working days. Active days: {active_days}",
                BLOCK_TIME,
            )

        # Check working hours
        if now.hour < start_hour:
            return (
                False,
                f"Before working hours ({start_hour}:00). Queued for later.",
                BLOCK_TIME,
            )
        if now.hour >= end_hour:
            return (
                False,
                f"After working hours ({end_hour}:00). Queued for tomorrow.",
                BLOCK_TIME,
            )

    # Sync rate limits from outreaches table (cloud scheduler may have sent some)
    await db.sync_rate_limits_from_outreaches()

    # Check daily limit
    rate_data = await db.get_rate_limit_today()
    sent_today = rate_data.get("sent", 0)
    daily_limit = max(
        rate_data.get("daily_limit", constants.INITIAL_DAILY_INVITATIONS),
        constants.MIN_DAILY_LIMIT,
    )

    if sent_today >= daily_limit:
        return (
            False,
            f"Daily limit reached ({sent_today}/{daily_limit}). Resumes tomorrow.",
            BLOCK_DAILY,
        )

    # Check weekly cap (subscription-aware)
    weekly_sent = await db.get_weekly_invitation_sum()
    weekly_cap, _ = await _get_effective_caps()
    if weekly_sent >= weekly_cap:
        return (
            False,
            f"Weekly limit reached ({weekly_sent}/{weekly_cap}). Resumes next week.",
            BLOCK_WEEKLY,
        )

    return True, "", BLOCK_NONE


async def can_send_email_now() -> tuple[bool, str]:
    """Check if we can send a cold email right now.

    Respects the same working hours as LinkedIn but uses separate
    email-specific daily/weekly limits.

    Returns (can_send, reason_if_not).
    """
    now = datetime.now()
    cfg = load_config()
    working_hours = cfg.get("working_hours", {})

    start_hour = working_hours.get("start", constants.DEFAULT_START_HOUR)
    end_hour = working_hours.get("end", constants.DEFAULT_END_HOUR)
    active_days = working_hours.get("days", constants.DEFAULT_ACTIVE_DAYS)

    # Check day of week
    if now.weekday() not in active_days:
        return False, f"Outside working days. Active days: {active_days}"

    # Check working hours
    if now.hour < start_hour:
        return False, f"Before working hours ({start_hour}:00)."
    if now.hour >= end_hour:
        return False, f"After working hours ({end_hour}:00)."

    # Check daily email limit
    rate_data = await db.get_email_rate_limit_today()
    sent_today = rate_data.get("sent", 0)
    daily_limit = constants.DAILY_EMAIL_OUTREACH_LIMIT

    if sent_today >= daily_limit:
        return (
            False,
            f"Daily email limit reached ({sent_today}/{daily_limit}). "
            "Resumes tomorrow.",
        )

    # Check weekly email cap
    weekly_sent = await db.get_weekly_email_sum()
    weekly_cap = constants.WEEKLY_EMAIL_OUTREACH_CAP
    if weekly_sent >= weekly_cap:
        return (
            False,
            f"Weekly email limit reached ({weekly_sent}/{weekly_cap}). "
            "Resumes next week.",
        )

    return True, ""


async def increment_email_sent() -> None:
    """Track an email send in the email rate limits table."""
    from ..db.queries import increment_email_sent as _inc

    await run_db(_inc)


def get_next_delay() -> int:
    """Get randomized delay in seconds for the next send.

    Returns a random delay between MIN_DELAY_MINUTES and MAX_DELAY_MINUTES.
    """
    min_secs = constants.MIN_DELAY_MINUTES * 60
    max_secs = constants.MAX_DELAY_MINUTES * 60
    return random.randint(min_secs, max_secs)


def _query_rate_limit_rows(week_ago: str) -> list[dict]:
    """Sync helper to fetch per-day rate-limit rows for the rolling 7-day window."""
    from ..db.schema import get_db

    conn = get_db()
    rows = conn.execute(
        """SELECT date, sent FROM rate_limits
           WHERE date >= ? AND sent > 0
           ORDER BY date ASC""",
        (week_ago,),
    ).fetchall()
    conn.close()
    return [dict(r) for r in rows]


async def estimate_weekly_limit_reset() -> tuple[bool, int, str]:
    """Estimate when the weekly invitation limit will free up capacity.

    The weekly limit is a rolling 7-day window. Capacity frees up when
    the oldest day's invitations drop out of the window.

    Returns:
        (is_at_limit, seconds_until_capacity, human_readable_message)
    """
    weekly_cap, _ = await _get_effective_caps()
    weekly_sent = await db.get_weekly_invitation_sum()

    if weekly_sent < weekly_cap:
        return False, 0, ""

    # Find per-day sent counts in the 7-day window
    today = date.today()
    week_ago = (today - timedelta(days=6)).isoformat()
    rows = await run_db(_query_rate_limit_rows, week_ago)

    if not rows:
        return True, 86400, "~1 day"

    # Walk days oldest-first: find when enough invites drop to go under cap
    overshoot = weekly_sent - weekly_cap
    now = datetime.now()
    for row in rows:
        row_date = date.fromisoformat(row["date"])
        row_sent = row["sent"]
        overshoot -= row_sent
        if overshoot < 0:
            # This day dropping out frees enough capacity
            drop_date = row_date + timedelta(days=7)
            drop_dt = datetime.combine(drop_date, datetime.min.time())
            secs = max(0, int((drop_dt - now).total_seconds()))
            hours = secs // 3600
            if hours >= 24:
                days = hours // 24
                msg = f"~{days} day{'s' if days != 1 else ''}"
            elif hours > 0:
                msg = f"~{hours} hour{'s' if hours != 1 else ''}"
            else:
                msg = "< 1 hour"
            return True, secs, msg

    # All days needed to drop — capacity frees when last day drops
    oldest_date = date.fromisoformat(rows[-1]["date"])
    drop_date = oldest_date + timedelta(days=7)
    drop_dt = datetime.combine(drop_date, datetime.min.time())
    secs = max(0, int((drop_dt - now).total_seconds()))
    days = max(1, secs // 86400)
    return True, secs, f"~{days} day{'s' if days != 1 else ''}"


async def update_limits_after_send(blocked: bool = False) -> int:
    """Update rate limits after a send attempt. Returns new daily limit."""
    rate_data = await db.get_rate_limit_today()

    stats = {
        "sent": rate_data.get("sent", 0),
        "accepted": rate_data.get("accepted", 0),
        "blocked": blocked,
    }

    new_limit = await adjust_daily_limit(
        max(
            rate_data.get("daily_limit", constants.INITIAL_DAILY_INVITATIONS),
            constants.MIN_DAILY_LIMIT,
        ),
        stats,
    )

    if new_limit != rate_data.get("daily_limit"):
        await db.update_daily_limit(new_limit)
        logger.info(
            "Daily limit adjusted: %s -> %s",
            rate_data.get("daily_limit"),
            new_limit,
        )

    return new_limit


# ──────────────────────────────────────────────
# Global engagement budget
# ──────────────────────────────────────────────


async def check_engagement_budget(
    engagement_type: str = "",
) -> tuple[bool, int, int]:
    """Check if the engagement budget has room.

    When engagement_type is "react" or "comment", checks the per-type budget.
    When empty (default), checks the global combined budget — used by the
    scheduler as a safety net before executing any engagement job.

    Returns (has_budget, current_count, limit).
    """
    window = constants.ENGAGEMENT_BUDGET_WINDOW_MINUTES

    if engagement_type == "react":
        current = await db.get_engagement_burst_count_by_type(
            minutes=window, action_type="react"
        )
        limit = constants.ENGAGEMENT_REACT_BUDGET_PER_WINDOW
    elif engagement_type == "comment":
        current = await db.get_engagement_burst_count_by_type(
            minutes=window, action_type="comment"
        )
        limit = constants.ENGAGEMENT_COMMENT_BUDGET_PER_WINDOW
    else:
        current = await db.get_engagement_burst_count(minutes=window)
        limit = constants.ENGAGEMENT_BUDGET_PER_WINDOW

    return current < limit, current, limit


# ──────────────────────────────────────────────
# Pending invitation limit management
# ──────────────────────────────────────────────

_pending_cache: dict[str, Any] = {}


async def get_cached_pending_invitations(
    client: Any, account_id: str
) -> tuple[int, list[dict]]:
    """Return (count, invitation_list) with a 5-minute TTL cache.

    On cache miss, fetches from LinkedIn via client.get_pending_invitations().
    """
    now = time.time()
    fetched_at = _pending_cache.get("fetched_at", 0)
    if (
        _pending_cache.get("invitations") is not None
        and (now - fetched_at) < constants.PENDING_CACHE_TTL_SECONDS
    ):
        invitations = _pending_cache["invitations"]
        return len(invitations), invitations

    try:
        invitations = await client.get_pending_invitations(account_id)
    except Exception as e:
        logger.warning("Failed to fetch pending invitations: %s", e)
        # Return cached data if available, otherwise empty
        if _pending_cache.get("invitations") is not None:
            invitations = _pending_cache["invitations"]
            return len(invitations), invitations
        return 0, []

    _pending_cache["invitations"] = invitations
    _pending_cache["fetched_at"] = now
    return len(invitations), invitations


def invalidate_pending_cache() -> None:
    """Clear the pending invitation cache. Call after send or withdrawal."""
    _pending_cache.clear()


async def check_pending_limit(
    client: Any, account_id: str
) -> tuple[bool, str, str]:
    """Check if LinkedIn's pending invitation cap is reached.

    Returns (can_send, reason_if_not, block_type).
    """
    count, _ = await get_cached_pending_invitations(client, account_id)
    limit = constants.MAX_PENDING_INVITATIONS

    if count >= limit:
        return (
            False,
            f"LinkedIn pending invitation limit reached ({count}/{limit}). "
            "Oldest invitations need withdrawal to free spots.",
            BLOCK_PENDING,
        )

    if count >= limit - constants.PENDING_INVITE_BUFFER:
        logger.warning(
            "Approaching LinkedIn pending invitation limit: %d/%d",
            count,
            limit,
        )

    return True, "", BLOCK_NONE


async def withdraw_oldest_to_free_spot(
    client: Any, account_id: str
) -> dict[str, Any]:
    """Withdraw the oldest pending invitation to free a spot.

    Returns {success, invitation_id, days_old, name}.
    """
    count, invitations = await get_cached_pending_invitations(
        client, account_id
    )
    if not invitations:
        return {"success": False, "error": "No pending invitations to withdraw"}

    # Parse timestamps and sort oldest-first
    now_ts = int(time.time())
    candidates = []
    for inv in invitations:
        inv_time = (
            inv.get("parsed_datetime")
            or inv.get("timestamp")
            or inv.get("created_at")
            or 0
        )
        if isinstance(inv_time, str):
            try:
                inv_time = int(
                    datetime.fromisoformat(
                        inv_time.replace("Z", "+00:00")
                    ).timestamp()
                )
            except (ValueError, TypeError):
                inv_time = 0
        inv_id = inv.get("id") or inv.get("invitation_id") or ""
        if inv_id and inv_time:
            candidates.append((inv_time, inv_id, inv))

    if not candidates:
        return {"success": False, "error": "No valid invitation candidates"}

    candidates.sort(key=lambda x: x[0])  # oldest first
    oldest_time, oldest_id, oldest_inv = candidates[0]
    days_old = (now_ts - oldest_time) // 86400
    name = oldest_inv.get("invited_user") or oldest_inv.get("name") or ""

    try:
        result = await client.withdraw_invitation(account_id, oldest_id)
    except Exception as e:
        logger.warning("Failed to withdraw invitation %s: %s", oldest_id, e)
        return {"success": False, "error": str(e)}

    if result.get("success"):
        invalidate_pending_cache()
        await db.log_action(
            "invite_withdrawn_to_free_spot",
            result="success",
            details={
                "invitation_id": oldest_id,
                "days_old": days_old,
                "name": name,
                "pending_count_before": count,
            },
        )
        logger.info(
            "Withdrew oldest invitation %s (%d days old, %s) to free spot",
            oldest_id,
            days_old,
            name,
        )
        return {
            "success": True,
            "invitation_id": oldest_id,
            "days_old": days_old,
            "name": name,
        }

    error = result.get("error", "Unknown error")
    logger.warning("Failed to withdraw invitation %s: %s", oldest_id, error)
    return {"success": False, "error": error}
