//! Pure crossing-detection for the `zfin milestones` command.
//!
//! Given a `(date, value)` series, find the dates the value
//! first reaches each of a configured set of thresholds. Two
//! threshold modes:
//!
//!   - **Absolute** — fixed multiples of a step (e.g.,
//!     `$1M, $2M, $3M, ...`).
//!   - **Relative** — geometric multiples of the series'
//!     starting value (e.g., `1x, 2x, 4x, 8x, ...`).
//!
//! No I/O, no allocation outside the result slice. The caller
//! supplies the merged `(date, value)` series. Inflation
//! adjustment is applied at the call site by deflating the
//! series before invoking `detectCrossings` — this keeps the
//! detector ignorant of inflation semantics.

const std = @import("std");
const Date = @import("../Date.zig");

// ── Step expression parser ───────────────────────────────────

/// User-specified step. Drives whether thresholds are absolute
/// dollar multiples or geometric multipliers of the starting
/// value.
pub const Step = union(enum) {
    /// `--step 1M` → `.{ .absolute = 1_000_000 }`.
    absolute: f64,
    /// `--step 2x` → `.{ .relative = 2.0 }`.
    relative: f64,
};

pub const ParseStepError = error{
    Empty,
    InvalidNumber,
    NonFiniteValue,
    NonPositiveAbsolute,
    NonProgressiveRelative,
    UnknownSuffix,
};

/// Parse a step expression. Accepts:
///   - Bare numbers: `1000000`
///   - Absolute with `K`/`k`/`M`/`m` suffix: `1M`, `500k`, `1.5M`
///   - Relative with `x`/`X` suffix: `2x`, `1.5x`
///
/// Rejects:
///   - Empty input
///   - Non-finite values (NaN, Inf)
///   - Zero or negative absolute values
///   - Relative values ≤ 1.0 (no progression)
///   - `%` suffix (intentionally unsupported — see spec)
///   - Any other suffix character
///
/// Whitespace is NOT trimmed; callers should pre-trim.
pub fn parseStep(input: []const u8) ParseStepError!Step {
    if (input.len == 0) return error.Empty;

    const last = input[input.len - 1];

    // Relative form.
    if (last == 'x' or last == 'X') {
        if (input.len == 1) return error.InvalidNumber;
        const num_str = input[0 .. input.len - 1];
        const v = std.fmt.parseFloat(f64, num_str) catch return error.InvalidNumber;
        if (!std.math.isFinite(v)) return error.NonFiniteValue;
        if (v <= 1.0) return error.NonProgressiveRelative;
        return .{ .relative = v };
    }

    // Absolute with K/M suffix.
    var multiplier: f64 = 1.0;
    var num_str: []const u8 = input;
    if (last == 'K' or last == 'k') {
        if (input.len == 1) return error.InvalidNumber;
        multiplier = 1_000.0;
        num_str = input[0 .. input.len - 1];
    } else if (last == 'M' or last == 'm') {
        if (input.len == 1) return error.InvalidNumber;
        multiplier = 1_000_000.0;
        num_str = input[0 .. input.len - 1];
    } else if (!std.ascii.isDigit(last) and last != '.') {
        // A non-digit, non-period trailing character that isn't
        // x/X/K/k/M/m may still be a legitimate float literal
        // (`inf`, `nan`, `1e10`). Defer to `parseFloat` and only
        // reject as unknown suffix if parsing fails AND we're
        // confident the input doesn't look like a float.
        const v_try = std.fmt.parseFloat(f64, input) catch {
            return error.UnknownSuffix;
        };
        if (!std.math.isFinite(v_try)) return error.NonFiniteValue;
        if (v_try <= 0.0) return error.NonPositiveAbsolute;
        return .{ .absolute = v_try };
    }

    const v = std.fmt.parseFloat(f64, num_str) catch return error.InvalidNumber;
    if (!std.math.isFinite(v)) return error.NonFiniteValue;
    const absolute = v * multiplier;
    if (absolute <= 0.0) return error.NonPositiveAbsolute;
    return .{ .absolute = absolute };
}

// ── Series + crossing types ──────────────────────────────────

