"""Running data processing module for KaiserLift.

This module provides functionality for processing running/cardio data,
calculating pace metrics, and generating training targets based on
Pareto front analysis.
"""

import os
from pathlib import Path
from typing import IO, Iterable

import numpy as np
import pandas as pd

SECONDS_PER_HOUR = 3600


def calculate_pace_from_duration(
    duration_minutes: float, distance_miles: float
) -> float:
    """Calculate pace in seconds per mile from duration and distance.

    Parameters
    ----------
    duration_minutes : float
        Total duration of run in minutes
    distance_miles : float
        Total distance in miles

    Returns
    -------
    float
        Pace in seconds per mile

    Examples
    --------
    >>> calculate_pace_from_duration(27.0, 3.0)
    540.0
    >>> calculate_pace_from_duration(47.5, 5.0)
    570.0
    """
    if pd.isna(duration_minutes) or pd.isna(distance_miles) or distance_miles <= 0:
        return np.nan
    try:
        # Convert duration from minutes to seconds, then divide by distance
        pace_seconds_per_mile = (duration_minutes * 60) / distance_miles
        return pace_seconds_per_mile
    except (ValueError, ZeroDivisionError):
        return np.nan


def seconds_to_pace_string(seconds: float) -> str:
    """Convert seconds/mile to 'M:SS' format.

    Parameters
    ----------
    seconds : float
        Pace in seconds per mile

    Returns
    -------
    str
        Pace formatted as 'M:SS'

    Examples
    --------
    >>> seconds_to_pace_string(570)
    '9:30'
    >>> seconds_to_pace_string(525)
    '8:45'
    """
    if pd.isna(seconds) or seconds <= 0:
        return np.nan
    minutes = int(seconds // 60)
    secs = int(seconds % 60)
    return f"{minutes}:{secs:02d}"


def process_running_csv_files(files: Iterable[IO | Path]) -> pd.DataFrame:
    """Load and clean running data from CSV files.

    Parameters
    ----------
    files:
        Iterable of file paths or file-like objects. Uses the last file
        or most recently created file if multiple paths provided.

    Returns
    -------
    pd.DataFrame
        DataFrame with columns: [Date, Exercise, Category, Distance, Pace]
        Pace is calculated from Duration and Distance and returned in seconds/mile.
        Distance in miles.
    """

    file_list = list(files)
    if not file_list:
        raise ValueError("No CSV files provided")

    def _is_pathlike(obj: object) -> bool:
        return isinstance(obj, (str, os.PathLike))

    if all(_is_pathlike(f) for f in file_list):
        latest_file = max(file_list, key=os.path.getctime)
        print(f"Using {latest_file}")
        data_source = latest_file
    else:
        data_source = file_list[-1]

    df = pd.read_csv(data_source)
    df.columns = df.columns.str.strip()

    # Handle distance and duration column variants (e.g., "Distance (miles)", "Duration (minutes)")
    distance_like = next((c for c in df.columns if c.startswith("Distance (")), None)
    if distance_like and "Distance" not in df.columns:
        df = df.rename(columns={distance_like: "Distance"})

    duration_like = next((c for c in df.columns if c.startswith("Duration (")), None)
    if duration_like and "Duration" not in df.columns:
        df = df.rename(columns={duration_like: "Duration"})

    # Parse date
    df["Date"] = pd.to_datetime(df["Date"], format="%Y-%m-%d")
    df_sorted = df.sort_values(by="Date", ascending=True)

    # Ensure numeric types for Distance and Duration
    df_sorted["Distance"] = pd.to_numeric(
        df_sorted.get("Distance", np.nan), errors="coerce"
    )
    df_sorted["Duration"] = pd.to_numeric(
        df_sorted.get("Duration", np.nan), errors="coerce"
    )

    # Calculate pace from Duration (minutes) and Distance (miles)
    df_sorted["Pace"] = df_sorted.apply(
        lambda row: calculate_pace_from_duration(
            row.get("Duration", np.nan), row.get("Distance", np.nan)
        ),
        axis=1,
    )

    # Filter to only Cardio category
    df_sorted = df_sorted[df_sorted["Category"] == "Cardio"]

    # Drop unused columns (Duration is dropped after calculating Pace)
    df_sorted = df_sorted.drop(
        [
            "Distance Unit",
            "Duration Unit",
            "Duration",
            "Time",
            "Weight",
            "Reps",
            "Weight Unit",
        ],
        axis=1,
        errors="ignore",
    )

    # Ensure required columns exist
    for col in ["Distance", "Pace"]:
        if col not in df_sorted.columns:
            df_sorted[col] = np.nan

    return df_sorted[["Date", "Exercise", "Category", "Distance", "Pace"]].dropna(
        subset=["Distance", "Pace"]
    )


def highest_pace_per_distance(df: pd.DataFrame) -> pd.DataFrame:
    """Find best (fastest) time for each distance using Pareto dominance.

    Groups by (Exercise, Distance) and finds minimum pace (lower = faster,
    equivalent to minimum total time for the same distance).
    Applies Pareto dominance filter in distance-time space: removes records
    dominated by others that ran at least as far in less total time.

    Parameters
    ----------
    df : pd.DataFrame
        Running data with columns: [Date, Exercise, Category, Distance, Pace]

    Returns
    -------
    pd.DataFrame
        Pareto records representing true running PRs

    Notes
    -----
    A record (D, P) is dominated if there exists another record (D', P') where:
    - D' >= D (at least as far)
    - P' * D' <= P * D (total time at least as fast)

    Dominance is based on total time (Pace * Distance), not pace alone, to
    match the distance-vs-time chart axes.  A longer run that takes more total
    time does NOT dominate a shorter run, even if the pace was faster.
    """

    df_copy = df.copy()
    df_copy["Distance"] = pd.to_numeric(df_copy["Distance"], errors="coerce")
    df_copy["Pace"] = pd.to_numeric(df_copy["Pace"], errors="coerce")
    df_copy = df_copy.dropna(subset=["Exercise", "Distance", "Pace"])

    if df_copy.empty:
        return pd.DataFrame(columns=df.columns)

    # Find fastest pace (minimum) for each (Exercise, Distance) pair.
    # For the same distance, min pace == min total time, so this is correct.
    idx = df_copy.groupby(["Exercise", "Distance"])["Pace"].idxmin()
    best_pace_sets = df_copy.loc[idx].copy()

    # Precompute total time (seconds) for each record to use in dominance check.
    best_pace_sets["_TotalTime"] = best_pace_sets["Pace"] * best_pace_sets["Distance"]

    # Apply Pareto dominance filter in distance-time space.
    def is_dominated(row, group_df):
        """Check if this running record is dominated in distance-time space.

        Record (D, T) is dominated if exists (D', T') where:
        - D' >= D (at least as far)
        - T' <= T (at least as fast total time)
        """
        dominating = group_df[
            (group_df["Distance"] >= row["Distance"])
            & (group_df["_TotalTime"] <= row["_TotalTime"])
        ]
        # More than just itself means it's dominated
        return len(dominating) > 1

    final_indices = []
    for exercise_name, group in best_pace_sets.groupby("Exercise"):
        rows_to_keep = group[~group.apply(lambda row: is_dominated(row, group), axis=1)]
        final_indices.extend(rows_to_keep.index.tolist())

    result = best_pace_sets.loc[final_indices].sort_values(["Exercise", "Distance"])
    return result.drop(columns=["_TotalTime"])


# Breakpoints for the variable Riegel pace exponent.
# Standard Riegel (0.06) is well-calibrated for road distances up to half
# marathon. Beyond that, physiological fatigue accumulates non-linearly —
# glycogen depletion, muscle damage, and sleep deprivation at ultra distances
# all cause pace to drop off faster than 0.06 predicts. Values above marathon
# distance are derived from empirical ultra-marathon race data.
_RIEGEL_EXPONENT_BREAKPOINTS: list[tuple[float, float]] = [
    (0.0, 0.06),
    (13.1, 0.06),  # half marathon — standard Riegel holds here
    (26.2, 0.07),  # marathon
    (50.0, 0.11),  # 50 miles
    (100.0, 0.14),  # 100 miles
]


def riegel_pace_exponent(target_distance: float) -> float:
    """Return the Riegel pace exponent for the given target distance (miles).

    Uses a piecewise linear interpolation between empirically-derived
    breakpoints.  The exponent grows with distance to capture the accelerating
    fatigue at ultra-marathon distances that the classic fixed value of 0.06
    under-predicts.

    Parameters
    ----------
    target_distance : float
        Target distance in miles.

    Returns
    -------
    float
        Pace exponent ``b`` such that ``pace2 = pace1 * (d2/d1)^b``.
        Equivalently the time exponent is ``1 + b``.
    """
    bp = _RIEGEL_EXPONENT_BREAKPOINTS
    if target_distance <= bp[0][0]:
        return bp[0][1]
    if target_distance >= bp[-1][0]:
        return bp[-1][1]
    for i in range(len(bp) - 1):
        d0, e0 = bp[i]
        d1, e1 = bp[i + 1]
        if d0 <= target_distance <= d1:
            t = (target_distance - d0) / (d1 - d0)
            return e0 + t * (e1 - e0)
    return bp[0][1]  # fallback — should never be reached


def estimate_pace_at_distance(
    best_pace: float, best_distance: float, target_distance: float
) -> float:
    """Estimate pace at a different distance using a variable-exponent Riegel formula.

    Extends the classic Riegel power law (T2 = T1 * (D2/D1)^1.06) with a
    distance-dependent exponent that steepens for ultra-marathon distances,
    reflecting the non-linear fatigue accumulation seen in empirical race data.

    Parameters
    ----------
    best_pace : float
        Best known pace in seconds/mile
    best_distance : float
        Distance at which best_pace was achieved (miles)
    target_distance : float
        Distance to estimate pace for (miles)

    Returns
    -------
    float
        Estimated pace in seconds/mile at target distance

    Examples
    --------
    >>> estimate_pace_at_distance(570, 5.0, 10.0)  # Double distance
    596.7  # Predicted slower pace
    """

    if best_distance <= 0 or target_distance <= 0 or pd.isna(best_pace):
        return np.nan

    if target_distance == best_distance:
        return float(best_pace)

    distance_ratio = target_distance / best_distance
    pace_factor = distance_ratio ** riegel_pace_exponent(target_distance)

    estimated_pace = best_pace * pace_factor
    return estimated_pace


def add_speed_metric_column(df: pd.DataFrame) -> pd.DataFrame:
    """Add a 'Speed' column in mph for easier interpretation.

    Converts pace (sec/mi) to speed (mph) using: speed = SECONDS_PER_HOUR / pace

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame with 'Pace' column in seconds/mile

    Returns
    -------
    pd.DataFrame
        DataFrame with additional 'Speed' column in mph
    """

    df_copy = df.copy()
    df_copy["Distance"] = pd.to_numeric(df_copy["Distance"], errors="coerce")
    df_copy["Pace"] = pd.to_numeric(df_copy["Pace"], errors="coerce")

    # Convert pace (sec/mi) to speed (mph)
    df_copy["Speed"] = df_copy["Pace"].apply(
        lambda p: SECONDS_PER_HOUR / p if pd.notna(p) and p > 0 else np.nan
    )

    return df_copy


def df_next_running_targets(df_records: pd.DataFrame) -> pd.DataFrame:
    """Generate next achievable running targets based on Pareto front.

    Targets are nestled between neighboring Pareto points using local deltas
    so they stay visually inside the front on the log-scale chart:

    - Mid-gap targets: +10% of the distance delta to the right and +10% of the
      speed delta above the slower point, capped just below the faster point.
    - Left endpoint: if the fastest Pareto effort is longer than 1 mile,
      always place a nominal 1-mile target at a faster speed (using the
      estimated 1-mile pace when possible, otherwise a small speed bump).
      If the fastest effort is already <= 1 mile, stay at that distance and
      bump speed using 10% of the first speed gap (with a small minimum bump).
    - Right endpoint: move farther than the longest Pareto effort and slow
      down by at least 1 mph when feasible, but never below half the longest
      speed so the target stays realistic.

    Parameters
    ----------
    df_records : pd.DataFrame
        Pareto running records with columns: [Exercise, Distance, Pace]

    Returns
    -------
    pd.DataFrame
        Target runs with columns: [Exercise, Distance, Pace, Speed]

    Examples
    --------
    Given PRs at 5.0 mi @ 9:30 and 10.0 mi @ 10:00, generates a target
    around 5.5 mi at a pace between the two PRs using the 10% deltas.
    """

    rows = []

    for ex in df_records["Exercise"].unique():
        ed = df_records[df_records["Exercise"] == ex].sort_values("Distance")

        if ed.empty:
            continue

        distances = ed["Distance"].tolist()
        paces = ed["Pace"].tolist()

        if len(distances) < 2:
            continue

        speeds = [
            SECONDS_PER_HOUR / p if pd.notna(p) and p > 0 else np.nan for p in paces
        ]

        # Left endpoint target: place at half the leftmost pareto distance
        # so the target is shorter and faster than the current best.
        left_target_distance = distances[0] / 2
        first_gap = (
            speeds[0] - speeds[1] if pd.notna(speeds[0]) and pd.notna(speeds[1]) else 0
        )
        left_speed_bump = max(first_gap * 0.10, speeds[0] * 0.02)
        left_target_speed = speeds[0] + left_speed_bump
        estimated_speed = np.nan
        if pd.notna(paces[0]) and paces[0] > 0:
            estimated_pace = estimate_pace_at_distance(
                paces[0], distances[0], left_target_distance
            )
            if pd.notna(estimated_pace) and estimated_pace > 0:
                estimated_speed = SECONDS_PER_HOUR / estimated_pace
        left_target_speed = max(
            speeds[0] + left_speed_bump,
            speeds[0] * 1.02,
            estimated_speed,
        )
        if left_target_speed > 0 and pd.notna(left_target_speed):
            rows.append(
                (ex, left_target_distance, SECONDS_PER_HOUR / left_target_speed)
            )

        for i in range(len(distances) - 1):
            left_distance = distances[i]
            right_distance = distances[i + 1]
            left_speed = speeds[i]
            right_speed = speeds[i + 1]

            distance_gap = right_distance - left_distance
            if distance_gap <= 0:
                continue

            # Position target between the two Pareto points (10% of the gap)
            target_distance = left_distance + 0.10 * distance_gap

            right_speed_safe = right_speed if pd.notna(right_speed) else 0
            left_speed_safe = left_speed if pd.notna(left_speed) else 0

            speed_gap = max(left_speed_safe - right_speed_safe, 0)
            target_speed = right_speed_safe + 0.10 * speed_gap

            if pd.notna(left_speed_safe) and left_speed_safe > 0:
                target_speed = min(target_speed, left_speed_safe * 0.995)

            if target_speed <= right_speed_safe:
                target_speed = min(left_speed_safe * 0.995, right_speed_safe * 1.02)

            if target_speed <= 0 or pd.isna(target_speed):
                continue

            target_pace = SECONDS_PER_HOUR / target_speed
            rows.append((ex, target_distance, target_pace))

        # Right endpoint target: farther than the longest distance and slower
        # by at least 1 mph when feasible, but never below half the speed.
        last_gap = distances[-1] - distances[-2]
        bump = last_gap if last_gap > 0 else distances[-1] * 0.1
        right_target_distance = distances[-1] + 0.1 * bump
        right_target_speed = speeds[-1] - 1.0
        min_allowed_speed = speeds[-1] * 0.5
        if right_target_speed < min_allowed_speed:
            right_target_speed = min_allowed_speed
        if right_target_speed > 0 and pd.notna(right_target_speed):
            rows.append(
                (ex, right_target_distance, SECONDS_PER_HOUR / right_target_speed)
            )

    target_df = pd.DataFrame(rows, columns=["Exercise", "Distance", "Pace"])
    return add_speed_metric_column(target_df)


def highest_pace_per_distance_pace_pareto(df: pd.DataFrame) -> pd.DataFrame:
    """Find Pareto front in pace-distance space (best pace per distance).

    Groups by (Exercise, Distance) and finds minimum pace (fastest), then
    applies Pareto dominance in pace-distance space: removes records dominated
    by others that ran at least as far at least as fast a pace.

    This differs from ``highest_pace_per_distance`` which uses total-time
    dominance (distance-vs-time chart axes).  Here dominance is defined purely
    on pace: a record (D, P) is dominated when another record (D', P') has
    D' >= D *and* P' <= P (faster or equal pace over at least as long a
    distance).

    Parameters
    ----------
    df : pd.DataFrame
        Running data with columns: [Date, Exercise, Category, Distance, Pace]

    Returns
    -------
    pd.DataFrame
        Pareto records for the pace-vs-distance chart

    Notes
    -----
    A record (D, P) is dominated if there exists another record (D', P') where:
    - D' >= D (at least as far)
    - P' <= P (at least as fast pace, i.e. fewer seconds per mile)
    """

    df_copy = df.copy()
    df_copy["Distance"] = pd.to_numeric(df_copy["Distance"], errors="coerce")
    df_copy["Pace"] = pd.to_numeric(df_copy["Pace"], errors="coerce")
    df_copy = df_copy.dropna(subset=["Exercise", "Distance", "Pace"])

    if df_copy.empty:
        return pd.DataFrame(columns=df.columns)

    # Find fastest pace (minimum) for each (Exercise, Distance) pair.
    idx = df_copy.groupby(["Exercise", "Distance"])["Pace"].idxmin()
    best_pace_sets = df_copy.loc[idx].copy()

    # Apply Pareto dominance filter in pace-distance space.
    def is_dominated_pace(row, group_df):
        """Check if this record is dominated in pace-distance space.

        Record (D, P) is dominated if exists (D', P') where:
        - D' >= D (at least as far)
        - P' <= P (at least as fast pace)
        """
        dominating = group_df[
            (group_df["Distance"] >= row["Distance"])
            & (group_df["Pace"] <= row["Pace"])
        ]
        # More than just itself means it's dominated
        return len(dominating) > 1

    final_indices = []
    for exercise_name, group in best_pace_sets.groupby("Exercise"):
        rows_to_keep = group[
            ~group.apply(lambda row: is_dominated_pace(row, group), axis=1)
        ]
        final_indices.extend(rows_to_keep.index.tolist())

    result = best_pace_sets.loc[final_indices].sort_values(["Exercise", "Distance"])
    return result


def predict_race_pace(
    df_records: pd.DataFrame, exercise: str, target_distance: float
) -> dict:
    """Predict target pace for a specific race distance.

    Uses Pareto front records to estimate achievable pace at target distance.
    Provides both optimistic (best case) and conservative (safe) estimates.

    Parameters
    ----------
    df_records : pd.DataFrame
        Pareto running records with columns: [Exercise, Distance, Pace]
    exercise : str
        Exercise name (e.g., "Running")
    target_distance : float
        Race distance in miles (e.g., 3.1 for 5K)

    Returns
    -------
    dict
        Dictionary with keys:
        - 'optimistic_pace': float (seconds/mile)
        - 'conservative_pace': float (seconds/mile)
        - 'optimistic_time': str (MM:SS total race time)
        - 'conservative_time': str (MM:SS total race time)

    Examples
    --------
    >>> predict_race_pace(df_records, "Running", 3.1)  # 5K prediction
    {
        'optimistic_pace': 540.0,
        'conservative_pace': 570.0,
        'optimistic_time': '27:54',
        'conservative_time': '29:27'
    }
    """

    ex_records = df_records[df_records["Exercise"] == exercise].sort_values("Distance")

    if ex_records.empty:
        return {
            "optimistic_pace": np.nan,
            "conservative_pace": np.nan,
            "optimistic_time": "N/A",
            "conservative_time": "N/A",
        }

    # Find closest Pareto points
    distances = ex_records["Distance"].values
    paces = ex_records["Pace"].values

    # Find nearest records (one shorter, one longer if available)
    shorter_idx = distances[distances <= target_distance]
    longer_idx = distances[distances >= target_distance]

    if len(shorter_idx) > 0:
        best_shorter = ex_records[ex_records["Distance"] == shorter_idx[-1]].iloc[0]
        optimistic = estimate_pace_at_distance(
            best_shorter["Pace"], best_shorter["Distance"], target_distance
        )
    else:
        # Extrapolate from shortest available
        optimistic = estimate_pace_at_distance(paces[0], distances[0], target_distance)

    if len(longer_idx) > 0:
        best_longer = ex_records[ex_records["Distance"] == longer_idx[0]].iloc[0]
        conservative = estimate_pace_at_distance(
            best_longer["Pace"], best_longer["Distance"], target_distance
        )
    else:
        # Use optimistic + 10% buffer
        conservative = optimistic * 1.10

    def total_time_str(pace_sec_per_mi: float, distance_mi: float) -> str:
        """Convert pace and distance to total time string."""
        if pd.isna(pace_sec_per_mi):
            return "N/A"
        total_seconds = pace_sec_per_mi * distance_mi
        minutes = int(total_seconds // 60)
        seconds = int(total_seconds % 60)
        return f"{minutes}:{seconds:02d}"

    return {
        "optimistic_pace": optimistic,
        "conservative_pace": conservative,
        "optimistic_pace_str": seconds_to_pace_string(optimistic),
        "conservative_pace_str": seconds_to_pace_string(conservative),
        "optimistic_time": total_time_str(optimistic, target_distance),
        "conservative_time": total_time_str(conservative, target_distance),
    }
