<?php

/**
 * Builds an Active Journeys URL, preserving whichever filters are passed.
 * Moved here from public/journal.php 2026-08-18 (RenderHelpers.php is
 * required globally by index.php, journal.php is not) so
 * dashboard.php's dashBuildBehaviorAlerts() can link a behavior-pattern
 * alert straight to the specific account/classification it cited,
 * without journal.php's own definition being duplicated (and silently
 * drifting) a second time.
 */
function activeJourneysUrl(array $params = []): string {
    return '/traderalign/journal?' . http_build_query(array_merge(['workspace' => 'active'], $params));
}

/** Builds a Trading Archive URL, preserving whichever filters are passed. Moved alongside activeJourneysUrl() above, same reasoning. */
function archiveUrl(array $params = []): string {
    return '/traderalign/journal?' . http_build_query(array_merge(['workspace' => 'archive'], $params));
}

// Shared P&L display helpers — previously copy-pasted identically into
// dashboard.php, journal.php, reports.php, drafts.php, journal-validation.php,
// and mt5-resolve.php. Consolidated here (same pattern as PropFirmHelpers.php)
// so the six copies can't silently drift from each other again.

// Validation Workflow — the one shared "is this trade in scope for the
// user's CURRENT workflow" SQL fragment. Every trade-aggregating query in
// this app used to hardcode "(is_validation = 0 OR is_validation IS NULL)"
// unconditionally (correct for the main workflow, but would show an empty
// page for a validating user, whose own trades all carry is_validation=1).
// $workflowState should come from ValidationService::getWorkflowState() —
// pass the user's real current value, never hardcode 'main' here.
// $alias is the table alias used in the query ('t' for "t.is_validation",
// '' for a bare "is_validation" column reference), matching the exact
// convention every existing call site in this app already used before
// this helper existed.
function validationScopeSql(string $workflowState, string $alias = ''): string {
    $prefix = $alias ? "{$alias}." : '';
    return $workflowState === 'validation'
        ? "({$prefix}is_validation = 1)"
        : "({$prefix}is_validation = 0 OR {$prefix}is_validation IS NULL)";
}

// ── User-local time (2026-08-19) ────────────────────────────────────────
// The one authoritative "what time is it right now, for this user" source.
// Before this, trader-profile.php's tpGreetingWord() and ColexHelpers.php's
// generateGreeting() each independently called date()/date('H') — reading
// PHP's own process-wide date_default_timezone_set(APP_TIMEZONE) (a single
// hardcoded server timezone, config/config.php), never the individual
// trader's own configured user_profiles.timezone (set via Settings, already
// validated there against timezone_identifiers_list()). The two
// implementations had even drifted from each other (12/18 vs. 12/17 hour
// cutoffs), on top of both being wrong for a user in a different timezone
// than the server. Every page that needs "the user's local now" for a
// time-of-day-dependent display must go through resolveUserTimezone()/
// userNow()/greetingWord() instead of a bare date() call, so no second,
// independently-drifting greeting clock can appear again.
//
// $timezone is whatever's stored in user_profiles.timezone for the current
// user (pass null/'' for "not set yet") — never a second hardcoded default;
// APP_TIMEZONE (config.php) is only the fallback for a user who hasn't
// configured one, exactly the same fallback role it already plays for the
// server's own default PHP timezone.
function resolveUserTimezone(?string $timezone): DateTimeZone {
    $timezone = trim((string)$timezone);
    if ($timezone !== '') {
        try {
            return new DateTimeZone($timezone);
        } catch (\Throwable $e) {
            // Invalid/legacy stored value — fall through to the app default.
        }
    }
    return new DateTimeZone(APP_TIMEZONE);
}

function userNow(?string $timezone): DateTime {
    return new DateTime('now', resolveUserTimezone($timezone));
}