/// Input series row. Caller supplies these in date-ascending
/// order (the detector trusts that ordering and does not sort).
pub const Point = struct {
    date: Date,
    value: f64,
};

/// One detected crossing. `days_since_prev` is null for the
/// first crossing in the result; `days_since_first` is 0 for
/// the first crossing.
pub const Crossing = struct {
    /// 1-indexed crossing number. Useful for relative mode where
    /// the threshold is `start * factor^(index-1)`.
    index: u32,
    /// The threshold reached.
    threshold: f64,
    /// Date of the first observed value at or above `threshold`.
    /// Per spec, this is "first observed at" rather than
    /// "actually crossed on" — the resolution is bounded by the
    /// source series' cadence (typically weekly).
    date: Date,
    /// Days from the previous crossing in this result. Null for
    /// the first crossing.
    days_since_prev: ?i32,
    /// Days from the first crossing in this result.
    days_since_first: i32,
    /// True iff this crossing is the synthetic "starting point"
    /// row — value at the start of the series, not a true
    /// crossing. Renderers typically annotate this with a
    /// footnote.
    is_start: bool,
};

// ── Crossing detection ───────────────────────────────────────

/// Detect threshold crossings in `series`. Result is allocated
/// via `allocator`; caller owns the slice and frees with
/// `allocator.free`.
///
/// Returns an empty slice if `series` is empty.
///
/// Algorithm:
///   - For absolute mode, thresholds are `step, 2*step, 3*step, ...`
///     up to the series' maximum value.
///   - For relative mode, thresholds are `start, start*f, start*f^2, ...`
///     up to the series' maximum value. The starting value is
///     emitted as a synthetic crossing (`is_start = true`).
///   - For each threshold T, scan forward from the current
///     position to find the first index i where
///     `series[i].value >= T`. Record `series[i].date`.
///   - When multiple thresholds fall between two adjacent points
///     (a single big jump), all those thresholds are recorded
///     with the same crossing date.
pub fn detectCrossings(
    allocator: std.mem.Allocator,
    series: []const Point,
    step: Step,
) ![]Crossing {
    if (series.len == 0) return &.{};

    const start_value = series[0].value;
    const start_date = series[0].date;
    var max_value: f64 = start_value;
    for (series) |p| {
        if (p.value > max_value) max_value = p.value;
    }

    var out: std.ArrayList(Crossing) = .empty;
    errdefer out.deinit(allocator);

    var index: u32 = 1;
    var prev_date: ?Date = null;

    switch (step) {
        .absolute => |s| {
            // Absolute thresholds: s, 2s, 3s, ...
            // Skip thresholds entirely below the starting value
            // (no crossing observable).
            var k: u32 = 1;
            // Position the scan cursor; thresholds are detected
            // by walking forward through the series for each one.
            while (true) : (k += 1) {
                const T = s * @as(f64, @floatFromInt(k));
                if (T > max_value) break;

                // Find first index where value >= T.
                const cross = findFirstAtOrAbove(series, T) orelse continue;

                // Skip thresholds that the starting value is
                // ALREADY at or above — those aren't observed
                // crossings.
                if (T <= start_value) continue;

                const ds_first: i32 = cross.date.days - start_date.days;
                const ds_prev: ?i32 = if (prev_date) |pd| cross.date.days - pd.days else null;

                try out.append(allocator, .{
                    .index = index,
                    .threshold = T,
                    .date = cross.date,
                    .days_since_prev = ds_prev,
                    .days_since_first = ds_first,
                    .is_start = false,
                });
                index += 1;
                prev_date = cross.date;
            }
        },
        .relative => |f| {
            // Relative thresholds: emit the starting value as
            // index 1 (synthetic), then start*f, start*f^2, ...
            try out.append(allocator, .{
                .index = 1,
                .threshold = start_value,
                .date = start_date,
                .days_since_prev = null,
                .days_since_first = 0,
                .is_start = true,
            });
            index = 2;
            prev_date = start_date;

            var T = start_value * f;
            while (T <= max_value) {
                const cross = findFirstAtOrAbove(series, T) orelse {
                    // Shouldn't happen given T <= max_value, but be safe.
                    break;
                };

                const ds_first: i32 = cross.date.days - start_date.days;
                const ds_prev: ?i32 = if (prev_date) |pd| cross.date.days - pd.days else null;

                try out.append(allocator, .{
                    .index = index,
                    .threshold = T,
                    .date = cross.date,
                    .days_since_prev = ds_prev,
                    .days_since_first = ds_first,
                    .is_start = false,
                });
                index += 1;
                prev_date = cross.date;
                T *= f;
            }
        },
    }

    return out.toOwnedSlice(allocator);
}

