<?php
/**
 * ════════════════════════════════════════════════════════════════════
 * Journey Segmentation
 * ────────────────────────────────────────────────────────────────────
 * The single source of truth for "which real Phase/Funded bucket does
 * each of this prop-firm account's trades belong to" — extracted
 * 2026-08-06 from three functions that used to live as plain globals in
 * public/journal.php (rnTagTradePhase(), rnPartitionTradesByPhase(),
 * rnPartitionAccountJourney()), at the explicit request of a review that
 * asked a fair question: now that both Active Journeys and Trading
 * Archive agree by calling one shared function, what stops a future
 * change from calling the OLDER, uncorrected rnPartitionTradesByPhase()
 * directly and silently reintroducing the exact divergence this was
 * built to prevent?
 *
 * PHP has no enforced visibility for plain top-level functions — a
 * doc-comment warning "don't call this directly" is only a convention,
 * not a guarantee. This class exists to make that guarantee real:
 * partitionAccountJourney() is the ONLY public method. tagTradePhase()
 * and partitionTradesByPhase() are `private` — genuinely inaccessible
 * from outside this class, enforced by the PHP engine itself, not by a
 * comment someone could miss six months from now.
 *
 * Confirmed real callers as of this extraction: Active Journeys
 * (public/journal.php's rnBuildJourney()) and Trading Archive
 * (src/TradingArchiveEngine.php's gatherPropSegments(), which also
 * backs the single-account "Lessons From Success" page via
 * gatherSuccessAccounts() → gatherPropSegments()). Reports, Dashboard,
 * Trader Profile, Drafts, and Trading Center do not do their own
 * phase/journey segmentation at all — they read challenge_accounts /
 * lifecycle_state directly for whole-account facts, or (Trading
 * Center's own phase-BUCKET navigation) browse accounts by
 * challenge_accounts.current_stage/lifecycle_state, never by per-trade
 * partitioning — so there is no second implementation of this specific
 * logic anywhere else in the app that also needs to depend on this
 * class.
 * ════════════════════════════════════════════════════════════════════
 */
class JourneySegmentation
{
    /**
     * Builds on partitionTradesByPhase()'s base per-trade-tag
     * partitioning (see that method's own doc comment) and adds two
     * real, confirmed corrections on top of it:
     *
     * 1. Instant Funding has no real Phase 1/2/3 concept at all (an
     *    established business rule — see Trading Center's own bucket
     *    definitions and Trading Archive's "Instant Funding never
     *    mentions phases anywhere" rule) — its trades must never be
     *    split by tagTradePhase()'s per-trade phase tags, which can
     *    carry a stale leftover value (e.g. prop_phase_at_entry=1,
     *    confirmed live on a real funded instant-funding account) with
     *    no real meaning for this challenge type. Every trade lands in
     *    ONE bucket keyed by $currentLabel — the account's own live
     *    lifecycle state.
     *
     * 2. Funded-state reconciliation: some genuinely funded accounts
     *    (lifecycle_state funded_active — $currentLabel === 'Funded
     *    Account') have NO trade tagged prop_firm_funded=1 at all — a
     *    real, confirmed capture-time gap (trades taken right after a
     *    promotion sometimes never get re-tagged; confirmed live across
     *    4 real accounts spanning one_step/two_step/three_step for one
     *    user). Without correction, the trailing bucket stays under its
     *    stale prior-phase label and the account never reads as
     *    "Funded" anywhere that checks the bucket label — the same
     *    account could show Funded on one page and Phase 1/2/3 on
     *    another if this correction only lived in one caller, which is
     *    exactly the divergence this class exists to prevent. The
     *    account's own live lifecycle state is the authoritative signal
     *    (the same principle rnDeriveTerminalStatus(), public/
     *    journal.php, already applies to a bucket's STATUS) — trust it
     *    to relabel just the TRAILING bucket, never an earlier one: a
     *    real, populated Phase 1/2/3 bucket earlier in the sequence is
     *    still genuine passed-phase history and must keep its own real
     *    label.
     *
     * $challengeType: this account's challenge_accounts.challenge_type
     * (nullable — callers pass null for anything that isn't a real
     * prop-firm challenge account). $currentLabel: the account's live-
     * lifecycle-state-derived label ('Phase 1'/'Phase 2'/'Phase 3'/
     * 'Funded Account'/'Current Phase') — every caller already computes
     * this from lifecycle_state for its own terminal-status derivation
     * (rnDeriveTerminalStatus()), so it's passed in rather than
     * recomputed here — a second, independent computation of the same
     * match() would itself be exactly the kind of duplicated logic this
     * class exists to eliminate.
     */
    public static function partitionAccountJourney(array $trades, ?string $challengeType, string $currentLabel): array
    {
        if ($challengeType === 'instant_funding') {
            $buckets = [$currentLabel => $trades];
        } else {
            $buckets = self::partitionTradesByPhase($trades, $currentLabel);
        }

        if ($currentLabel === 'Funded Account') {
            $lastKey = array_key_last($buckets);
            if ($lastKey !== null && $lastKey !== 'Funded Account') {
                $buckets['Funded Account'] = $buckets[$lastKey];
                unset($buckets[$lastKey]);
            }
        }

        return $buckets;
    }