// The one canonical time-of-day greeting word, derived from the user's own
// local hour. Neither of the two prior implementations (trader-profile.php's
// tpGreetingWord(): morning < 12; ColexHelpers.php's old inline version:
// morning < 12, afternoon < 17) had a late-night bucket — a bare "hour < 12
// is morning" reads as "Good morning" at 1am, exactly the reported bug.
// Morning now starts at 5am (the usual convention for when a day actually
// begins); midnight-4:59am stays "Good evening", carrying over from the
// night before rather than switching to "morning" at the stroke of
// midnight. Afternoon runs noon-4:59pm, evening from 5pm onward (matching
// ColexHelpers.php's original afternoon/evening split, not trader-profile.
// php's slightly later 6pm one — a judgment call resolving the two prior
// implementations' own disagreement, not independently confirmed with
// product).
function greetingWord(?string $timezone): string {
    $hour = (int)userNow($timezone)->format('G');
    if ($hour >= 5 && $hour < 12) return 'Good morning';
    if ($hour >= 12 && $hour < 17) return 'Good afternoon';
    return 'Good evening'; // 5pm-11:59pm, and midnight-4:59am (late night)
}

// ── User display name (2026-08-19) ──────────────────────────────────────
// The one authoritative "what should this trader be called" resolution.
// user_profiles.display_name is written by exactly two places — onboarding
// (api/onboarding/step2.php's "Preferred Username" field) and Settings
// (api/settings/update.php's "profile" section) — and both write into this
// SAME single column, there is no separate "onboarding name" field
// anywhere in the schema. Settings can only ever be visited (and therefore
// only ever write) after onboarding, and its own "Preferred Username"
// input is a required field (min 2 chars) that can never save empty, so
// the stored value is already, by construction, exactly the priority this
// function's callers want: the Settings-entered name if the trader ever
// set one there, else the onboarding-entered name, else nothing to show.
// This function is the one place "nothing yet" becomes the fallback
// ("Trader") — never a hardcoded personal name. Previously this fallback
// check was independently copy-pasted in public/trader-profile.php,
// public/colex.php, and api/colex/stats.php — consolidated here so all
// three (and any future caller) can never drift or reintroduce a second
// identity system.
function resolveDisplayName(?array $profile): string {
    $name = trim((string)($profile['display_name'] ?? ''));
    return $name !== '' ? $name : 'Trader';
}

function pnlClass($pnl) {
    if ($pnl > 0) return 'positive';
    if ($pnl < 0) return 'negative';
    return '';
}

function pnlFormat($pnl) {
    return ($pnl >= 0 ? '+' : '') . number_format($pnl, 2);
}

// accountCurrentBalance() — DELETED 2026-08-08 (Single Source of Truth
// migration Wave 5f). This function's own doc comment (added 2026-08-07)
// confidently named it "the one, single, authoritative 'current balance'
// for ANY trading account — prop-firm or personal," always reading
// trading_accounts.current_balance directly — but that field is exactly
// the never-resets whole-ledger value CLAUDE.md's "Single Source of Truth
// Rule" documents as WRONG for a prop-firm account's phase-boundary
// balance (only correct for personal accounts, which have no phase
// concept). It had zero real call sites in the entire codebase — every
// attempted use of it was reverted after live testing (see
// public/journal.php's renderRnFundedCard()/rnBuildPhaseCardFromRows(),
// which still document one such revert). A confidently-documented but
// wrong-for-prop-firm-accounts function is a worse landmine than an
// undocumented one — its own doc comment actively invited the exact
// mistake the whole incident was about. Deleted rather than fixed in
// place: its real, correct intent (one canonical current-balance
// computation, phase-aware for prop-firm accounts) is what
// PropFirmRuleEngine::compute()['phase_balance'] already is — callers
// needing a prop-firm account's current balance should call that
// directly (see api/account/state.php, api/trades/validate.php,
// public/trading-center.php, src/TradingArchiveEngine.php,
// public/journal.php for the established account-type-branching pattern:
// PropFirmRuleEngine::compute() for prop_firm, trading_accounts.
// current_balance directly for personal, since personal accounts have no
// phase concept for it to be wrong about).

