<?php

/**
 * Action-context helpers introduced by run 2026-04-25-action-context-helper
 * (Step 1) and pruned by run 2026-04-25-action-fragment-context-removal after
 * Cluster 10 (Drawer-Fragments) was declared a structural exception in run
 * 2026-04-25-action-context-rollout-step11-fragments-exception. The remaining
 * 5 orthogonal building blocks plus the 2 cluster aggregators
 * (actionEditContext, actionCreateContext) centralize the ~40-80 line
 * pre-render preamble that Edit/Create actions repeat. Drawer-fragment actions
 * delegate to domain-services with their own status models and are NOT served
 * by these helpers — see the Step 11 decision record for rationale.
 */

use MintyPHP\Router;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;

/**
 * Resolve a model by raw id (uuid, integer or code) using the caller's finder.
 *
 * The finder MUST apply tenant scope itself — this helper deliberately stays
 * scope-agnostic so it can serve UUID, integer-id and code-keyed actions.
 *
 * On miss the helper flashes the i18n key and redirects to $redirectPath; the
 * caller's page execution ends inside Router::redirect().
 *
 * @param callable(string): mixed $finder           Returns the resolved model or null.
 * @param string                  $rawId            Raw id from the URL (already trimmed by router).
 * @param string                  $notFoundFlashKey Translation key for the flash message.
 * @param string                  $redirectPath     Target path on miss (e.g. 'admin/users').
 *
 * @return mixed The resolved model. Never returns when not found (redirect ends execution).
 */
function actionResolveModelOrFail(
    callable $finder,
    string $rawId,
    string $notFoundFlashKey,
    string $redirectPath
): mixed {
    $model = $finder($rawId);
    if ($model !== null) {
        return $model;
    }

    Flash::error(t($notFoundFlashKey), $redirectPath, 'not_found');
    Router::redirect($redirectPath);
    // Router::redirect() calls die() in production. Guard for tests where
    // executeRedirect=false: caller MUST not rely on this being reached.
    return null;
}

/**
 * Run AuthorizationService::authorize and extract the capabilities map.
 *
 * Both forbidden strategies set a final HTTP response and end page execution:
 *   - 'redirect' → Router::redirect('error/forbidden') (302)
 *   - 'deny'     → Guard::deny() (403)
 *
 * Neither strategy can be bypassed from the caller — both terminate via
 * Router::redirect() / die() in production.
 *
 * @param string                $abilityKey        Policy ability constant.
 * @param array<string, mixed>  $context           Authorization context payload.
 * @param string                $forbiddenStrategy 'redirect' (default) or 'deny'.
 *
 * @return array<string, mixed> The decision's capabilities attribute, or [].
 */
function actionAuthorizeAndExtractCapabilities(
    string $abilityKey,
    array $context,
    string $forbiddenStrategy = 'redirect'
): array {
    $decision = app(AuthorizationService::class)->authorize($abilityKey, $context);

    if (!$decision->isAllowed()) {
        if ($forbiddenStrategy === 'deny') {
            Guard::deny();
            return [];
        }
        Router::redirect('error/forbidden');
        return [];
    }

    $capabilities = $decision->attribute('capabilities', []);
    return is_array($capabilities) ? $capabilities : [];
}

/**
 * Derive the tenant scope tuple from a capabilities map.
 *
 * Strict semantics (GR-SEC-009): scope='all' is ONLY returned when the
 * $allTenantsFlagKey is present and === true. A null/missing/non-true value
 * for the flag NEVER widens the scope — it falls through to 'list', which
 * may legitimately be empty.
 *
 * The Departments-Edit semantics are the calibration target. Users-Edit has
 * a different "null = preserve out-of-scope" semantic that is handled by
 * mergeTenantIdsPreservingOutOfScope and is NOT in scope for this helper.
 *
 * @param array<string, mixed> $capabilities       Capabilities map.
 * @param string               $allTenantsFlagKey  Flag key that grants scope='all'.
 * @param string               $allowedListKey     List key for explicit tenant ids.
 *
 * @return array{scope: 'all'|'list', ids: list<int>}
 */
function actionDeriveTenantScope(
    array $capabilities,
    string $allTenantsFlagKey = 'can_manage_all_tenants',
    string $allowedListKey = 'allowed_tenant_ids'
): array {
    if (($capabilities[$allTenantsFlagKey] ?? null) === true) {
        return ['scope' => 'all', 'ids' => []];
    }

    $raw = $capabilities[$allowedListKey] ?? null;
    if (!is_array($raw)) {
        return ['scope' => 'list', 'ids' => []];
    }

    $ids = [];
    foreach ($raw as $value) {
        $intValue = is_numeric($value) ? (int) $value : 0;
        if ($intValue > 0) {
            $ids[] = $intValue;
        }
    }

    return ['scope' => 'list', 'ids' => array_values(array_unique($ids))];
}

