<?php

namespace App\Models;

use App\Support\AppMode;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

#[Fillable(['name', 'email', 'password', 'tenant_id', 'role', 'settings'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable implements MustVerifyEmail
{
    /** @use HasFactory<UserFactory> */
    use HasFactory, Notifiable;

    /**
     * Request-scoped memo for `storageBytesUsed()`. The MediaController gate
     * + the JSON usage endpoint are the two consumers in a single request;
     * memoizing prevents the second call from re-summing N rows.
     *
     * Not persisted — `?int $storageBytesUsedMemo` lives only for the
     * lifetime of the in-memory User instance. A subsequent request rebuilds
     * the User and so the memo is naturally cleared.
     */
    private ?int $storageBytesUsedMemo = null;

    /**
     * Request-scoped memo for `workspacePlan()` — the active workspace's plan
     * (owner's subscription) or this user's own plan. The four plan gates all
     * resolve through it in a single request; memoizing avoids re-resolving
     * the owner's subscription per gate. Cleared naturally when the request
     * rebuilds the User instance.
     */
    private ?Plan $workspacePlanMemo = null;

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'terms_accepted_at' => 'datetime',
            'disabled_at' => 'datetime',
            'password' => 'hashed',
            'settings' => 'array',
            'is_admin' => 'boolean',
        ];
    }

    public function isDisabled(): bool
    {
        return $this->disabled_at !== null;
    }

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }

    /**
     * Every workspace this user is a member of (membership source of truth —
     * `tenant_id` is only the ACTIVE-workspace pointer). Carries the
     * per-workspace `role` pivot. SaaS-only; self-hosted users have no pivot
     * rows (single shared null-tenant pool).
     */
    public function workspaces(): BelongsToMany
    {
        return $this->belongsToMany(Tenant::class, 'tenant_user')
            ->withPivot('role', 'ai_tokens_monthly')
            ->withTimestamps();
    }

    /**
     * The auto-created personal workspace this user owns — the fallback
     * active pointer when a team workspace is left/deleted (Δ9).
     */
    public function personalWorkspace(): ?Tenant
    {
        return $this->workspaces()
            ->where('tenants.personal', true)
            ->wherePivot('role', 'owner')
            ->first();
    }

    /**
     * This user's role in the given workspace, or null if not a member.
     */
    public function roleIn(Tenant $tenant): ?string
    {
        $pivot = $this->workspaces()
            ->where('tenants.id', $tenant->id)
            ->first();

        return $pivot?->getRelationValue('pivot')?->role;
    }

    public function isOwnerOf(Tenant $tenant): bool
    {
        return $this->roleIn($tenant) === 'owner';
    }

    /**
     * True when this user owns a workspace that still has OTHER members.
     * Deleting such a user (self-service or admin) would tear down a shared
     * team + everyone's projects, so both delete paths block on this until the
     * owner removes the members first. SaaS-only — self-hosted has no
     * tenant_user pivot (single shared pool, no owner/member concept).
     */
    public function ownsWorkspaceWithOtherMembers(): bool
    {
        if (! AppMode::isSaas()) {
            return false;
        }

        return $this->workspaces()
            ->wherePivot('role', 'owner')
            ->whereHas('members', fn ($q) => $q->where('users.id', '!=', $this->id))
            ->exists();
    }

    public function socialAccounts(): HasMany
    {
        return $this->hasMany(SocialAccount::class);
    }

    public function subscriptions(): HasMany
    {
        return $this->hasMany(Subscription::class);
    }

    public function activeSubscription(): ?Subscription
    {
        return $this->subscriptions()
            ->whereIn('status', ['trialing', 'active', 'past_due'])
            ->latest('id')
            ->first();
    }

    public function activePlan(): Plan
    {
        $sub = $this->activeSubscription();
        if ($sub) {
            return $sub->plan;
        }

        return Plan::where('key', 'free')->where('is_active', true)->firstOrFail();
    }

    /**
     * The plan that governs this user's CURRENT actions — the active
     * workspace's plan (the OWNER's subscription, via `Tenant::activePlan()`)
     * when the user is inside a team workspace, falling back to the user's own
     * plan otherwise.
     *
     * This is the workspace-aware replacement for `$this->activePlan()` in the
     * four plan gates: a team member inherits the owner's entitlements rather
     * than their own (free) plan. Request-memoized.
     *
     * The tenant's plan is used only when the tenant actually has a pivot
     * owner (the billing anchor). An OWNERLESS tenant (a self-hosted null
     * tenant, which never reaches here because the gates short-circuit first;
     * or a pre-P1 tenant whose owner pivot wasn't backfilled) carries no
     * billing anchor, so we fall back to the user's OWN subscription — keeping
     * "solo user is their own workspace owner" true even before the pivot row
     * exists, and never demoting a paying solo user to Free.
     */
    private function workspacePlan(): Plan
    {
        if ($this->workspacePlanMemo !== null) {
            return $this->workspacePlanMemo;
        }

        $tenant = $this->tenant;

        return $this->workspacePlanMemo = ($tenant && $tenant->hasOwner())
            ? $tenant->activePlan()
            : $this->activePlan();
    }

    /**
     * Whether the user can perform mutating actions in their active workspace
     * (consumed by the later `EnsureCanEdit` middleware — added now so that
     * phase needn't touch this model). Mode-aware:
     *
     *   - SaaS: the active-workspace pivot role ∈ {owner, admin, editor};
     *     a viewer (or a non-member) is read-only.
     *   - Self-hosted: there's no pivot — `users.role` is the role-of-record;
     *     the admin (is_admin = owner-equivalent) always edits, and any user
     *     edits unless explicitly set to `viewer` (legacy member/owner/editor
     *     values all read as editable).
     *
     * Pivot fallback (SaaS): a user with an active `tenant_id` but NO resolvable
     * pivot role (pre-P1-backfill install, or a tenant whose owner pivot wasn't
     * backfilled) is treated as EDITABLE — only an explicit `viewer` pivot is
     * read-only. This mirrors `workspacePlan()`'s ownerless fallback and avoids
     * bricking a pre-backfill install: the viewer restriction is opt-in (a
     * pivot must explicitly say `viewer`), never the default for a missing row.
     */
    public function canEditActiveWorkspace(): bool
    {
        if (AppMode::isSelfHosted()) {
            return $this->is_admin || $this->role !== 'viewer';
        }

        $tenant = $this->tenant;
        if ($tenant === null) {
            return false;
        }

        $role = $this->roleIn($tenant);

        // No pivot row resolved → editable (pre-backfill safety). Only an
        // explicit `viewer` is read-only.
        if ($role === null) {
            return true;
        }

        return $role !== 'viewer';
    }

    /**
     * Whether this user can SEE the given project (Team Workspaces P3d / Δ23 —
     * per-project visibility). Orthogonal to capability (`canEditActiveWorkspace`):
     * a granted Viewer sees it read-only, a granted Editor can edit it.
     *
     * SaaS: the workspace owner OR admin sees every project in the tenant
     * (short-circuit — no grant rows for them); every other member sees only
     * projects explicitly granted via the `project_user` pivot.
     *
     * Self-hosted: always true — single shared null-tenant pool, all-see-all
     * (Δ23 per-project sharing is SaaS-only for v1).
     *
     * NOTE: tenant scoping is the OUTER filter at every call site (the caller
     * already proved `project.tenant_id === user.tenant_id` before reaching
     * here) — this narrows visibility WITHIN the tenant; it never widens it.
     */
    public function canAccessProject(Project $project): bool
    {
        if (AppMode::isSelfHosted()) {
            return true;
        }

        $tenant = $project->tenant;

        // No resolvable pivot role in the project's tenant → see-all
        // (pre-backfill safety, mirroring `canEditActiveWorkspace`'s ownerless
        // fallback). A pre-P1-backfill install — or a tenant whose owner pivot
        // wasn't backfilled — is the legacy single-user-per-tenant owner; the
        // per-project visibility narrowing is OPT-IN (it only kicks in once the
        // user has a real editor/viewer pivot row). Owner/admin also short-
        // circuit. Only a resolved editor/viewer is narrowed to grants.
        $role = $tenant !== null ? $this->roleIn($tenant) : null;
        if ($role === null || in_array($role, ['owner', 'admin'], true)) {
            return true;
        }

        return ProjectMember::query()
            ->where('project_id', $project->id)
            ->where('user_id', $this->id)
            ->exists();
    }

    public function canExport(): bool
    {
        // Self-hosted is single-user — no plans, no billing, all features on.
        // The script-buyer installed it for themselves; gating exports on a
        // subscription that doesn't exist would lock them out of their own tool.
        if (AppMode::isSelfHosted()) {
            return true;
        }

        return (bool) $this->workspacePlan()->feature('can_export', false);
    }

    /**
     * Whether this user's plan unlocks premium-tagged kits (kits where
     * `marketing.is_premium = true` in the kit JSON). Free is locked out
     * to drive Pro upgrades; self-hosted bypasses entirely.
     *
     * Free plans (default seed) ship `can_use_premium_kits = false`. The
     * migration that seeded this defaults Pro / Scale to true; admins can
     * flip per-plan via the Plan editor.
     */
    public function canUsePremiumKits(): bool
    {
        if (AppMode::isSelfHosted()) {
            return true;
        }

        return (bool) $this->workspacePlan()->feature('can_use_premium_kits', false);
    }

    public function projectLimit(): ?int
    {
        if (AppMode::isSelfHosted()) {
            return null;
        }

        $limit = $this->workspacePlan()->feature('project_limit', null);

        return $limit === null ? null : (int) $limit;
    }

    /**
     * Total bytes consumed by this user's project media. Sums size_bytes
     * from the media_files registry joined through the user's projects.
     *
     * Only project-owned files count toward the user's quota — branding,
     * theme, kit-install, and export-tile registry rows are admin / install
     * scoped and unattributable to a single subscriber. The Pro user's quota
     * therefore tracks `public/user-uploads/projects/{id}/*` exclusively.
     *
     * Projects are scoped by `tenant_id` (v1 ships one tenant per user — no
     * team collaboration). Self-hosted installs use null tenant; we still
     * filter by the user's tenant_id (which is null in that mode) so the
     * sum is correct in both modes. The self-hosted bypass on
     * `storageBytesLimit()` short-circuits the gate before this is called
     * for the cap check anyway, but the JSON usage endpoint still wants an
     * accurate `used_bytes` figure.
     *
     * Memoized request-scoped: the MediaController gate and the
     * /account/storage-usage endpoint each call this once, so the second hit
     * inside the same request returns the cached sum without re-querying.
     */
    public function storageBytesUsed(): int
    {
        if ($this->storageBytesUsedMemo !== null) {
            return $this->storageBytesUsedMemo;
        }

        $projectIds = Project::query()
            ->where(function ($q) {
                if ($this->tenant_id === null) {
                    $q->whereNull('tenant_id');
                } else {
                    $q->where('tenant_id', $this->tenant_id);
                }
            })
            ->pluck('id')
            ->all();

        if (empty($projectIds)) {
            return $this->storageBytesUsedMemo = 0;
        }

        return $this->storageBytesUsedMemo = (int) MediaFile::query()
            ->where('owner_type', 'project')
            ->whereIn('owner_id', $projectIds)
            ->sum('size_bytes');
    }

    /**
     * Storage cap for this user. null = unlimited.
     *
     * Self-hosted installs always return null (the buyer owns their disk —
     * gating storage in script mode would lock them out of their own tool,
     * matching the existing `canExport()` / `projectLimit()` bypass).
     */
    public function storageBytesLimit(): ?int
    {
        if (AppMode::isSelfHosted()) {
            return null;
        }

        return $this->workspacePlan()?->storageBytesLimit();
    }

    /**
     * Whether a pending upload of `$bytes` would fit within the user's
     * remaining storage headroom. true when limit is null (unlimited), and
     * also true for the exact-fill edge case (used + bytes === limit).
     *
     * Negative `$bytes` is clamped to 0 so a malformed caller can't accidentally
     * unlock headroom.
     */
    public function canUpload(int $bytes): bool
    {
        $limit = $this->storageBytesLimit();
        if ($limit === null) {
            return true;
        }

        return ($this->storageBytesUsed() + max(0, $bytes)) <= $limit;
    }

    // -------------------------------------------------------------------------
    // AI token quota (Plan Gating § AI)
    // -------------------------------------------------------------------------

    public function canUseAi(): bool
    {
        if (AppMode::isSelfHosted()) {
            return true;
        }

        return (bool) $this->workspacePlan()->feature('can_use_ai', false);
    }

    /** Workspace pool limit (owner's plan). null = unlimited. */
    public function aiPoolLimit(): ?int
    {
        if (AppMode::isSelfHosted()) {
            return null;
        }

        return $this->workspacePlan()->aiTokensMonthly();
    }

    /** Total tokens spent by ALL members of this workspace in the current period. */
    public function aiPoolUsed(): int
    {
        $periodStart = AiUsage::resolvePeriodStart($this->tenant);

        return (int) AiUsage::query()
            ->when(
                $this->tenant_id !== null,
                fn ($q) => $q->where('tenant_id', $this->tenant_id),
                fn ($q) => $q->whereNull('tenant_id'),
            )
            ->where('period_start', $periodStart->toDateString())
            ->sum('total_tokens');
    }

    /** Tokens left in the workspace pool. null = unlimited. */
    public function aiPoolRemaining(): ?int
    {
        $limit = $this->aiPoolLimit();
        if ($limit === null) {
            return null;
        }

        return max(0, $limit - $this->aiPoolUsed());
    }

    /**
     * Per-member allocation set by the workspace owner. null = uncapped within
     * the pool. Owner rows are always null (only the pool binds them).
     */
    public function aiMemberLimit(): ?int
    {
        if (AppMode::isSelfHosted()) {
            return null;
        }

        if ($this->tenant_id === null) {
            return null;
        }

        $pivot = $this->workspaces()
            ->where('tenants.id', $this->tenant_id)
            ->first();

        $allocation = $pivot?->getRelationValue('pivot')?->ai_tokens_monthly;

        return $allocation === null ? null : (int) $allocation;
    }

    /** Tokens spent by THIS user in this workspace in the current period. */
    public function aiMemberUsed(): int
    {
        $periodStart = AiUsage::resolvePeriodStart($this->tenant);

        return (int) AiUsage::query()
            ->where('user_id', $this->id)
            ->when(
                $this->tenant_id !== null,
                fn ($q) => $q->where('tenant_id', $this->tenant_id),
                fn ($q) => $q->whereNull('tenant_id'),
            )
            ->where('period_start', $periodStart->toDateString())
            ->sum('total_tokens');
    }

    /** Tokens left in this user's personal allocation. null = uncapped within pool. */
    public function aiMemberRemaining(): ?int
    {
        $limit = $this->aiMemberLimit();
        if ($limit === null) {
            return null;
        }

        return max(0, $limit - $this->aiMemberUsed());
    }

    /**
     * The binding remaining figure — whichever tier (pool or member) runs out
     * first. null only when BOTH tiers are unlimited.
     */
    public function aiTokensRemaining(): ?int
    {
        $pool   = $this->aiPoolRemaining();
        $member = $this->aiMemberRemaining();

        if ($pool === null && $member === null) {
            return null;
        }

        return min($pool ?? PHP_INT_MAX, $member ?? PHP_INT_MAX);
    }

    public function canSpendAi(): bool
    {
        $remaining = $this->aiTokensRemaining();

        return $remaining === null || $remaining > 0;
    }
}
