<?php
/**
 * ValidationService — Validation Workflow orchestrator.
 *
 * Per the Validation Workflow spec (traderalign-validation-workflow.md
 * §20): this class owns WORKFLOW RULES (which workflow a user is in,
 * 50/100-trade progress, graduation eligibility, the atomic graduation
 * transition) — it is explicitly NOT a second trading-math engine. Every
 * number it reports about a strategy's performance is fetched from
 * AnalyticsEngine::getValidationStrategyMetrics() (api/analytics/engine.php)
 * — the canonical, single-source-of-truth calculation layer every other
 * page in this app already reads from. This class never independently
 * computes win rate, expectancy, profit factor, or any other trading
 * statistic.
 *
 * Validation strategies/trades are just strategies/trades with
 * is_validation = 1 (the existing, already-canonical flag — see
 * strategies.is_validation / trades.is_validation in CLAUDE.md). Accounts
 * are universal and untouched by this class entirely — a validation
 * trade is a real trade on a real account, this class never creates or
 * reads a separate "validation account" concept.
 */
require_once __DIR__ . '/../api/analytics/engine.php';
require_once __DIR__ . '/RenderHelpers.php'; // getReportingPeriodBounds() — periodBounds() below delegates to it, never its own date math.

class ValidationService
{
    // Spec §10/§11: 50-trade milestone, 100-trade hard ceiling. Both are
    // CLOSED, is_validation=1 trade counts — open/draft/cancelled trades
    // and non-validation trades never count toward either number.
    const TARGET_TRADES = 50;
    const MAX_TRADES = 100;

    // 2026-08-12 edge-warning pass: how many NEW closed trades must exist
    // after graduation before the post-graduation status card is allowed to
    // claim a direction ("Improving"/"deteriorated further") rather than
    // "not enough new trades yet" — same n-aware-before-claiming-a-trend
    // discipline this app already applies elsewhere (EdgeHealthEngine's
    // period tiers, Trader Profile's growth timeline).
    const MIN_NEW_TRADES_FOR_TREND = 10;

    /**
     * 2026-08-17 scale/architecture follow-up. A single page render
     * routinely calls getWorkflowState()/getEntrySource() from several
     * independent places (sidebar.php, the page's own gate check,
     * dashboard.php's own read, every apFetchAccounts() call site that
     * threads workflow_state through) — before this cache existed, each of
     * those was its own separate `SELECT * FROM user_profiles WHERE
     * user_id=?` round trip, all asking the identical question against a
     * row that cannot have changed mid-render except through this class's
     * own three mutators (graduate()/returnToMain()/enterValidation()).
     * Keyed by userId, process-local — under this app's request-per-process
     * PHP deployment (PHP-FPM/mod_php), static class state is reset at the
     * start of every request, so this can never leak one user's cached
     * profile into another user's request; it only ever shortcuts repeat
     * reads WITHIN the single request currently executing. Any of this
     * class's own writes to workflow_state/validation_entry_source must
     * call clearProfileCache() immediately after, so a later read in the
     * SAME request (there usually isn't one — these mutators are the last
     * thing an endpoint does before returning — but this must hold even
     * when there is) never serves stale pre-mutation state.
     */
    private static array $profileCache = [];

    private static function getProfile($userId, $db): array
    {
        if (!array_key_exists($userId, self::$profileCache)) {
            self::$profileCache[$userId] = $db->selectById('user_profiles', $userId, 'user_id') ?: [];
        }
        return self::$profileCache[$userId];
    }

    private static function clearProfileCache($userId): void
    {
        unset(self::$profileCache[$userId]);
    }

    /**
     * The one, first-class "which workflow is this user currently in"
     * read — every workflow-aware page (sidebar, Journal, Drafts, Trading
     * Center, Dashboard) should call this rather than re-deriving it from
     * onboarding_context's journey_type, which is a one-time choice that
     * never updates again (see user_profiles.workflow_state's own
     * migration comment).
     */
    public static function getWorkflowState($userId, $db): string
    {
        $profile = self::getProfile($userId, $db);
        $state = $profile['workflow_state'] ?? 'main';
        return $state === 'validation' ? 'validation' : 'main';
    }

    public static function isValidating($userId, $db): bool
    {
        return self::getWorkflowState($userId, $db) === 'validation';
    }