/**
 * Enforce a can_view_page-style flag from the capabilities map.
 *
 * Strict comparison (=== true). No truthy coercion: integers, strings,
 * arrays, null all fail. Capabilities are system-internal PHP booleans
 * produced by AuthorizationService — no vendor enum coercion. If a capability
 * source produces non-boolean truthy values, normalize at the source, not
 * here.
 *
 * On failure either Router::redirect('error/forbidden') or Guard::deny() is
 * invoked depending on $forbiddenStrategy. Both end page execution.
 *
 * @param array<string, mixed> $capabilities      Capabilities map.
 * @param string               $flagKey           Flag to check (default 'can_view_page').
 * @param string               $forbiddenStrategy 'redirect' (default) or 'deny'.
 */
function actionEnforceCanViewPage(
    array $capabilities,
    string $flagKey = 'can_view_page',
    string $forbiddenStrategy = 'redirect'
): void {
    if (($capabilities[$flagKey] ?? false) === true) {
        return;
    }

    if ($forbiddenStrategy === 'deny') {
        Guard::deny();
        return;
    }
    Router::redirect('error/forbidden');
}

/**
 * Build the view-auth payload from a whitelisted slice of capabilities.
 *
 * Return value contains MIXED types from capabilities (bool, int, string,
 * array). Caller MUST e()-escape all non-boolean values before view output.
 * See GR-SEC-010. Test ActionContextHelperTest verifies the array shape but
 * does not enforce escaping — that is the view's responsibility.
 *
 * @param array<string, mixed> $capabilities       Full capabilities map.
 * @param list<string>         $whitelistedFlags   Flag names to expose to the view.
 *
 * @return array{page: array<string, mixed>}
 */
function actionBuildViewAuth(array $capabilities, array $whitelistedFlags): array
{
    return [
        'page' => array_intersect_key($capabilities, array_flip($whitelistedFlags)),
    ];
}

/**
 * CALLERS MUST call actionRequireCsrf() BEFORE this aggregator for POST requests. This helper does NOT verify CSRF (GR-SEC-001). For GET-only edit-render flows, CSRF is not required.
 *
 * Cluster-1/2/3 Edit aggregator: resolve model → authorize → derive tenant
 * scope → enforce can_view_page → build viewAuth.
 *
 * Two-Level-Authorize (CONTEXT + SUBMIT, used by Roles/Permissions) is NOT
 * built in — actions that need it call the building blocks directly.
 *
 * Forward-compatibility pattern: this aggregator's signature uses an assoc
 * array $args to permit additive Step 2+ extension (e.g. notFoundFlashScopeKey
 * override added with the Departments-Edit pilot in Run
 * 2026-04-25-action-context-rollout-step2-departments). Building blocks
 * (actionResolveModelOrFail et al.) remain frozen by
 * ActionContextHelperContractTest::testBuildingBlocksExistWithStableSignatures;
 * caller-specific behavior must be expressed via additive aggregator args, not
 * by widening building-block signatures.
 *
 * Required keys in $args:
 *   - finder: callable(string): mixed
 *   - rawId: string
 *   - notFoundFlashKey: string
 *   - notFoundRedirectPath: string
 *   - abilityKey: string
 *   - context: array<string, mixed>
 *   - viewAuthFlags: list<string>
 * Optional keys:
 *   - forbiddenStrategy: 'redirect'|'deny' (default 'redirect')
 *   - tenantScopeFlagKey: string
 *   - tenantScopeListKey: string
 *   - canViewPageFlagKey: string (default 'can_view_page')
 *   - notFoundFlashScopeKey: string|null — when provided, the aggregator
 *     inlines Flash::error(t($notFoundFlashKey), $notFoundRedirectPath,
 *     $notFoundFlashScopeKey) + Router::redirect instead of delegating to
 *     actionResolveModelOrFail (which hardcodes scope-key 'not_found'). Use
 *     this to preserve a domain-specific dedup-scope-key (e.g.
 *     'department_not_found') without widening the frozen building-block
 *     signature. The scope-key is a flash-dedup identifier only — it must
 *     not contain PII (GR-SEC-002).
 *
 * @param array<string, mixed> $args
 * @return array{model: mixed, capabilities: array<string, mixed>, tenantScope: array{scope: 'all'|'list', ids: list<int>}, viewAuth: array{page: array<string, mixed>}}
 */