    /**
     * ROOT-CAUSE FIX (2026-07-29, reported by Kevin as a data-
     * segmentation bug, not a UI bug — confirmed correct). The original
     * implementation partitioned trades into phase cards by comparing
     * each trade's `captured_at` against `lifecycle_events.occurred_at`
     * date windows. Verified live against real accounts that this is
     * unreliable: (a) most real accounts have SPARSE or ZERO
     * lifecycle_events at all — e.g. `FTMO 5K - Attempt 1` (14 real
     * trades, genuinely `phase_1_complete`) has 0 events; `Holla
     * Starter` (7 real trades, genuinely `phase_2_complete`) has only 1
     * (`PHASE_2_STARTED`, no `ACCOUNT_CREATED`) — so a whole phase's
     * trades would either be silently merged into the wrong bucket or
     * vanish from every table entirely. (b) even when events exist,
     * they can be logged with real lag relative to when a trade was
     * actually taken under that phase — confirmed on `Holla Starter`:
     * its one real Phase-2 trade has `captured_at` 2026-06-30
     * 00:27:35, a full hour BEFORE its own `PHASE_2_STARTED` event
     * (01:26:57), so date-window matching placed it in the wrong bucket
     * (or excluded it) regardless.
     *
     * Fixed by partitioning on each trade's OWN real phase tag instead
     * of a derived date window — `trades.prop_firm_funded` (100%
     * populated on every real prop-firm trade in this DB),
     * `trades.prop_firm_phase` (~53%), `trades.prop_phase_at_entry`
     * (~46%, checked in that priority order — funded status is
     * unambiguous when set; phase_1/2/3 next). Trades with none of
     * those three signals (~15% of real trades, mostly legacy/import
     * rows) are assigned via nearest-neighbor interpolation from the
     * closest chronologically-adjacent TAGGED trade — see below. Only
     * when an account has NO tagged trades anywhere (and no trades at
     * all) does this fall back to a single current-phase card, matching
     * the account's own live lifecycle_state.
     *
     * Groups an account's real trades (already ordered by captured_at
     * ASC) into Phase 1 / Phase 2 / Phase 3 / Funded Account buckets by
     * each trade's own real tag (tagTradePhase()), interpolating
     * untagged trades from their nearest tagged neighbor (prefers the
     * trade immediately before it — an untagged trade almost always
     * belongs to whatever phase was already in progress — falling back
     * to the next tagged trade for any run of untagged trades before
     * the first tagged one). Buckets are returned in Phase 1 → Phase 2
     * → Phase 3 → Funded order, only for phases that actually have
     * trades. If truly NO trade in the account carries any signal at
     * all, every trade lands in one bucket keyed by $currentLabel (the
     * account's live current phase).
     *
     * Private — this is the exact method a future accidental direct
     * call would bypass Instant Funding's no-phases rule and the
     * funded-state reconciliation above; there is no legitimate reason
     * for any caller outside this class to reach it. Always go through
     * partitionAccountJourney().
     */
    private static function partitionTradesByPhase(array $trades, string $currentLabel): array
    {
        $tags = [];
        foreach ($trades as $i => $t) { $tags[$i] = self::tagTradePhase($t); }

        $lastKnown = null;
        foreach ($tags as $i => $tag) {
            if ($tag !== null) { $lastKnown = $tag; continue; }
            if ($lastKnown !== null) $tags[$i] = $lastKnown;
        }
        $nextKnown = null;
        for ($i = count($tags) - 1; $i >= 0; $i--) {
            if ($tags[$i] !== null) { $nextKnown = $tags[$i]; continue; }
            if ($nextKnown !== null) $tags[$i] = $nextKnown;
        }

        $buckets = [];
        foreach ($trades as $i => $t) {
            $key = $tags[$i] ?? $currentLabel;
            $buckets[$key][] = $t;
        }

        $ordered = [];
        foreach (['Phase 1', 'Phase 2', 'Phase 3', 'Funded Account'] as $label) {
            if (!empty($buckets[$label])) { $ordered[$label] = $buckets[$label]; unset($buckets[$label]); }
        }
        foreach ($buckets as $label => $rows) { $ordered[$label] = $rows; } // leftover fallback key, if any
        return $ordered;
    }

    /** Private — see partitionTradesByPhase()'s own doc comment for the full priority-order rationale (prop_firm_funded > prop_firm_phase > prop_phase_at_entry). */
    private static function tagTradePhase(array $t): ?string
    {
        if (!empty($t['prop_firm_funded'])) return 'Funded Account';
        if (!empty($t['prop_firm_phase'])) {
            return match ($t['prop_firm_phase']) {
                'phase_1' => 'Phase 1', 'phase_2' => 'Phase 2', 'phase_3' => 'Phase 3', default => null,
            };
        }
        if ($t['prop_phase_at_entry'] !== null && $t['prop_phase_at_entry'] !== '') {
            return match ((int)$t['prop_phase_at_entry']) {
                1 => 'Phase 1', 2 => 'Phase 2', 3 => 'Phase 3', default => null,
            };
        }
        return null;
    }
}