    /**
     * 2026-08-16 workflow-isolation-leakage pass. Which of the two real
     * entry paths put this user into validation — 'onboarding' (chose "I'm
     * Developing a Trading Edge"; has never been in the main workflow, so
     * no return link should ever exist) or 'main_workflow' (an existing
     * main-workflow user clicked "Validate a New Edge →"; has real
     * main-workflow strategies/accounts waiting and needs an explicit way
     * back). Returns null when not applicable (workflow_state === 'main',
     * or a pre-2026-08-16 validation session that predates this column —
     * treated the same as 'onboarding' by every caller, i.e. no return
     * link, which is the safe default for state this column can't explain).
     */
    public static function getEntrySource($userId, $db): ?string
    {
        $profile = self::getProfile($userId, $db);
        $source = $profile['validation_entry_source'] ?? null;
        return in_array($source, ['onboarding', 'main_workflow'], true) ? $source : null;
    }

    /**
     * The one, first-class "this page belongs to the established-edge
     * (main) workflow only" gate — 2026-08-17 scale/architecture follow-up,
     * extracted from four independently-duplicated copies of the identical
     * four-line block (reports.php, strategy.php, trader-profile.php,
     * journal.php), each written by a different session and each one more
     * opportunity for a future page to copy it slightly wrong or forget it
     * entirely. Every current and future main-workflow-only page should
     * call this as its first line after auth, instead of re-deriving the
     * check inline. A no-op or redirect+exit — never returns a value,
     * since there is nothing to do with the result other than stop.
     */
    public static function gateMainWorkflowOnly($userId, $db, string $redirectTo = '/traderalign/journal-validation'): void
    {
        if (self::getWorkflowState($userId, $db) === 'validation') {
            header('Location: ' . $redirectTo);
            exit;
        }
    }

    /**
     * 2026-08-16 workflow-isolation-leakage pass. The counterpart to
     * graduate() for the "Validate a New Edge →" case only — flips
     * workflow_state back to 'main' WITHOUT touching the validation
     * strategy or its trades in any way (they stay is_validation = 1,
     * fully preserved, exactly where the trader left them). This is not a
     * graduation: no trade-count floor, no edge evaluation, no provenance
     * record — just "stop looking at the validation workflow for now."
     *
     * Server-side-enforced, not just a hidden UI affordance: only callable
     * when workflow_state is genuinely 'validation' AND
     * validation_entry_source is genuinely 'main_workflow'. An
     * onboarding-originated user (entry_source === 'onboarding' or null)
     * is rejected outright — they have never been in the main workflow, so
     * there is nothing real to "return" to, and this must not become a
     * side-door around the 50-trade graduation requirement.
     */
    public static function returnToMain($userId, $db): array
    {
        if (self::getWorkflowState($userId, $db) !== 'validation') {
            return ['success' => false, 'error' => 'You are not currently in the validation workflow.'];
        }
        if (self::getEntrySource($userId, $db) !== 'main_workflow') {
            return ['success' => false, 'error' => 'This validation session has no main workflow to return to.'];
        }

        $db->update('user_profiles', $userId, [
            'workflow_state' => 'main',
            'validation_entry_source' => null,
        ], 'user_id');
        self::clearProfileCache($userId);

        return ['success' => true];
    }

    /**
     * 2026-08-17 follow-up to the workflow-isolation-leakage pass, closing
     * the real gap it left flagged: returnToMain() leaves a user's
     * validation strategy fully intact but un-resumable through any normal
     * path — clicking "Validate a New Edge →" again always creates a
     * BRAND-NEW strategy (strategy-validation.php's POST handler's own
     * unconditional insert), never picks the old one back up. This is the
     * shared "enter the validation workflow" flip, extracted out of that
     * POST handler's own inline block so a second entry point — resuming
     * an EXISTING validation strategy, see strategy-validation.php's new
     * ?resume= GET handler — can use the exact same logic rather than a
     * second copy. Same no-op-if-already-validating guard as the original
     * inline version: never overwrites an onboarding-originated
     * validation_entry_source with 'main_workflow' if the user is somehow
     * already validating when this runs.
     */
    public static function enterValidation($userId, $db): void
    {
        if (self::getWorkflowState($userId, $db) !== 'validation') {
            $db->update('user_profiles', $userId, [
                'workflow_state' => 'validation',
                'validation_entry_source' => 'main_workflow',
            ], 'user_id');
            self::clearProfileCache($userId);
        }
    }