function actionEditContext(array $args): array
{
    $finder = $args['finder'];
    $rawId = (string) $args['rawId'];
    $notFoundFlashKey = (string) $args['notFoundFlashKey'];
    $notFoundRedirectPath = (string) $args['notFoundRedirectPath'];
    $abilityKey = (string) $args['abilityKey'];
    $context = is_array($args['context'] ?? null) ? $args['context'] : [];
    $viewAuthFlags = is_array($args['viewAuthFlags'] ?? null) ? $args['viewAuthFlags'] : [];
    $forbiddenStrategy = (string) ($args['forbiddenStrategy'] ?? 'redirect');
    $tenantScopeFlagKey = (string) ($args['tenantScopeFlagKey'] ?? 'can_manage_all_tenants');
    $tenantScopeListKey = (string) ($args['tenantScopeListKey'] ?? 'allowed_tenant_ids');
    $canViewPageFlagKey = (string) ($args['canViewPageFlagKey'] ?? 'can_view_page');
    $notFoundFlashScopeKey = $args['notFoundFlashScopeKey'] ?? null;
    $notFoundFlashScopeKey = is_string($notFoundFlashScopeKey) && $notFoundFlashScopeKey !== ''
        ? $notFoundFlashScopeKey
        : null;

    if ($notFoundFlashScopeKey !== null) {
        $model = is_callable($finder) ? $finder($rawId) : null;
        if ($model === null) {
            Flash::error(t($notFoundFlashKey), $notFoundRedirectPath, $notFoundFlashScopeKey);
            Router::redirect($notFoundRedirectPath);
            // Router::redirect() calls die() in production. Guard for tests
            // where executeRedirect=false: caller MUST not rely on this being
            // reached — return value mirrors the building-block fallthrough.
            $model = null;
        }
    } else {
        $model = actionResolveModelOrFail($finder, $rawId, $notFoundFlashKey, $notFoundRedirectPath);
    }
    $capabilities = actionAuthorizeAndExtractCapabilities($abilityKey, $context, $forbiddenStrategy);
    $tenantScope = actionDeriveTenantScope($capabilities, $tenantScopeFlagKey, $tenantScopeListKey);
    actionEnforceCanViewPage($capabilities, $canViewPageFlagKey, $forbiddenStrategy);
    $viewAuth = actionBuildViewAuth($capabilities, $viewAuthFlags);

    return [
        'model' => $model,
        'capabilities' => $capabilities,
        'tenantScope' => $tenantScope,
        'viewAuth' => $viewAuth,
    ];
}

/**
 * CALLERS MUST call actionRequireCsrf() BEFORE this aggregator for POST requests. This helper does NOT verify CSRF (GR-SEC-001). For GET-only create-render flows, CSRF is not required.
 *
 * Cluster-7 Create aggregator: authorize → derive tenant scope → enforce
 * can_view_page → build viewAuth. No model resolution.
 *
 * Required keys in $args:
 *   - abilityKey: string
 *   - context: array<string, mixed>
 *   - viewAuthFlags: list<string>
 * Optional keys:
 *   - forbiddenStrategy: 'redirect'|'deny'
 *   - tenantScopeFlagKey: string
 *   - tenantScopeListKey: string
 *   - canViewPageFlagKey: string
 *
 * @param array<string, mixed> $args
 * @return array{capabilities: array<string, mixed>, tenantScope: array{scope: 'all'|'list', ids: list<int>}, viewAuth: array{page: array<string, mixed>}}
 */
function actionCreateContext(array $args): array
{
    $abilityKey = (string) $args['abilityKey'];
    $context = is_array($args['context'] ?? null) ? $args['context'] : [];
    $viewAuthFlags = is_array($args['viewAuthFlags'] ?? null) ? $args['viewAuthFlags'] : [];
    $forbiddenStrategy = (string) ($args['forbiddenStrategy'] ?? 'redirect');
    $tenantScopeFlagKey = (string) ($args['tenantScopeFlagKey'] ?? 'can_manage_all_tenants');
    $tenantScopeListKey = (string) ($args['tenantScopeListKey'] ?? 'allowed_tenant_ids');
    $canViewPageFlagKey = (string) ($args['canViewPageFlagKey'] ?? 'can_view_page');

    $capabilities = actionAuthorizeAndExtractCapabilities($abilityKey, $context, $forbiddenStrategy);
    $tenantScope = actionDeriveTenantScope($capabilities, $tenantScopeFlagKey, $tenantScopeListKey);
    actionEnforceCanViewPage($capabilities, $canViewPageFlagKey, $forbiddenStrategy);
    $viewAuth = actionBuildViewAuth($capabilities, $viewAuthFlags);

    return [
        'capabilities' => $capabilities,
        'tenantScope' => $tenantScope,
        'viewAuth' => $viewAuth,
    ];
}