// Human duration formatter — engine.php's Lifecycle Journey computes
// duration_days as a raw float (e.g. 1.1, 427.0), which read as a
// nonsensical bare decimal wherever it was rendered directly ("1.1" days).
// Scales the display unit to the actual elapsed time instead: minutes/hours
// for anything under a day, days for under a year, years (+ remaining days)
// beyond that.
// Reporting-period noun helper — 'weekly'/'monthly'/'quarterly'/'annual' are
// adjective-shaped and read wrong dropped straight into a sentence ("this
// weekly", "flagged this quarterly"). Every place that builds a sentence
// like "this {period}" must use this noun form instead of the raw $period
// value. Previously reimplemented ad hoc in six places in
// progressive_insights_engine.php plus reports.php's own $fsPeriodNoun —
// consolidated here so it can't drift or be missed again.
function periodNoun($period) {
    return match ($period) {
        'weekly' => 'week',
        'monthly' => 'month',
        'quarterly' => 'quarter',
        'annual' => 'year',
        default => 'year',
    };
}

// The one, canonical "current reporting period" boundary calculation for
// the weekly/monthly/quarterly/annual vocabulary (2026-08-08, Single
// Source of Truth migration Wave 5d). Before this, the exact same
// calendar-aligned weekly/monthly/quarterly/annual math was independently
// hand-written in reports.php's own inline $start/$end switch AND in
// TraderProfileService::periodBounds() — reports.php's own comment even
// flagged this ("no shared period-bounds function existed... if a second
// caller ever needs it, extract then") once that second caller
// (TraderProfileService) actually showed up. Quarterly is calendar-aligned
// (Q1 Jan-Mar/Q2 Apr-Jun/Q3 Jul-Sep/Q4 Oct-Dec), matching the rest of this
// app's periods — never a rolling 90-day window.
//
// $referenceDate defaults to now; passing an earlier date lets a caller
// compute a past period's own bounds (e.g. "the monthly period containing
// 3 months ago") without a separate prev-period code path — reports.php's
// own prior-period comparison (immediately-preceding period of the same
// length, not necessarily calendar-aligned to $referenceDate) is a
// genuinely different concept and is NOT what this parameter is for; that
// logic stays in reports.php, unchanged, since it has exactly one caller.
function getReportingPeriodBounds($period, ?DateTime $referenceDate = null) {
    $now = $referenceDate ? clone $referenceDate : new DateTime();
    switch ($period) {
        case 'weekly':
            $start = (clone $now)->modify('monday this week')->setTime(0, 0, 0);
            $end = (clone $now)->modify('sunday this week')->setTime(23, 59, 59);
            $label = 'This Week';
            break;
        case 'quarterly':
            $curMonth = (int)$now->format('n');
            $qStartMonth = (int)(floor(($curMonth - 1) / 3) * 3 + 1);
            $qNum = (int)ceil($curMonth / 3);
            $start = (new DateTime())->setDate((int)$now->format('Y'), $qStartMonth, 1)->setTime(0, 0, 0);
            $end = (clone $start)->modify('+3 months')->modify('-1 second');
            $label = 'Q' . $qNum . ' ' . $now->format('Y');
            break;
        case 'annual':
            $start = (clone $now)->modify('first day of January')->setTime(0, 0, 0);
            $end = (clone $now)->modify('last day of December')->setTime(23, 59, 59);
            $label = 'This Year';
            break;
        case 'monthly':
        default:
            $period = 'monthly';
            $start = (clone $now)->modify('first day of this month')->setTime(0, 0, 0);
            $end = (clone $now)->modify('last day of this month')->setTime(23, 59, 59);
            $label = 'This Month';
            break;
    }
    return [
        'period' => $period, 'start' => $start, 'end' => $end,
        'start_str' => $start->format('Y-m-d H:i:s'), 'end_str' => $end->format('Y-m-d H:i:s'),
        'label' => $label, 'period_noun' => periodNoun($period),
    ];
}