    /**
     * Every active, non-deleted validation strategy for this user, with a
     * real closed-trade count per strategy. Same query journal-validation.php
     * already used (now the single source for it), tightened to also
     * require the trade row itself carry is_validation=1 (defensive —
     * every trade attached to an is_validation=1 strategy's version should
     * already carry this, but the trade is the actual isolation boundary,
     * not the strategy).
     */
    public static function getValidationStrategies($userId, $db): array
    {
        return $db->raw(
            "SELECT s.*, sv.id as version_id, sv.version_number,
                    (SELECT COUNT(*) FROM trades t
                     WHERE t.strategy_version_id = sv.id AND t.is_validation = 1
                       AND t.status = 'closed' AND t.deleted_at IS NULL) as trade_count
             FROM strategies s
             JOIN strategy_versions sv ON sv.id = s.current_version_id
             WHERE s.user_id = :uid AND s.is_validation = 1 AND s.is_active = 1 AND s.deleted_at IS NULL
             ORDER BY s.created_at DESC",
            [':uid' => $userId]
        );
    }

    /**
     * 50/100-trade progress for one validation strategy version. The
     * denominator is always 50 (spec §10 — "do not invent a different
     * denominator"); progress_pct is capped at 100 even past the target
     * (a strategy at 73/50 trades still reads 100%, not 146%).
     */
    public static function getProgress($userId, $strategyVersionId, $db): array
    {
        $rows = $db->raw(
            "SELECT COUNT(*) as cnt FROM trades
             WHERE user_id = :uid AND strategy_version_id = :svid
               AND is_validation = 1 AND status = 'closed' AND deleted_at IS NULL",
            [':uid' => $userId, ':svid' => $strategyVersionId]
        );
        $count = (int)($rows[0]['cnt'] ?? 0);

        return [
            'trade_count' => $count,
            'target' => self::TARGET_TRADES,
            'max' => self::MAX_TRADES,
            'progress_pct' => min(100, round(($count / self::TARGET_TRADES) * 100, 1)),
            'eligible_to_graduate' => $count >= self::TARGET_TRADES,
            'at_cap' => $count >= self::MAX_TRADES,
        ];
    }

    /**
     * Canonical performance metrics for one validation strategy — thin
     * delegation, see class docblock. Nothing here computes trading math.
     * $dateFrom/$dateTo (optional) power the Tracker's lightweight
     * Weekly/Monthly/Quarterly view (spec §22) — omitted, this is the
     * strategy's all-time validation performance.
     */
    public static function getMetrics($userId, $strategyVersionId, $dateFrom = null, $dateTo = null): array
    {
        return AnalyticsEngine::getValidationStrategyMetrics($userId, $strategyVersionId, $dateFrom, $dateTo, 'only');
    }

    /**
     * Same canonical metrics, scoped to a strategy version's trades AFTER
     * graduation — used only by strategy.php's prefill step. By the time a
     * trader reaches that step, ValidationService::graduate() has already
     * flipped is_validation to 0 on both the strategy and its trades (see
     * graduate() above), so this must read them the normal, main-workflow
     * way — requiring is_validation=1 here would find zero rows.
     */
    public static function getPostGraduationMetrics($userId, $strategyVersionId): array
    {
        return AnalyticsEngine::getValidationStrategyMetrics($userId, $strategyVersionId, null, null, 'exclude');
    }