fn findFirstAtOrAbove(series: []const Point, threshold: f64) ?Point {
    for (series) |p| {
        if (p.value >= threshold) return p;
    }
    return null;
}

// ── CPI deflation ────────────────────────────────────────────

/// Convert a nominal value at calendar year `from_year` to its
/// equivalent in `to_year` dollars, given a slice of year/CPI
/// pairs. The CPI series is the per-year inflation rate (as
/// from `shiller.annual_returns[i].cpi_inflation`).
///
/// If `from_year == to_year`, returns `nominal` unchanged.
/// If `from_year > to_year`, deflates (compounds backward).
/// If `from_year < to_year`, inflates (compounds forward).
///
/// Years outside the available CPI series are clamped to the
/// nearest endpoint (with the caller's responsibility to warn).
pub const YearCpi = struct {
    year: u16,
    cpi: f64,
};

pub fn deflate(
    nominal: f64,
    from_year: u16,
    to_year: u16,
    cpi: []const YearCpi,
) f64 {
    if (cpi.len == 0) return nominal;
    if (from_year == to_year) return nominal;

    var factor: f64 = 1.0;
    if (from_year < to_year) {
        // Inflate forward: multiply by (1 + cpi) for each year
        // from `from_year` (inclusive) up to `to_year` (exclusive).
        var y: u16 = from_year;
        while (y < to_year) : (y += 1) {
            factor *= (1.0 + cpiForYear(y, cpi));
        }
        return nominal * factor;
    } else {
        // Deflate backward: divide by (1 + cpi) for each year
        // from `to_year` (inclusive) up to `from_year` (exclusive).
        var y: u16 = to_year;
        while (y < from_year) : (y += 1) {
            factor *= (1.0 + cpiForYear(y, cpi));
        }
        return nominal / factor;
    }
}

fn cpiForYear(year: u16, cpi: []const YearCpi) f64 {
    // Linear scan — small slice (<200 entries), and it's called
    // O(years × series_length) times in the worst case which is
    // still trivial.
    if (year <= cpi[0].year) return cpi[0].cpi;
    if (year >= cpi[cpi.len - 1].year) return cpi[cpi.len - 1].cpi;
    for (cpi) |yc| {
        if (yc.year == year) return yc.cpi;
    }
    return cpi[cpi.len - 1].cpi;
}

// ── Tests ────────────────────────────────────────────────────

test "parseStep: absolute bare numbers" {
    const a = try parseStep("1000000");
    try std.testing.expect(a == .absolute);
    try std.testing.expectEqual(@as(f64, 1_000_000), a.absolute);

    const b = try parseStep("500");
    try std.testing.expect(b == .absolute);
    try std.testing.expectEqual(@as(f64, 500), b.absolute);
}

test "parseStep: K and M suffixes" {
    const cases = [_]struct { in: []const u8, out: f64 }{
        .{ .in = "1M", .out = 1_000_000 },
        .{ .in = "1m", .out = 1_000_000 },
        .{ .in = "1.5M", .out = 1_500_000 },
        .{ .in = "500K", .out = 500_000 },
        .{ .in = "500k", .out = 500_000 },
        .{ .in = "0.5M", .out = 500_000 },
    };
    for (cases) |c| {
        const s = try parseStep(c.in);
        try std.testing.expect(s == .absolute);
        try std.testing.expectEqual(c.out, s.absolute);
    }
}