// Account-lifespan formatter for Reports' Closed Account Retrospective —
// distinct from formatDurationDays() above (which formats a generic elapsed
// duration): a "0 day(s)" account read as a data-entry mistake to most
// users, when it's a real, valid outcome (the account was opened and closed
// within the same calendar day). Names that case explicitly instead of
// falling through to a bare "0".
function formatAccountDuration($days) {
    $days = (float)$days;
    if ($days < 1) return 'Same-day account';
    return formatDurationDays($days);
}

// Canonical chart-timeframe option list (2026-07-27, item 2 finishing pass)
// — previously duplicated verbatim in 3 places with no single source
// (strategy-validation.php's timeframe picker, dashboard.php's and
// drafts.php's actual_timeframe <select> at Draft Resolution). Now the one
// place all four (those three plus the new Strategy Builder mandatory
// field and the Calculator's Stage 2 planned-timeframe selector) read
// from, so the set can never drift between them again.
function timeframeOptions() {
    return ['1m', '5m', '15m', '30m', '1H', '4H', 'Daily', 'Weekly'];
}

// Real bug found during the 2026-07-31 Trading Archive audit: this used to
// round the "under 24 hours" bucket to one decimal place ("21.6 hours") and
// the "under 365 days" bucket to the nearest whole day, silently discarding
// its own hour component ("1.7 days" rounded away to "2 days"). Both are the
// exact "decimal that can't correspond to anything real" problem this file
// exists to prevent — a duration is either N whole hours and M whole minutes,
// or N whole days and M whole hours, never a fractional hour/day on its own.
// Every bucket below now composes two whole units (days+hours, hours+minutes)
// instead of rounding to a single fractional one.
function formatDurationDays($days) {
    $days = (float)$days;
    if ($days < 0) $days = 0;
    $totalMinutes = $days * 24 * 60;

    if ($totalMinutes < 1) return 'Less than a minute';
    if ($totalMinutes < 60) {
        $minutes = max(1, (int)round($totalMinutes));
        return $minutes . ' minute' . ($minutes === 1 ? '' : 's');
    }
    $totalHours = $totalMinutes / 60;
    if ($totalHours < 24) {
        $hours = (int)floor($totalHours);
        $minutes = (int)round(($totalHours - $hours) * 60);
        if ($minutes === 60) { $hours++; $minutes = 0; }
        $out = $hours . ' hour' . ($hours === 1 ? '' : 's');
        if ($minutes > 0) $out .= ' ' . $minutes . ' minute' . ($minutes === 1 ? '' : 's');
        return $out;
    }
    if ($days < 365) {
        $daysInt = (int)floor($days);
        $remHours = (int)round(($days - $daysInt) * 24);
        if ($remHours === 24) { $daysInt++; $remHours = 0; }
        $out = $daysInt . ' day' . ($daysInt === 1 ? '' : 's');
        if ($remHours > 0) $out .= ' ' . $remHours . ' hour' . ($remHours === 1 ? '' : 's');
        return $out;
    }
    $years = (int)floor($days / 365);
    $remainingDays = (int)round($days - ($years * 365));
    $out = $years . ' year' . ($years === 1 ? '' : 's');
    if ($remainingDays > 0) $out .= ', ' . $remainingDays . ' day' . ($remainingDays === 1 ? '' : 's');
    return $out;
}