    /**
     * 2026-08-12 edge-warning pass. A pure classification over numbers
     * AnalyticsEngine already computed — this never derives expectancy or
     * profit factor itself, it only decides what those two already-real
     * numbers mean. "Demonstrated" = a positive dollar expectancy per
     * trade AND (zero losing trades so far, or a real profit factor above
     * 1.0). A null profit_factor with real trades present means zero
     * losers in the sample yet — strictly better than requiring
     * profit_factor > 1, never treated as a gap.
     *
     * Returns demonstrated=null (not true/false) when there isn't enough
     * data to classify at all (zero trades) — callers must not treat null
     * as "no edge," only as "nothing to evaluate yet."
     */
    public static function evaluateEdge(array $metrics): array
    {
        $tradeCount = (int)($metrics['total_trades'] ?? 0);
        $expectancy = (float)($metrics['expectancy'] ?? 0);
        $profitFactor = array_key_exists('profit_factor', $metrics) ? $metrics['profit_factor'] : null;

        if ($tradeCount === 0) {
            return ['demonstrated' => null, 'reason' => 'no_trades', 'expectancy' => $expectancy, 'profit_factor' => $profitFactor];
        }

        $demonstrated = ($expectancy > 0) && ($profitFactor === null || (float)$profitFactor > 1.0);

        return [
            'demonstrated' => $demonstrated,
            'expectancy' => $expectancy,
            'profit_factor' => $profitFactor,
            'reason' => $demonstrated ? 'positive_expectancy_and_profit_factor' : 'expectancy_or_profit_factor_not_positive',
        ];
    }

    /**
     * The most recent graduation provenance record for one strategy, or
     * null if it was never graduated (or graduated before this pass added
     * these fields — a pre-2026-08-12 graduation record has no
     * metrics_at_graduation/edge_warning_acknowledged keys, so callers must
     * treat a record missing those keys as "no warning card to show," not
     * throw). A strategy could in principle appear more than once if it
     * were ever re-entered into validation and re-graduated (not a flow
     * this pass builds, but defensive) — the LAST matching record wins.
     */
    public static function findGraduationRecordForStrategy($userId, $strategyId, $db): ?array
    {
        $profile = $db->selectById('user_profiles', $userId, 'user_id');
        $ctx = json_decode($profile['onboarding_context'] ?? '{}', true) ?: [];
        $graduations = $ctx['graduations'] ?? [];
        $match = null;
        foreach ($graduations as $g) {
            if (($g['strategy_id'] ?? null) === $strategyId) {
                $match = $g;
            }
        }
        return $match;
    }

    /**
     * Live status for a strategy that graduated WITH the edge warning
     * acknowledged — compares its current, live, post-graduation metrics
     * (fetched by the caller via getPostGraduationMetrics(), never
     * recomputed here) against the metrics_at_graduation snapshot frozen
     * in its provenance record. Never a static/frozen verdict — this is
     * re-evaluated on every Dashboard render, so the label moves as real
     * trades accumulate.
     */
    public static function getGraduationWarningStatus(array $graduationRecord, array $currentMetrics): array
    {
        $atGrad = $graduationRecord['metrics_at_graduation'] ?? [];
        $expAtGrad = (float)($atGrad['expectancy'] ?? 0);
        $tradesAtGrad = (int)($graduationRecord['trade_count_at_graduation'] ?? 0);
        $expNow = (float)($currentMetrics['expectancy'] ?? 0);
        $tradesNow = (int)($currentMetrics['total_trades'] ?? 0);
        $newTrades = max(0, $tradesNow - $tradesAtGrad);

        $edgeNow = self::evaluateEdge($currentMetrics);

        if ($edgeNow['demonstrated'] === true) {
            return ['status' => 'demonstrated', 'label' => 'Profitable edge demonstrated', 'tone' => 'positive', 'new_trades' => $newTrades];
        }

        if ($newTrades < self::MIN_NEW_TRADES_FOR_TREND) {
            return ['status' => 'insufficient_new_data', 'label' => 'Not enough new trades yet to update this assessment', 'tone' => 'neutral', 'new_trades' => $newTrades];
        }

        // Relative-change threshold (15% of the at-graduation expectancy,
        // floored at a small absolute amount when that baseline is itself
        // near zero) — a real direction, not noise from a handful of trades.
        $delta = $expNow - $expAtGrad;
        $threshold = max(0.01, abs($expAtGrad) * 0.15);
        if ($delta >= $threshold) {
            return ['status' => 'improving', 'label' => 'Improving', 'tone' => 'caution', 'new_trades' => $newTrades];
        }
        if ($delta <= -$threshold) {
            return ['status' => 'deteriorating', 'label' => 'Performance has deteriorated further', 'tone' => 'negative', 'new_trades' => $newTrades];
        }
        return ['status' => 'unchanged', 'label' => 'Still developing, no clear change yet', 'tone' => 'neutral', 'new_trades' => $newTrades];
    }