test "parseStep: relative x suffix" {
    const cases = [_]struct { in: []const u8, out: f64 }{
        .{ .in = "2x", .out = 2.0 },
        .{ .in = "2X", .out = 2.0 },
        .{ .in = "1.5x", .out = 1.5 },
    };
    for (cases) |c| {
        const s = try parseStep(c.in);
        try std.testing.expect(s == .relative);
        try std.testing.expectEqual(c.out, s.relative);
    }
}

test "parseStep: rejects" {
    try std.testing.expectError(error.Empty, parseStep(""));
    try std.testing.expectError(error.NonProgressiveRelative, parseStep("1x"));
    try std.testing.expectError(error.NonProgressiveRelative, parseStep("1.0x"));
    try std.testing.expectError(error.NonProgressiveRelative, parseStep("0.5x"));
    try std.testing.expectError(error.NonPositiveAbsolute, parseStep("0"));
    try std.testing.expectError(error.NonPositiveAbsolute, parseStep("-1M"));
    try std.testing.expectError(error.UnknownSuffix, parseStep("1G"));
    try std.testing.expectError(error.UnknownSuffix, parseStep("100%"));
    try std.testing.expectError(error.InvalidNumber, parseStep("2x.5"));
    try std.testing.expectError(error.InvalidNumber, parseStep("M"));
    try std.testing.expectError(error.InvalidNumber, parseStep("x"));
    try std.testing.expectError(error.NonFiniteValue, parseStep("inf"));
    try std.testing.expectError(error.NonFiniteValue, parseStep("infM"));
    try std.testing.expectError(error.NonFiniteValue, parseStep("infx"));
}

test "detectCrossings: empty series" {
    const result = try detectCrossings(std.testing.allocator, &.{}, .{ .absolute = 1_000_000 });
    defer std.testing.allocator.free(result);
    try std.testing.expectEqual(@as(usize, 0), result.len);
}

test "detectCrossings: absolute mode, simple progression" {
    // Series climbs from $1.28M to $7.0M over 12 years.
    // $1M step: starting value already past, so first observable
    // crossing is $2M.
    const series = [_]Point{
        .{ .date = Date.fromYmd(2014, 7, 3), .value = 1_280_000 },
        .{ .date = Date.fromYmd(2017, 1, 1), .value = 2_100_000 },
        .{ .date = Date.fromYmd(2019, 1, 1), .value = 3_500_000 },
        .{ .date = Date.fromYmd(2024, 6, 1), .value = 7_000_000 },
    };

    const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 });
    defer std.testing.allocator.free(result);

    // Expect crossings at $2M (2017-01-01), $3M (2019-01-01),
    // $4M, $5M, $6M, $7M (all 2024-06-01 due to the jump).
    try std.testing.expectEqual(@as(usize, 6), result.len);
    try std.testing.expectEqual(@as(f64, 2_000_000), result[0].threshold);
    try std.testing.expectEqual(Date.fromYmd(2017, 1, 1), result[0].date);
    try std.testing.expectEqual(@as(f64, 3_000_000), result[1].threshold);
    try std.testing.expectEqual(Date.fromYmd(2019, 1, 1), result[1].date);
    // $4M, $5M, $6M, $7M all hit at 2024-06-01.
    for (result[2..]) |c| {
        try std.testing.expectEqual(Date.fromYmd(2024, 6, 1), c.date);
    }

    // First crossing has null days_since_prev.
    try std.testing.expectEqual(@as(?i32, null), result[0].days_since_prev);
    // days_since_first is measured from the series start, not the
    // first crossing.
    const expected_first = Date.fromYmd(2017, 1, 1).days - Date.fromYmd(2014, 7, 3).days;
    try std.testing.expectEqual(expected_first, result[0].days_since_first);
}