// Natural-language duration formatter with week/month granularity — built
// for Trading Archive's Lessons From Success page (2026-08-04 polish
// pass), which needed "1 week 3 days" / "1 month 5 days" / "1 year 2
// months" phrasing that formatDurationDays() above doesn't produce (it
// only ever composes days+hours or years+days, no week/month buckets).
// Deliberately a NEW, separate function rather than an edit to
// formatDurationDays() — that function's exact output shape is already
// tuned and verified against Reports' Active Duration column and other
// existing call sites; changing its bucket boundaries there would be an
// unreviewed behavior change to pages outside this pass's scope. Every
// bucket composes at most two whole units, never a bare fractional one —
// same "never a decimal that can't correspond to anything real"
// discipline as formatDurationDays().
function formatDurationNatural($days) {
    $days = (float)$days;
    if ($days < 0) $days = 0;
    $totalMinutes = $days * 24 * 60;

    if ($totalMinutes < 1) return 'Less than a minute';
    if ($totalMinutes < 60) {
        $minutes = max(1, (int)round($totalMinutes));
        return $minutes . ' minute' . ($minutes === 1 ? '' : 's');
    }
    $totalHours = $totalMinutes / 60;
    if ($totalHours < 24) {
        $hours = (int)floor($totalHours);
        $minutes = (int)round(($totalHours - $hours) * 60);
        if ($minutes === 60) { $hours++; $minutes = 0; }
        $out = $hours . ' hour' . ($hours === 1 ? '' : 's');
        if ($minutes > 0) $out .= ' ' . $minutes . ' minute' . ($minutes === 1 ? '' : 's');
        return $out;
    }
    if ($days < 7) {
        $daysInt = (int)floor($days);
        $remHours = (int)round(($days - $daysInt) * 24);
        if ($remHours === 24) { $daysInt++; $remHours = 0; }
        $out = $daysInt . ' day' . ($daysInt === 1 ? '' : 's');
        if ($remHours > 0) $out .= ' ' . $remHours . ' hour' . ($remHours === 1 ? '' : 's');
        return $out;
    }
    if ($days < 30) {
        $weeks = (int)floor($days / 7);
        $remDays = (int)round($days - ($weeks * 7));
        if ($remDays === 7) { $weeks++; $remDays = 0; }
        $out = $weeks . ' week' . ($weeks === 1 ? '' : 's');
        if ($remDays > 0) $out .= ' ' . $remDays . ' day' . ($remDays === 1 ? '' : 's');
        return $out;
    }
    if ($days < 365) {
        $months = (int)floor($days / 30);
        $remDays = (int)round($days - ($months * 30));
        if ($remDays >= 30) { $months++; $remDays -= 30; }
        $out = $months . ' month' . ($months === 1 ? '' : 's');
        if ($remDays > 0) $out .= ' ' . $remDays . ' day' . ($remDays === 1 ? '' : 's');
        return $out;
    }
    $years = (int)floor($days / 365);
    $remDays = $days - ($years * 365);
    $months = (int)round($remDays / 30);
    if ($months === 12) { $years++; $months = 0; }
    $out = $years . ' year' . ($years === 1 ? '' : 's');
    if ($months > 0) $out .= ' ' . $months . ' month' . ($months === 1 ? '' : 's');
    return $out;
}

// Account-longevity wrapper around formatDurationNatural() (2026-08-06) —
// for a real account's age/lifespan (Trading Archive's Account Age /
// Trading Longevity fields), never below a 1-day floor. Confirmed by
// Kevin directly: an account created and blown/closed a second later is
// still a real "1 day" account, never "5 minutes" — the same "no
// fabricated sub-day precision" principle formatJourneyDuration() below
// already documents for a different context (goal/phase pass timing).
// Deliberately still delegates to formatDurationNatural() rather than
// switching to formatJourneyDuration(), since the compound "1 month 1
// day"-style phrasing formatDurationNatural() produces for larger spans
// was already confirmed correct and wanted here — only the sub-1-day
// case needed fixing, not the whole formatter.
function formatAccountLongevity($days) {
    return formatDurationNatural(max(1.0, (float)$days));
}