    /**
     * Period bounds for the Tracker's lightweight Weekly/Monthly/Quarterly
     * view (spec §22) — delegates entirely to the app's own canonical
     * getReportingPeriodBounds() (src/RenderHelpers.php, required globally
     * by index.php), the same calendar-aligned boundary math Reports and
     * Trader Profile already use. This class computes no date math of its
     * own. Returns [startStr, endStr], or [null, null] for 'all' (no date
     * bound — the strategy's full validation history).
     */
    public static function periodBounds(string $period): array
    {
        if (!in_array($period, ['weekly', 'monthly', 'quarterly'], true)) {
            return [null, null];
        }
        $bounds = getReportingPeriodBounds($period);
        return [$bounds['start_str'], $bounds['end_str']];
    }

    /**
     * Atomic graduation (spec §16/§30). Verifies workflow state and the
     * 50-100 trade bound SERVER-SIDE (never trusts the client), then in
     * one transaction:
     *   - flips is_validation=0 on the strategy AND every trade attached
     *     to any of its versions (same rows, same IDs — no copy, no
     *     duplicate, per spec §13's central rule)
     *   - flips the user's whole workflow_state to 'main' (spec §17 — the
     *     sidebar/Reports/main-Strategy availability change for the whole
     *     account, not just this one strategy; see the plan's "graduation
     *     is per-strategy in mechanics, per-user in effect" note)
     *   - records provenance in onboarding_context (spec §18 — history is
     *     preserved, never destroyed)
     * All-or-nothing: any failure rolls back the whole transaction, never
     * leaving workflow_state=main with trades still flagged is_validation=1
     * or vice versa.
     *
     * 2026-08-12 edge-warning pass: $forceOverride is the ONLY way past a
     * strategy that hasn't demonstrated a profitable edge (evaluateEdge()
     * — see above) at the moment of graduation. This is enforced HERE,
     * server-side, not just as a confirm() dialog in the Tracker's JS — a
     * client that never shows the warning UI still cannot graduate an
     * edge-negative strategy without explicitly sending force=true.
     */
    public static function graduate($userId, $strategyId, PDO $pdo, $db, bool $forceOverride = false): array
    {
        if (self::getWorkflowState($userId, $db) !== 'validation') {
            return ['success' => false, 'error' => 'You are not currently in the validation workflow.'];
        }

        $rows = $db->raw(
            "SELECT s.*, sv.id as version_id FROM strategies s
             LEFT JOIN strategy_versions sv ON sv.id = s.current_version_id
             WHERE s.id = :sid AND s.user_id = :uid AND s.deleted_at IS NULL",
            [':sid' => $strategyId, ':uid' => $userId]
        );
        $strategy = $rows[0] ?? null;
        if (!$strategy) {
            return ['success' => false, 'error' => 'Strategy not found or access denied.'];
        }
        if ((int)($strategy['is_validation'] ?? 0) !== 1) {
            return ['success' => false, 'error' => 'This strategy is not in validation mode. It may already be in production.'];
        }
        if (empty($strategy['version_id'])) {
            return ['success' => false, 'error' => 'Strategy has no current version.'];
        }

        // Server-side cap enforcement — never trust a client-supplied trade
        // count (spec §32: "the cap must be enforced server-side").
        $progress = self::getProgress($userId, $strategy['version_id'], $db);
        if (!$progress['eligible_to_graduate']) {
            return [
                'success' => false,
                'error' => "This strategy needs at least " . self::TARGET_TRADES . " closed validation trades before graduating (currently {$progress['trade_count']}).",
            ];
        }

        $versionRows = $db->raw("SELECT id FROM strategy_versions WHERE strategy_id = :sid", [':sid' => $strategyId]);
        $versionIds = array_column($versionRows, 'id');
        if (empty($versionIds)) {
            return ['success' => false, 'error' => 'Strategy has no versions.'];
        }

        // Edge evaluation — server-side gate, not a UI-only confirm dialog.
        // Metrics are the exact same canonical getMetrics() call the
        // Tracker's own CTA reads, all-time, is_validation=1 scope.
        $metrics = self::getMetrics($userId, $strategy['version_id']);
        $edge = self::evaluateEdge($metrics);
        if ($edge['demonstrated'] === false && !$forceOverride) {
            return [
                'success' => false,
                'warning' => true,
                'edge_demonstrated' => false,
                'metrics' => $metrics,
                'error' => 'This strategy has not yet demonstrated a profitable edge based on its validation trades so far.',
            ];
        }

        try {
            $pdo->beginTransaction();

            $stmt = $pdo->prepare("UPDATE strategies SET is_validation = 0 WHERE id = :sid AND user_id = :uid");
            $stmt->execute([':sid' => $strategyId, ':uid' => $userId]);
            if ($stmt->rowCount() === 0) {
                throw new \RuntimeException('Failed to update strategy: no rows affected.');
            }

            $placeholders = [];
            $params = [':uid' => $userId];
            foreach ($versionIds as $i => $vid) {
                $key = ":vid{$i}";
                $placeholders[] = $key;
                $params[$key] = $vid;
            }
            $inClause = implode(', ', $placeholders);
            $stmt = $pdo->prepare(
                "UPDATE trades SET is_validation = 0
                 WHERE strategy_version_id IN ({$inClause}) AND user_id = :uid AND deleted_at IS NULL"
            );
            $stmt->execute($params);
            $tradesUpdated = $stmt->rowCount();

            // Preserve provenance (spec §18) in the same JSON blob every
            // other onboarding/validation write already uses this session.
            $profile = $db->selectById('user_profiles', $userId, 'user_id');
            $ctx = json_decode($profile['onboarding_context'] ?? '{}', true) ?: [];
            $edgeWarningAcknowledged = ($edge['demonstrated'] === false && $forceOverride === true);
            $ctx['graduations'][] = [
                'strategy_id' => $strategyId,
                'strategy_name' => $strategy['name'] ?? '',
                'graduated_at' => date('Y-m-d H:i:s'),
                'trade_count_at_graduation' => $progress['trade_count'],
                'versions_migrated' => count($versionIds),
                'trades_migrated' => $tradesUpdated,
                // Edge-warning pass (2026-08-12): whether this strategy had
                // a demonstrated profitable edge at graduation, and whether
                // the trader explicitly overrode a "no edge yet" warning to
                // graduate anyway. metrics_at_graduation is a small,
                // canonical snapshot (not re-derived) used only to compute
                // the live Dashboard status card's Improving/Deteriorated
                // trend later — never treated as authoritative on its own.
                'edge_demonstrated' => $edge['demonstrated'] === true,
                'edge_warning_acknowledged' => $edgeWarningAcknowledged,
                'metrics_at_graduation' => [
                    'expectancy' => $metrics['expectancy'] ?? null,
                    'profit_factor' => $metrics['profit_factor'] ?? null,
                    'win_rate' => $metrics['win_rate'] ?? null,
                    'avg_r_multiple' => $metrics['avg_r_multiple'] ?? null,
                    'total_trades' => $metrics['total_trades'] ?? null,
                ],
            ];

            // validation_entry_source cleared alongside the workflow_state
            // flip (2026-08-16) — it's only ever meaningful while
            // workflow_state === 'validation'; leaving a stale
            // 'main_workflow'/'onboarding' value here would be read by the
            // NEXT validation session (e.g. via "Validate a New Edge →"
            // again later) before that session's own write ever runs,
            // which could show a return link one render early.
            $stmt = $pdo->prepare(
                "UPDATE user_profiles
                 SET workflow_state = 'main', validation_graduated_at = NOW(), validation_entry_source = NULL, onboarding_context = :ctx
                 WHERE user_id = :uid"
            );
            $stmt->execute([':ctx' => json_encode($ctx, JSON_UNESCAPED_SLASHES), ':uid' => $userId]);

            $pdo->commit();
            self::clearProfileCache($userId);

            return [
                'success' => true,
                'strategy_id' => $strategyId,
                'strategy_name' => $strategy['name'] ?? '',
                'version_id' => $strategy['version_id'],
                'trade_count_at_graduation' => $progress['trade_count'],
                'versions_migrated' => count($versionIds),
                'trades_migrated' => $tradesUpdated,
                'edge_demonstrated' => $edge['demonstrated'] === true,
                'edge_warning_acknowledged' => $edgeWarningAcknowledged,
            ];
        } catch (\Throwable $e) {
            $pdo->rollBack();
            return ['success' => false, 'error' => 'Graduation failed: ' . $e->getMessage()];
        }
    }
}