test "detectCrossings: relative mode, doubling" {
    // Synthetic doubling: 1M, 2M, 4M, 8M.
    const series = [_]Point{
        .{ .date = Date.fromYmd(2014, 1, 1), .value = 1_000_000 },
        .{ .date = Date.fromYmd(2018, 1, 1), .value = 2_100_000 },
        .{ .date = Date.fromYmd(2022, 1, 1), .value = 4_200_000 },
        .{ .date = Date.fromYmd(2026, 1, 1), .value = 8_400_000 },
    };

    const result = try detectCrossings(std.testing.allocator, &series, .{ .relative = 2.0 });
    defer std.testing.allocator.free(result);

    // 1x synthetic + 3 real crossings (2x, 4x, 8x).
    try std.testing.expectEqual(@as(usize, 4), result.len);
    try std.testing.expect(result[0].is_start);
    try std.testing.expectEqual(@as(f64, 1_000_000), result[0].threshold);
    try std.testing.expectEqual(@as(f64, 2_000_000), result[1].threshold);
    try std.testing.expectEqual(@as(f64, 4_000_000), result[2].threshold);
    try std.testing.expectEqual(@as(f64, 8_000_000), result[3].threshold);

    // days_since_prev is set on all but the first.
    try std.testing.expectEqual(@as(?i32, null), result[0].days_since_prev);
    try std.testing.expect(result[1].days_since_prev != null);
    try std.testing.expect(result[1].days_since_prev.? > 0);
}

test "detectCrossings: series shorter than first threshold" {
    const series = [_]Point{
        .{ .date = Date.fromYmd(2014, 1, 1), .value = 100_000 },
        .{ .date = Date.fromYmd(2015, 1, 1), .value = 150_000 },
    };

    const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 });
    defer std.testing.allocator.free(result);
    try std.testing.expectEqual(@as(usize, 0), result.len);
}

test "detectCrossings: relative mode with single point" {
    const series = [_]Point{
        .{ .date = Date.fromYmd(2014, 1, 1), .value = 1_000_000 },
    };
    const result = try detectCrossings(std.testing.allocator, &series, .{ .relative = 2.0 });
    defer std.testing.allocator.free(result);
    // Just the synthetic starting point.
    try std.testing.expectEqual(@as(usize, 1), result.len);
    try std.testing.expect(result[0].is_start);
}

test "detectCrossings: multiple thresholds in single jump" {
    // Big jump from $1M to $5M: $2M, $3M, $4M, $5M all hit at the
    // same observation.
    const series = [_]Point{
        .{ .date = Date.fromYmd(2014, 1, 1), .value = 1_000_000 },
        .{ .date = Date.fromYmd(2014, 1, 8), .value = 5_500_000 },
    };

    const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 });
    defer std.testing.allocator.free(result);
    // $2M, $3M, $4M, $5M — all four at 2014-01-08.
    try std.testing.expectEqual(@as(usize, 4), result.len);
    for (result) |c| {
        try std.testing.expectEqual(Date.fromYmd(2014, 1, 8), c.date);
    }
}

test "deflate: identity when from == to" {
    const cpi = [_]YearCpi{ .{ .year = 2020, .cpi = 0.02 }, .{ .year = 2024, .cpi = 0.03 } };
    const v = deflate(1000, 2022, 2022, &cpi);
    try std.testing.expectEqual(@as(f64, 1000), v);
}

test "deflate: simple inflation forward" {
    const cpi = [_]YearCpi{
        .{ .year = 2020, .cpi = 0.10 },
        .{ .year = 2021, .cpi = 0.10 },
        .{ .year = 2022, .cpi = 0.10 },
    };
    // From 2020 → 2023: 3 years of 10% compounds to 1.331x.
    const v = deflate(1000, 2020, 2023, &cpi);
    try std.testing.expectApproxEqAbs(1331.0, v, 0.01);
}

test "deflate: simple deflation backward" {
    const cpi = [_]YearCpi{
        .{ .year = 2020, .cpi = 0.10 },
        .{ .year = 2021, .cpi = 0.10 },
        .{ .year = 2022, .cpi = 0.10 },
    };
    // From 2023 → 2020 dollars: divide by 1.331.
    const v = deflate(1331, 2023, 2020, &cpi);
    try std.testing.expectApproxEqAbs(1000.0, v, 0.01);
}

test "deflate: empty cpi returns nominal" {
    const v = deflate(1000, 2020, 2024, &.{});
    try std.testing.expectEqual(@as(f64, 1000), v);
}