// Journey-duration formatter — built for Trading Archive's Lessons From
// Success page (2026-08-04, journey-vs-clock-time refinement pass). This
// is DELIBERATELY a different formatter from formatDurationNatural()
// above: that one is for real elapsed-TIME durations (a trade's holding
// period, an MT5 reconciliation gap) where minute/hour precision is the
// actual point. A trader describing how long it took to PASS a prop
// account never thinks in minutes or hours — "I passed FTMO in 17
// minutes" isn't a sentence a real trader says, even when the underlying
// account-creation-to-pass timestamps really are only 17 minutes apart
// (common in seeded/test data, or a trader who front-loaded a challenge
// in one sitting). So this formatter's floor is always whole days, never
// smaller, and it escalates to weeks/months/years the same way a trader
// would naturally describe it, rather than composing two units the way
// formatDurationNatural() does ("1 week 3 days") — a journey duration is
// described as ONE unit ("1 week"), not a compound one.
//
// Bucket boundaries (a documented judgment call resolving a real
// internal inconsistency in the originating spec — one worked example
// showed a 6-calendar-day gap, Jan 1 to Jan 7, rendering as "1 week"
// rather than "6 days", which only holds if the week bucket begins at 7
// days, not 8): 0-1 days -> "1 day" (a same-day pass is still a real,
// valid journey, never "0 days" or a sub-day unit); 2-6 days -> "N
// days"; 7-29 days -> round to the nearest week (minimum "1 week");
// 30-364 days -> round to the nearest month (minimum "1 month"); 365+
// days -> round to the nearest year (minimum "1 year").
function formatJourneyDuration($days) {
    $days = (float)$days;
    if ($days < 0) $days = 0;
    $daysInt = (int)round($days);

    if ($daysInt <= 1) return '1 day';
    if ($daysInt < 7) return $daysInt . ' days';
    if ($daysInt < 30) {
        $weeks = max(1, (int)round($daysInt / 7));
        return $weeks . ' week' . ($weeks === 1 ? '' : 's');
    }
    if ($daysInt < 365) {
        $months = max(1, (int)round($daysInt / 30));
        return $months . ' month' . ($months === 1 ? '' : 's');
    }
    $years = max(1, (int)round($daysInt / 365));
    return $years . ' year' . ($years === 1 ? '' : 's');
}

// Same bucketing as formatJourneyDuration() above, but for the Trading
// Archive "Lessons From Success" single-account page (2026-08-05), which
// treats account progression as a journey a trader would narrate — "1
// Trading Day", not "1 day" — everywhere a level/journey/goal duration is
// shown. Only the sub-week bucket's noun changes ("Trading Day"/"Trading
// Days" instead of "day"/"days"); the week/month/year buckets already read
// naturally on their own and don't need the "Trading" qualifier.
function formatTradingJourneyDuration($days) {
    $days = (float)$days;
    if ($days < 0) $days = 0;
    $daysInt = (int)round($days);

    if ($daysInt <= 1) return '1 Trading Day';
    if ($daysInt < 7) return $daysInt . ' Trading Days';
    if ($daysInt < 30) {
        $weeks = max(1, (int)round($daysInt / 7));
        return $weeks . ' Week' . ($weeks === 1 ? '' : 's');
    }
    if ($daysInt < 365) {
        $months = max(1, (int)round($daysInt / 30));
        return $months . ' Month' . ($months === 1 ? '' : 's');
    }
    $years = max(1, (int)round($daysInt / 365));
    return $years . ' Year' . ($years === 1 ? '' : 's');
}

// Natural-language phrase for an AVERAGE of a discrete, whole-event count —
// trades, streak lengths, recoveries. A raw decimal like "1.4" is a real
// statistical average but describes something no single real event can ever
// be a fraction of, so this never returns a bare decimal. Small averages
// (< 5) that sit meaningfully between two whole numbers render as a tight
// range ("1–2 consecutive losses"), matching how a "typical" streak length
// is naturally described; a value close enough to one whole number renders
// exactly ("1 trade", not "about 1 trade"); larger averages render as a
// plain rounded "about N" figure, since a range adds no real clarity once N
// is already large (nobody needs to be told "87–88 trades").
function naturalCount(?float $avg, string $singular, ?string $plural = null) {
    if ($avg === null) return null;
    $plural = $plural ?? ($singular . 's');
    if ($avg < 0.5) return 'typically none';
    $n = max(1, (int)round($avg));
    if ($avg >= 5) return 'about ' . $n . ' ' . $plural;
    $frac = $avg - floor($avg);
    if ($frac <= 0.15 || $frac >= 0.85) return $n . ' ' . ($n === 1 ? $singular : $plural);
    $lo = max(1, (int)floor($avg));
    $hi = max($lo + 1, (int)ceil($avg));
    return $lo . '-' . $hi . ' ' . $plural;
}
