<?php

declare(strict_types=1);

namespace App\Http\Controllers\Settings;

use App\Http\Controllers\Controller;
use App\Http\Requests\Academy\UpsertAcademyBadgeRequest;
use App\Http\Requests\Academy\UpsertAcademyCategoryRequest;
use App\Http\Requests\Academy\UpsertAcademyChallengeRequest;
use App\Http\Requests\Academy\UpsertAcademyCourseRequest;
use App\Http\Requests\Academy\UpsertAcademyLessonRequest;
use App\Http\Requests\Academy\UpsertAcademyPromptPackRequest;
use App\Http\Requests\Academy\UpsertAcademyPromptTemplateRequest;
use App\Models\AcademyAiComparisonResult;
use App\Models\AcademyBadge;
use App\Models\AcademyCategory;
use App\Models\AcademyChallenge;
use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
use App\Models\AcademyLesson;
use App\Models\AcademyLessonBlock;
use App\Models\AcademyLessonRevision;
use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptPackItem;
use App\Models\AcademyPromptTemplate;
use App\Models\User;
use App\Services\Academy\AcademyAdminBillingOverviewService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseLessonOrderingService;
use App\Services\Academy\AcademyLessonMarkdownRenderer;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;

final class AcademyAdminController extends Controller
{
    private const PROMPT_PREVIEW_WEBP_QUALITY = 84;

    private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews';

    private const PROMPT_PREVIEW_VARIANT_WIDTHS = [
        'thumb' => 480,
        'md' => 960,
    ];

    public function __construct(
        private readonly AcademyAdminBillingOverviewService $billingOverview,
        private readonly AcademyCacheService $cache,
        private readonly AcademyCourseLessonOrderingService $courseLessonOrdering,
        private readonly AcademyLessonMarkdownRenderer $lessonMarkdownRenderer,
    ) {}

    public function dashboard(): Response
    {
        $billingSummary = $this->billingOverview->summary();

        return Inertia::render('Admin/Academy/Dashboard', [
            'stats' => [
                'courses' => AcademyCourse::query()->count(),
                'lessons' => AcademyLesson::query()->count(),
                'prompts' => AcademyPromptTemplate::query()->count(),
                'packs' => AcademyPromptPack::query()->count(),
                'challenges' => AcademyChallenge::query()->count(),
                'submissions' => AcademyChallengeSubmission::query()->count(),
                'badges' => AcademyBadge::query()->count(),
                'active_subscribers' => (int) ($billingSummary['active_subscribers'] ?? 0),
                'creator_subscribers' => (int) ($billingSummary['creator_subscribers'] ?? 0),
                'pro_subscribers' => (int) ($billingSummary['pro_subscribers'] ?? 0),
                'grace_period_subscribers' => (int) ($billingSummary['grace_period_subscribers'] ?? 0),
            ],
            'links' => [
                'billing' => route('admin.academy.billing'),
                'courses' => route('admin.academy.courses.index'),
                'categories' => route('admin.academy.categories.index'),
                'lessons' => route('admin.academy.lessons.index'),
                'prompts' => route('admin.academy.prompts.index'),
                'packs' => route('admin.academy.packs.index'),
                'challenges' => route('admin.academy.challenges.index'),
                'submissions' => route('admin.academy.submissions.index'),
                'badges' => route('admin.academy.badges.index'),
                'analytics' => route('admin.academy.analytics.overview'),
            ],
        ]);
    }

    public function billing(): Response
    {
        $summary = $this->billingOverview->summary();

        return Inertia::render('Admin/Academy/Billing', [
            'summary' => $summary,
            'planBreakdown' => $summary['plan_breakdown'] ?? [],
            'recentEvents' => $this->billingOverview->recentEvents(),
            'links' => [
                'dashboard' => route('admin.academy.dashboard'),
                'pricing' => route('academy.pricing'),
                'account' => route('academy.billing.account'),
            ],
        ]);
    }

    public function categoriesIndex(): Response
    {
        return $this->renderIndex('categories');
    }

    public function coursesIndex(): Response
    {
        return $this->renderIndex('courses');
    }

    public function coursesCreate(): Response
    {
        return $this->renderForm('courses', new AcademyCourse);
    }

    public function coursesStore(UpsertAcademyCourseRequest $request): RedirectResponse
    {
        $course = $this->saveCourseFromRequest($request);
        $this->cache->clearAll();

        return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created.');
    }

    public function coursesStoreJson(UpsertAcademyCourseRequest $request): RedirectResponse
    {
        $course = $this->saveCourseFromRequest($request);
        $this->cache->clearAll();

        return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created from JSON.');
    }

    public function coursesEdit(AcademyCourse $academyCourse): Response
    {
        return $this->renderForm('courses', $academyCourse);
    }

    public function coursesUpdate(UpsertAcademyCourseRequest $request, AcademyCourse $academyCourse): RedirectResponse
    {
        $this->saveCourseFromRequest($request, $academyCourse);
        $this->cache->clearAll();

        return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])->with('success', 'Academy course updated.');
    }

    public function coursesImportLessons(Request $request, AcademyCourse $academyCourse): RedirectResponse
    {
        $difficultyLevels = array_values(array_filter(array_map('strval', (array) config('academy.difficulty_levels', []))));

        $validated = $request->validate([
            'defaults' => ['nullable', 'array'],
            'defaults.category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
            'defaults.category_slug' => ['nullable', 'string', 'max:180'],
            'defaults.category' => ['nullable', 'string', 'max:180'],
            'defaults.difficulty' => ['nullable', 'string', Rule::in($difficultyLevels)],
            'defaults.access_level' => ['nullable', 'string', Rule::in(['free', 'creator', 'pro'])],
            'defaults.lesson_type' => ['nullable', 'string', 'max:80'],
            'defaults.active' => ['nullable', 'boolean'],
            'defaults.series_name' => ['nullable', 'string', 'max:120'],
            'lessons' => ['required', 'array', 'min:1', 'max:250'],
            'lessons.*.title' => ['required', 'string', 'max:180'],
            'lessons.*.slug' => ['nullable', 'string', 'max:180'],
            'lessons.*.goal' => ['nullable', 'string'],
            'lessons.*.excerpt' => ['nullable', 'string'],
            'lessons.*.category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
            'lessons.*.category_slug' => ['nullable', 'string', 'max:180'],
            'lessons.*.category' => ['nullable', 'string', 'max:180'],
            'lessons.*.difficulty' => ['nullable', 'string', Rule::in($difficultyLevels)],
            'lessons.*.access_level' => ['nullable', 'string', Rule::in(['free', 'creator', 'pro'])],
            'lessons.*.lesson_type' => ['nullable', 'string', 'max:80'],
            'lessons.*.active' => ['nullable', 'boolean'],
            'lessons.*.series_name' => ['nullable', 'string', 'max:120'],
            'lessons.*.tags' => ['nullable', 'array'],
            'lessons.*.tags.*' => ['string', 'max:60'],
        ]);

        $defaults = (array) ($validated['defaults'] ?? []);
        $lessons = array_values((array) ($validated['lessons'] ?? []));

        if ($lessons === []) {
            throw ValidationException::withMessages([
                'lessons' => 'Provide at least one lesson row to import.',
            ]);
        }

        DB::transaction(function () use ($academyCourse, $defaults, $lessons): void {
            $reservedSlugs = AcademyLesson::query()
                ->pluck('slug')
                ->filter(fn ($slug): bool => is_string($slug) && trim($slug) !== '')
                ->map(fn ($slug): string => trim((string) $slug))
                ->values()
                ->all();

            $nextOrder = (int) ((AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->max('order_num') ?? -1) + 1);

            foreach ($lessons as $lessonData) {
                $attributes = $this->buildImportedCourseLessonAttributes($academyCourse, (array) $lessonData, $defaults, $reservedSlugs);

                $lesson = new AcademyLesson;
                $lesson->fill($attributes)->save();

                AcademyCourseLesson::query()->create([
                    'course_id' => $academyCourse->id,
                    'lesson_id' => $lesson->id,
                    'section_id' => null,
                    'order_num' => $nextOrder,
                    'is_required' => true,
                    'access_override' => null,
                    'unlock_after_lesson_id' => null,
                ]);

                $nextOrder++;
            }

            $this->courseLessonOrdering->syncCourse($academyCourse);

            $academyCourse->forceFill([
                'lessons_count_cache' => (int) AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->count(),
            ])->save();
        });

        $this->cache->clearAll();

        return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])
            ->with('success', sprintf('%d lesson%s imported into the course.', count($lessons), count($lessons) === 1 ? '' : 's'));
    }

    public function coursesDestroy(AcademyCourse $academyCourse): RedirectResponse
    {
        $this->deleteStoredLessonCoverIfLocal((string) $academyCourse->cover_image);
        $this->deleteStoredLessonCoverIfLocal((string) $academyCourse->teaser_image);
        $academyCourse->delete();
        $this->cache->clearAll();

        return redirect()->route('admin.academy.courses.index')->with('success', 'Academy course deleted.');
    }

    public function categoriesCreate(): Response
    {
        return $this->renderForm('categories', new AcademyCategory);
    }

    public function categoriesStore(UpsertAcademyCategoryRequest $request): RedirectResponse
    {
        $category = new AcademyCategory;
        $category->fill($request->validated())->save();
        $this->cache->clearAll();

        return redirect()->route('admin.academy.categories.edit', ['academyCategory' => $category])->with('success', 'Academy category created.');
    }

    public function categoriesStoreJson(UpsertAcademyCategoryRequest $request): JsonResponse
    {
        $category = new AcademyCategory;
        $category->fill($request->validated())->save();
        $this->cache->clearAll();

        return response()->json([
            'success' => true,
            'category' => $this->serializeCategoryOption($category),
        ]);
    }

    public function categoriesEdit(AcademyCategory $academyCategory): Response
    {
        return $this->renderForm('categories', $academyCategory);
    }

    public function categoriesUpdate(UpsertAcademyCategoryRequest $request, AcademyCategory $academyCategory): RedirectResponse
    {
        $academyCategory->fill($request->validated())->save();
        $this->cache->clearAll();

        return redirect()->route('admin.academy.categories.edit', ['academyCategory' => $academyCategory])->with('success', 'Academy category updated.');
    }

    public function categoriesDestroy(AcademyCategory $academyCategory): RedirectResponse
    {
        $academyCategory->delete();
        $this->cache->clearAll();

        return redirect()->route('admin.academy.categories.index')->with('success', 'Academy category deleted.');
    }

    public function lessonsIndex(): Response
    {
        return $this->renderIndex('lessons');
    }

    public function lessonsCreate(): Response
    {
        return $this->renderForm('lessons', new AcademyLesson);
    }

    public function lessonsStore(UpsertAcademyLessonRequest $request): RedirectResponse
    {
        $lesson = DB::transaction(function () use ($request): AcademyLesson {
            $lesson = new AcademyLesson;
            $lesson->fill($this->persistLessonAttributes($request))->save();
            $this->syncLessonCourses($lesson, $request->validated('course_ids', []));
            $this->syncLessonBlocks($lesson, $request->validated('blocks', []));

            return $lesson;
        });

        $this->cache->clearAll();

        return redirect()->route('admin.academy.lessons.edit', ['academyLesson' => $lesson])->with('success', 'Academy lesson created.');
    }

    public function lessonsEdit(AcademyLesson $academyLesson): Response
    {
        $academyLesson->load(['blocks.comparisonResults']);

        return $this->renderForm('lessons', $academyLesson);
    }

    public function lessonsUpdate(UpsertAcademyLessonRequest $request, AcademyLesson $academyLesson): RedirectResponse
    {
        DB::transaction(function () use ($request, $academyLesson): void {
            $this->createLessonRevision($academyLesson, $request->user(), 'Before lesson update');
            $academyLesson->fill($this->persistLessonAttributes($request, $academyLesson))->save();
            $this->syncLessonCourses($academyLesson, $request->validated('course_ids', []));
            $this->syncLessonBlocks($academyLesson, $request->validated('blocks', []));
        });

        $this->cache->clearAll();

        return redirect()->route('admin.academy.lessons.edit', ['academyLesson' => $academyLesson])->with('success', 'Academy lesson updated.');
    }

    public function lessonsRestoreRevision(Request $request, AcademyLesson $academyLesson, AcademyLessonRevision $academyLessonRevision): RedirectResponse
    {
        abort_unless((int) $academyLessonRevision->lesson_id === (int) $academyLesson->id, 404);

        $validated = $request->validate([
            'field' => ['nullable', 'string', 'in:category_id,title,slug,lesson_number,course_order,series_name,excerpt,content,difficulty,access_level,lesson_type,cover_image,article_cover_image,tags,video_url,reading_minutes,featured,active,published_at,seo_title,seo_description,course_ids,blocks'],
        ]);

        $field = filled($validated['field'] ?? null) ? (string) $validated['field'] : null;

        DB::transaction(function () use ($academyLesson, $academyLessonRevision, $field, $request): void {
            $note = $field
                ? sprintf('Before restoring %s from revision #%d', $field, $academyLessonRevision->id)
                : sprintf('Before restoring revision #%d', $academyLessonRevision->id);

            $this->createLessonRevision($academyLesson, $request->user(), $note);
            $this->applyLessonRevisionSnapshot($academyLesson, $academyLessonRevision, $field);
        });

        $this->cache->clearAll();

        return redirect()->route('admin.academy.lessons.edit', ['academyLesson' => $academyLesson])->with('success', $field
            ? sprintf('Lesson field "%s" restored from revision.', $field)
            : 'Lesson restored from revision.');
    }

    public function lessonsDestroy(AcademyLesson $academyLesson): RedirectResponse
    {
        $academyLesson->load(['blocks.comparisonResults']);

        foreach ($academyLesson->blocks as $block) {
            foreach ($block->comparisonResults as $result) {
                $this->deleteStoredLessonMediaIfLocal($result->image_path);
                $this->deleteStoredLessonMediaIfLocal($result->thumb_path);
            }
        }

        $this->deleteStoredLessonCoverIfLocal((string) $academyLesson->cover_image);
        $this->deleteStoredLessonCoverIfLocal((string) $academyLesson->article_cover_image);
        $academyLesson->delete();
        $this->cache->clearAll();

        return redirect()->route('admin.academy.lessons.index')->with('success', 'Academy lesson deleted.');
    }

    public function promptsIndex(): Response
    {
        return $this->renderIndex('prompts');
    }

    public function promptsCreate(): Response
    {
        return $this->renderForm('prompts', new AcademyPromptTemplate);
    }

    public function promptsStore(UpsertAcademyPromptTemplateRequest $request): RedirectResponse
    {
        $prompt = new AcademyPromptTemplate;
        $prompt->forceFill($this->persistPromptAttributes($request, $prompt))->save();
        $this->cache->clearAll();

        return redirect()->route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt])->with('success', 'Academy prompt created.');
    }

    public function promptsEdit(AcademyPromptTemplate $academyPromptTemplate): Response
    {
        return $this->renderForm('prompts', $academyPromptTemplate);
    }

    public function promptsUpdate(UpsertAcademyPromptTemplateRequest $request, AcademyPromptTemplate $academyPromptTemplate): RedirectResponse
    {
        $academyPromptTemplate->forceFill($this->persistPromptAttributes($request, $academyPromptTemplate))->save();
        $this->cache->clearAll();

        return redirect()->route('admin.academy.prompts.edit', ['academyPromptTemplate' => $academyPromptTemplate])->with('success', 'Academy prompt updated.');
    }

    public function promptsDestroy(AcademyPromptTemplate $academyPromptTemplate): RedirectResponse
    {
        $academyPromptTemplate->delete();
        $this->cache->clearAll();

        return redirect()->route('admin.academy.prompts.index')->with('success', 'Academy prompt deleted.');
    }

    public function packsIndex(): Response
    {
        return $this->renderIndex('packs');
    }

    public function packsCreate(): Response
    {
        return $this->renderForm('packs', new AcademyPromptPack);
    }

    public function packsStore(UpsertAcademyPromptPackRequest $request): RedirectResponse
    {
        $pack = new AcademyPromptPack;
        $pack->fill(collect($request->validated())->except('prompt_ids')->all())->save();
        $this->syncPackItems($pack, $request->validated('prompt_ids', []));
        $this->cache->clearAll();

        return redirect()->route('admin.academy.packs.edit', ['academyPromptPack' => $pack])->with('success', 'Academy prompt pack created.');
    }

    public function packsEdit(AcademyPromptPack $academyPromptPack): Response
    {
        $academyPromptPack->load('prompts');

        return $this->renderForm('packs', $academyPromptPack);
    }

    public function packsUpdate(UpsertAcademyPromptPackRequest $request, AcademyPromptPack $academyPromptPack): RedirectResponse
    {
        $academyPromptPack->fill(collect($request->validated())->except('prompt_ids')->all())->save();
        $this->syncPackItems($academyPromptPack, $request->validated('prompt_ids', []));
        $this->cache->clearAll();

        return redirect()->route('admin.academy.packs.edit', ['academyPromptPack' => $academyPromptPack])->with('success', 'Academy prompt pack updated.');
    }

    public function packsDestroy(AcademyPromptPack $academyPromptPack): RedirectResponse
    {
        $academyPromptPack->delete();
        $this->cache->clearAll();

        return redirect()->route('admin.academy.packs.index')->with('success', 'Academy prompt pack deleted.');
    }

    public function challengesIndex(): Response
    {
        return $this->renderIndex('challenges');
    }

    public function challengesCreate(): Response
    {
        return $this->renderForm('challenges', new AcademyChallenge);
    }

    public function challengesStore(UpsertAcademyChallengeRequest $request): RedirectResponse
    {
        $challenge = new AcademyChallenge;
        $challenge->fill($request->validated())->save();
        $this->cache->clearAll();

        return redirect()->route('admin.academy.challenges.edit', ['academyChallenge' => $challenge])->with('success', 'Academy challenge created.');
    }

    public function challengesEdit(AcademyChallenge $academyChallenge): Response
    {
        return $this->renderForm('challenges', $academyChallenge);
    }

    public function challengesUpdate(UpsertAcademyChallengeRequest $request, AcademyChallenge $academyChallenge): RedirectResponse
    {
        $academyChallenge->fill($request->validated())->save();
        $this->cache->clearAll();

        return redirect()->route('admin.academy.challenges.edit', ['academyChallenge' => $academyChallenge])->with('success', 'Academy challenge updated.');
    }

    public function challengesDestroy(AcademyChallenge $academyChallenge): RedirectResponse
    {
        $academyChallenge->delete();
        $this->cache->clearAll();

        return redirect()->route('admin.academy.challenges.index')->with('success', 'Academy challenge deleted.');
    }

    public function badgesIndex(): Response
    {
        return $this->renderIndex('badges');
    }

    public function badgesCreate(): Response
    {
        return $this->renderForm('badges', new AcademyBadge);
    }

    public function badgesStore(UpsertAcademyBadgeRequest $request): RedirectResponse
    {
        $badge = new AcademyBadge;
        $badge->fill($request->validated())->save();

        return redirect()->route('admin.academy.badges.edit', ['academyBadge' => $badge])->with('success', 'Academy badge created.');
    }

    public function badgesEdit(AcademyBadge $academyBadge): Response
    {
        return $this->renderForm('badges', $academyBadge);
    }

    public function badgesUpdate(UpsertAcademyBadgeRequest $request, AcademyBadge $academyBadge): RedirectResponse
    {
        $academyBadge->fill($request->validated())->save();

        return redirect()->route('admin.academy.badges.edit', ['academyBadge' => $academyBadge])->with('success', 'Academy badge updated.');
    }

    public function badgesDestroy(AcademyBadge $academyBadge): RedirectResponse
    {
        $academyBadge->delete();

        return redirect()->route('admin.academy.badges.index')->with('success', 'Academy badge deleted.');
    }

    public function submissionsIndex(Request $request): Response
    {
        $submissions = AcademyChallengeSubmission::query()
            ->with(['challenge', 'artwork', 'user'])
            ->latest('submitted_at')
            ->paginate(25)
            ->withQueryString();

        $submissions->getCollection()->transform(fn (AcademyChallengeSubmission $submission): array => [
            'id' => (int) $submission->id,
            'moderation_status' => (string) $submission->moderation_status,
            'submitted_at' => $submission->submitted_at?->toISOString(),
            'ai_tool_used' => (string) ($submission->ai_tool_used ?? ''),
            'prompt_used' => (string) ($submission->prompt_used ?? ''),
            'workflow_notes' => (string) ($submission->workflow_notes ?? ''),
            'challenge' => $submission->challenge ? [
                'title' => (string) $submission->challenge->title,
                'slug' => (string) $submission->challenge->slug,
            ] : null,
            'user' => $submission->user ? [
                'name' => (string) $submission->user->name,
                'username' => (string) ($submission->user->username ?? ''),
            ] : null,
            'artwork' => $submission->artwork ? [
                'id' => (int) $submission->artwork->id,
                'title' => (string) ($submission->artwork->title ?? 'Untitled artwork'),
                'thumb_url' => $submission->artwork->thumbUrl('sm'),
            ] : null,
            'approve_url' => route('admin.academy.submissions.approve', ['academyChallengeSubmission' => $submission]),
            'reject_url' => route('admin.academy.submissions.reject', ['academyChallengeSubmission' => $submission]),
        ]);

        return Inertia::render('Admin/Academy/Submissions', [
            'submissions' => $submissions,
        ]);
    }

    public function approveSubmission(AcademyChallengeSubmission $academyChallengeSubmission): RedirectResponse
    {
        $academyChallengeSubmission->forceFill(['moderation_status' => 'approved'])->save();

        return back()->with('success', 'Challenge submission approved.');
    }

    public function rejectSubmission(AcademyChallengeSubmission $academyChallengeSubmission): RedirectResponse
    {
        $academyChallengeSubmission->forceFill(['moderation_status' => 'rejected'])->save();

        return back()->with('success', 'Challenge submission rejected.');
    }

    private function renderIndex(string $resource): Response
    {
        $meta = $this->resourceMeta($resource);
        $search = trim((string) request()->query('search', ''));
        $query = $meta['model']::query();

        if ($resource === 'courses') {
            $query->withCount('courseLessons');

            if ($search !== '') {
                $query->where(function ($builder) use ($search): void {
                    $like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';

                    $builder->where('title', 'like', $like)
                        ->orWhere('slug', 'like', $like)
                        ->orWhere('subtitle', 'like', $like)
                        ->orWhere('excerpt', 'like', $like)
                        ->orWhere('description', 'like', $like);
                });
            }

            $query->orderByDesc('is_featured')
                ->orderBy('order_num')
                ->orderByDesc('updated_at')
                ->orderByDesc('id');
        } else {
            $query->latest('updated_at');
        }

        if ($resource === 'prompts') {
            $query->with('category');
        }

        if ($resource === 'lessons') {
            $query->with('courses:id,title');
        }

        $items = $query->paginate(25)->withQueryString();
        $items->getCollection()->transform(fn (Model $model): array => $this->serializeIndexItem($resource, $model));

        return Inertia::render('Admin/Academy/CrudIndex', [
            'resource' => $resource,
            'title' => $meta['title'],
            'subtitle' => $meta['subtitle'],
            'items' => $items,
            'columns' => $meta['columns'],
            'createUrl' => route($meta['route_base'].'.create'),
            'filters' => [
                'search' => $search,
            ],
            'summary' => $resource === 'courses' ? [
                'total' => (int) $items->total(),
                'published' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
                    $like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';

                    $builder->where(function ($inner) use ($like): void {
                        $inner->where('title', 'like', $like)
                            ->orWhere('slug', 'like', $like)
                            ->orWhere('subtitle', 'like', $like)
                            ->orWhere('excerpt', 'like', $like)
                            ->orWhere('description', 'like', $like);
                    });
                })->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
                'featured' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
                    $like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';

                    $builder->where(function ($inner) use ($like): void {
                        $inner->where('title', 'like', $like)
                            ->orWhere('slug', 'like', $like)
                            ->orWhere('subtitle', 'like', $like)
                            ->orWhere('excerpt', 'like', $like)
                            ->orWhere('description', 'like', $like);
                    });
                })->where('is_featured', true)->count(),
                'drafts' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
                    $like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';

                    $builder->where(function ($inner) use ($like): void {
                        $inner->where('title', 'like', $like)
                            ->orWhere('slug', 'like', $like)
                            ->orWhere('subtitle', 'like', $like)
                            ->orWhere('excerpt', 'like', $like)
                            ->orWhere('description', 'like', $like);
                    });
                })->where('status', AcademyCourse::STATUS_DRAFT)->count(),
            ] : null,
        ]);
    }

    private function renderForm(string $resource, Model $record): Response
    {
        $meta = $this->resourceMeta($resource);

        return Inertia::render('Admin/Academy/CrudForm', [
            'resource' => $resource,
            'title' => $record->exists ? 'Edit '.$meta['singular'] : 'Create '.$meta['singular'],
            'subtitle' => $meta['subtitle'],
            'fields' => $meta['fields'],
            'record' => $this->serializeFormRecord($resource, $record),
            'submitUrl' => $record->exists ? route($meta['route_base'].'.update', $this->routeParams($resource, $record)) : route($meta['route_base'].'.store'),
            'indexUrl' => route($meta['route_base'].'.index'),
            'destroyUrl' => $record->exists ? route($meta['route_base'].'.destroy', $this->routeParams($resource, $record)) : null,
            'method' => $record->exists ? 'patch' : 'post',
            'editorContext' => $this->formEditorContext($resource, $record),
        ]);
    }

    private function formEditorContext(string $resource, Model $record): array
    {
        if ($resource === 'courses') {
            return [
                'links' => array_filter([
                    'builder' => $record->exists ? route('admin.academy.courses.builder.edit', ['academyCourse' => $record]) : null,
                    'preview' => $record->exists ? route('academy.courses.show', ['course' => $record->slug]) : null,
                ]),
                'coverUploadUrl' => route('api.studio.academy.lessons.media.upload'),
                'coverDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
                'bodyMediaUploadUrl' => route('api.studio.academy.lessons.media.upload'),
                'bodyMediaDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
                'bodyMediaAssetsUrl' => route('api.studio.academy.lessons.media.assets'),
                'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
                'outlineSummary' => $record instanceof AcademyCourse && $record->exists
                    ? $this->serializeCourseOutlineSummary($record)
                    : null,
                'courseSections' => $record instanceof AcademyCourse && $record->exists
                    ? $this->serializeCourseEditorSections($record)
                    : [],
                'courseLessons' => $record instanceof AcademyCourse && $record->exists
                    ? $this->serializeCourseEditorLessons($record)
                    : [],
                'availableLessons' => $record instanceof AcademyCourse && $record->exists
                    ? $this->serializeCourseAvailableLessons($record)
                    : [],
                'attachLessonUrl' => $record instanceof AcademyCourse && $record->exists
                    ? route('admin.academy.courses.lessons.attach', ['academyCourse' => $record])
                    : null,
                'importLessonsUrl' => $record instanceof AcademyCourse && $record->exists
                    ? route('admin.academy.courses.lessons.import', ['academyCourse' => $record])
                    : null,
                'sectionStoreUrl' => $record instanceof AcademyCourse && $record->exists
                    ? route('admin.academy.courses.sections.store', ['academyCourse' => $record])
                    : null,
                'reorderUrl' => $record instanceof AcademyCourse && $record->exists
                    ? route('admin.academy.courses.reorder', ['academyCourse' => $record])
                    : null,
                'courseImportUrl' => $record instanceof AcademyCourse && ! $record->exists
                    ? route('admin.academy.courses.import-json')
                    : null,
                'lessonCategoryOptions' => $this->categoriesForEditor('lesson'),
            ];
        }

        if ($resource === 'prompts') {
            return [
                'links' => array_filter([
                    'preview' => $record->exists ? route('academy.prompts.show', ['slug' => $record->slug]) : null,
                ]),
                'comparisonMediaUploadUrl' => route('api.studio.academy.lessons.media.upload'),
                'comparisonMediaDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
                'comparisonMediaAssetsUrl' => route('api.studio.academy.lessons.media.assets'),
                'comparisonMediaBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
                'comparisonCodeLists' => [
                    'providers' => array_values(array_filter(array_map('strval', (array) config('academy.prompt_comparison.providers', [])))),
                    'models' => array_values(array_filter(array_map('strval', (array) config('academy.prompt_comparison.models', [])))),
                ],
            ];
        }

        if ($resource === 'challenges') {
            return [
                'links' => array_filter([
                    'preview' => $record->exists ? route('academy.challenges.show', ['slug' => $record->slug]) : null,
                ]),
                'coverUploadUrl' => route('api.studio.academy.lessons.media.upload'),
                'coverDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
                'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
            ];
        }

        if ($resource !== 'lessons') {
            return [];
        }

        return [
            'currentLessonId' => $record instanceof AcademyLesson && $record->exists ? (int) $record->id : null,
            'currentLessonTitle' => $record instanceof AcademyLesson && $record->exists ? (string) $record->title : '',
            'coverUploadUrl' => route('api.studio.academy.lessons.media.upload'),
            'coverDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
            'bodyMediaUploadUrl' => route('api.studio.academy.lessons.media.upload'),
            'bodyMediaDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
            'bodyMediaAssetsUrl' => route('api.studio.academy.lessons.media.assets'),
            'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
            'numbering' => $this->serializeLessonNumberingContext($record instanceof AcademyLesson ? $record : null),
            'revisions' => $record instanceof AcademyLesson && $record->exists
                ? $this->serializeLessonRevisions($record)
                : [],
            'categories' => AcademyCategory::query()
                ->where('type', 'lesson')
                ->orderBy('order_num')
                ->orderBy('name')
                ->get()
                ->map(fn (AcademyCategory $category): array => $this->serializeCategoryOption($category))
                ->values()
                ->all(),
            'courses' => $this->serializeLessonEditorCourses($record instanceof AcademyLesson ? $record : null),
            'categoryStoreUrl' => route('api.academy.categories.store'),
            'categoryManageUrl' => route('admin.academy.categories.index'),
        ];
    }

    private function resourceMeta(string $resource): array
    {
        return match ($resource) {
            'courses' => [
                'model' => AcademyCourse::class,
                'title' => 'Academy Courses',
                'singular' => 'course',
                'subtitle' => 'Build structured learning paths from reusable Academy lessons.',
                'route_base' => 'admin.academy.courses',
                'columns' => ['title', 'difficulty', 'access_level', 'status', 'is_featured'],
                'fields' => [
                    ['name' => 'title', 'label' => 'Title', 'type' => 'text'],
                    ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
                    ['name' => 'subtitle', 'label' => 'Subtitle', 'type' => 'text'],
                    ['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea', 'rows' => 4],
                    ['name' => 'description', 'label' => 'Description', 'type' => 'textarea', 'rows' => 8],
                    ['name' => 'cover_image', 'label' => 'Cover Image', 'type' => 'text'],
                    ['name' => 'teaser_image', 'label' => 'Teaser Image', 'type' => 'text'],
                    ['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->courseAccessOptions()],
                    ['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->courseDifficultyOptions()],
                    ['name' => 'status', 'label' => 'Status', 'type' => 'select', 'options' => $this->courseStatusOptions()],
                    ['name' => 'order_num', 'label' => 'Order', 'type' => 'number'],
                    ['name' => 'estimated_minutes', 'label' => 'Estimated Minutes', 'type' => 'number'],
                    ['name' => 'published_at', 'label' => 'Published At', 'type' => 'datetime-local'],
                    ['name' => 'seo_title', 'label' => 'SEO Title', 'type' => 'text'],
                    ['name' => 'seo_description', 'label' => 'SEO Description', 'type' => 'textarea', 'rows' => 4],
                    ['name' => 'meta_keywords', 'label' => 'Meta Keywords', 'type' => 'textarea', 'rows' => 3],
                    ['name' => 'og_title', 'label' => 'OpenGraph Title', 'type' => 'text'],
                    ['name' => 'og_description', 'label' => 'OpenGraph Description', 'type' => 'textarea', 'rows' => 4],
                    ['name' => 'og_image', 'label' => 'OpenGraph Image', 'type' => 'text'],
                    ['name' => 'is_featured', 'label' => 'Featured', 'type' => 'checkbox'],
                ],
            ],
            'categories' => [
                'model' => AcademyCategory::class,
                'title' => 'Academy Categories',
                'singular' => 'category',
                'subtitle' => 'Manage lesson, prompt, pack, and challenge categories.',
                'route_base' => 'admin.academy.categories',
                'columns' => ['name', 'type', 'slug', 'active'],
                'fields' => [
                    ['name' => 'type', 'label' => 'Type', 'type' => 'select', 'options' => [['value' => 'lesson', 'label' => 'Lesson'], ['value' => 'prompt', 'label' => 'Prompt'], ['value' => 'challenge', 'label' => 'Challenge'], ['value' => 'pack', 'label' => 'Pack']]],
                    ['name' => 'name', 'label' => 'Name', 'type' => 'text'],
                    ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
                    ['name' => 'description', 'label' => 'Description', 'type' => 'textarea'],
                    ['name' => 'icon', 'label' => 'Icon', 'type' => 'text'],
                    ['name' => 'order_num', 'label' => 'Order', 'type' => 'number'],
                    ['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
                ],
            ],
            'lessons' => [
                'model' => AcademyLesson::class,
                'title' => 'Academy Lessons',
                'singular' => 'lesson',
                'subtitle' => 'Create and publish Academy lessons.',
                'route_base' => 'admin.academy.lessons',
                'columns' => ['title', 'course_names', 'course_order', 'difficulty', 'access_level', 'active'],
                'fields' => [
                    ['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('lesson')],
                    ['name' => 'course_ids', 'label' => 'Courses', 'type' => 'multiselect', 'options' => $this->courseOptions()],
                    ['name' => 'title', 'label' => 'Title', 'type' => 'text'],
                    ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
                    ['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea'],
                    ['name' => 'content', 'label' => 'Content', 'type' => 'textarea'],
                    ['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()],
                    ['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
                    ['name' => 'lesson_type', 'label' => 'Lesson Type', 'type' => 'text'],
                    ['name' => 'cover_image', 'label' => 'Cover Image', 'type' => 'text'],
                    ['name' => 'tags', 'label' => 'Tags', 'type' => 'csv'],
                    ['name' => 'video_url', 'label' => 'Video URL', 'type' => 'text'],
                    ['name' => 'reading_minutes', 'label' => 'Reading Minutes', 'type' => 'number'],
                    ['name' => 'published_at', 'label' => 'Published At', 'type' => 'datetime-local'],
                    ['name' => 'seo_title', 'label' => 'SEO Title', 'type' => 'text'],
                    ['name' => 'seo_description', 'label' => 'SEO Description', 'type' => 'textarea'],
                    ['name' => 'featured', 'label' => 'Featured', 'type' => 'checkbox'],
                    ['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
                ],
            ],
            'prompts' => [
                'model' => AcademyPromptTemplate::class,
                'title' => 'Academy Prompt Templates',
                'singular' => 'prompt template',
                'subtitle' => 'Manage prompt previews, premium prompts, and prompt of the week.',
                'route_base' => 'admin.academy.prompts',
                'columns' => ['title', 'difficulty', 'access_level', 'prompt_of_week', 'active'],
                'fields' => [
                    ['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('prompt')],
                    ['name' => 'title', 'label' => 'Title', 'type' => 'text'],
                    ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
                    ['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea'],
                    ['name' => 'prompt', 'label' => 'Prompt', 'type' => 'textarea'],
                    ['name' => 'negative_prompt', 'label' => 'Negative Prompt', 'type' => 'textarea'],
                    ['name' => 'usage_notes', 'label' => 'Usage Notes', 'type' => 'textarea'],
                    ['name' => 'workflow_notes', 'label' => 'Workflow Notes', 'type' => 'textarea'],
                    ['name' => 'documentation', 'label' => 'Documentation JSON', 'type' => 'json'],
                    ['name' => 'placeholders', 'label' => 'Placeholders JSON', 'type' => 'json'],
                    ['name' => 'helper_prompts', 'label' => 'Helper Prompts JSON', 'type' => 'json'],
                    ['name' => 'prompt_variants', 'label' => 'Prompt Variants JSON', 'type' => 'json'],
                    ['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()],
                    ['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
                    ['name' => 'aspect_ratio', 'label' => 'Aspect Ratio', 'type' => 'text'],
                    ['name' => 'tags', 'label' => 'Tags', 'type' => 'csv'],
                    ['name' => 'preview_image', 'label' => 'Preview Image URL', 'type' => 'text'],
                    ['name' => 'published_at', 'label' => 'Published At', 'type' => 'datetime-local'],
                    ['name' => 'seo_title', 'label' => 'SEO Title', 'type' => 'text'],
                    ['name' => 'seo_description', 'label' => 'SEO Description', 'type' => 'textarea'],
                    ['name' => 'featured', 'label' => 'Featured', 'type' => 'checkbox'],
                    ['name' => 'prompt_of_week', 'label' => 'Prompt Of Week', 'type' => 'checkbox'],
                    ['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
                ],
            ],
            'packs' => [
                'model' => AcademyPromptPack::class,
                'title' => 'Academy Prompt Packs',
                'singular' => 'prompt pack',
                'subtitle' => 'Bundle Academy prompts into reusable packs.',
                'route_base' => 'admin.academy.packs',
                'columns' => ['title', 'access_level', 'featured', 'active'],
                'fields' => [
                    ['name' => 'title', 'label' => 'Title', 'type' => 'text'],
                    ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
                    ['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea'],
                    ['name' => 'description', 'label' => 'Description', 'type' => 'textarea'],
                    ['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
                    ['name' => 'one_time_price_cents', 'label' => 'One-time Price (cents)', 'type' => 'number'],
                    ['name' => 'currency', 'label' => 'Currency', 'type' => 'text'],
                    ['name' => 'cover_image', 'label' => 'Cover Image', 'type' => 'text'],
                    ['name' => 'tags', 'label' => 'Tags', 'type' => 'csv'],
                    ['name' => 'prompt_ids', 'label' => 'Prompt Templates', 'type' => 'multiselect', 'options' => $this->promptOptions()],
                    ['name' => 'published_at', 'label' => 'Published At', 'type' => 'datetime-local'],
                    ['name' => 'featured', 'label' => 'Featured', 'type' => 'checkbox'],
                    ['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
                ],
            ],
            'challenges' => [
                'model' => AcademyChallenge::class,
                'title' => 'Academy Challenges',
                'singular' => 'challenge',
                'subtitle' => 'Create and moderate Academy challenge briefs.',
                'route_base' => 'admin.academy.challenges',
                'columns' => ['title', 'status', 'access_level', 'featured', 'active'],
                'fields' => [
                    ['name' => 'title', 'label' => 'Title', 'type' => 'text'],
                    ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
                    ['name' => 'excerpt', 'label' => 'Excerpt', 'type' => 'textarea'],
                    ['name' => 'description', 'label' => 'Description', 'type' => 'textarea'],
                    ['name' => 'brief', 'label' => 'Brief', 'type' => 'textarea'],
                    ['name' => 'rules', 'label' => 'Rules', 'type' => 'textarea'],
                    ['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
                    ['name' => 'status', 'label' => 'Status', 'type' => 'select', 'options' => [['value' => 'draft', 'label' => 'Draft'], ['value' => 'scheduled', 'label' => 'Scheduled'], ['value' => 'active', 'label' => 'Active'], ['value' => 'voting', 'label' => 'Voting'], ['value' => 'completed', 'label' => 'Completed'], ['value' => 'archived', 'label' => 'Archived']]],
                    ['name' => 'starts_at', 'label' => 'Starts At', 'type' => 'datetime-local'],
                    ['name' => 'ends_at', 'label' => 'Ends At', 'type' => 'datetime-local'],
                    ['name' => 'voting_starts_at', 'label' => 'Voting Starts At', 'type' => 'datetime-local'],
                    ['name' => 'voting_ends_at', 'label' => 'Voting Ends At', 'type' => 'datetime-local'],
                    ['name' => 'cover_image', 'label' => 'Cover Image', 'type' => 'text'],
                    ['name' => 'prize_text', 'label' => 'Prize Text', 'type' => 'text'],
                    ['name' => 'required_tags', 'label' => 'Required Tags', 'type' => 'csv'],
                    ['name' => 'allowed_categories', 'label' => 'Allowed Categories', 'type' => 'csv'],
                    ['name' => 'featured', 'label' => 'Featured', 'type' => 'checkbox'],
                    ['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
                ],
            ],
            'badges' => [
                'model' => AcademyBadge::class,
                'title' => 'Academy Badges',
                'singular' => 'badge',
                'subtitle' => 'Define Academy plan and achievement badges.',
                'route_base' => 'admin.academy.badges',
                'columns' => ['name', 'badge_type', 'slug', 'active'],
                'fields' => [
                    ['name' => 'name', 'label' => 'Name', 'type' => 'text'],
                    ['name' => 'slug', 'label' => 'Slug', 'type' => 'text'],
                    ['name' => 'description', 'label' => 'Description', 'type' => 'textarea'],
                    ['name' => 'icon', 'label' => 'Icon', 'type' => 'text'],
                    ['name' => 'badge_type', 'label' => 'Badge Type', 'type' => 'text'],
                    ['name' => 'rules', 'label' => 'Rules JSON', 'type' => 'json'],
                    ['name' => 'active', 'label' => 'Active', 'type' => 'checkbox'],
                ],
            ],
            default => throw new \InvalidArgumentException('Unknown Academy resource ['.$resource.'].'),
        };
    }

    private function serializeIndexItem(string $resource, Model $model): array
    {
        return match ($resource) {
            'courses' => [
                'id' => (int) $model->id,
                'title' => (string) $model->title,
                'slug' => (string) $model->slug,
                'subtitle' => (string) ($model->subtitle ?? ''),
                'excerpt' => (string) ($model->excerpt ?? ''),
                'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($model->cover_image ?? '')),
                'lessons_count' => (int) ($model->lessons_count_cache ?? $model->course_lessons_count ?? 0),
                'difficulty' => (string) $model->difficulty,
                'access_level' => (string) $model->access_level,
                'status' => (string) $model->status,
                'is_featured' => (bool) $model->is_featured,
                'published_at' => optional($model->published_at)->toIso8601String(),
                'updated_at' => optional($model->updated_at)->toIso8601String(),
                'edit_url' => route('admin.academy.courses.edit', ['academyCourse' => $model]),
                'destroy_url' => route('admin.academy.courses.destroy', ['academyCourse' => $model]),
                'builder_url' => route('admin.academy.courses.builder.edit', ['academyCourse' => $model]),
            ],
            'categories' => [
                'id' => (int) $model->id,
                'name' => (string) $model->name,
                'type' => (string) $model->type,
                'slug' => (string) $model->slug,
                'active' => (bool) $model->active,
                'edit_url' => route('admin.academy.categories.edit', ['academyCategory' => $model]),
                'destroy_url' => route('admin.academy.categories.destroy', ['academyCategory' => $model]),
            ],
            'lessons' => [
                'id' => (int) $model->id,
                'title' => (string) $model->title,
                'course_names' => $model->courses->pluck('title')->filter()->values()->all(),
                'course_order' => $model->course_order,
                'difficulty' => (string) $model->difficulty,
                'access_level' => (string) $model->access_level,
                'featured' => (bool) $model->featured,
                'active' => (bool) $model->active,
                'edit_url' => route('admin.academy.lessons.edit', ['academyLesson' => $model]),
                'destroy_url' => route('admin.academy.lessons.destroy', ['academyLesson' => $model]),
            ],
            'prompts' => [
                'id' => (int) $model->id,
                'title' => (string) $model->title,
                'slug' => (string) $model->slug,
                'excerpt' => (string) ($model->excerpt ?? ''),
                'difficulty' => (string) $model->difficulty,
                'access_level' => (string) $model->access_level,
                'category_name' => (string) ($model->category?->name ?? ''),
                'aspect_ratio' => (string) ($model->aspect_ratio ?? ''),
                'featured' => (bool) $model->featured,
                'prompt_of_week' => (bool) $model->prompt_of_week,
                'active' => (bool) $model->active,
                'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($model->preview_image ?? '')),
                'comparisons_count' => count($this->serializePromptToolNotes((array) ($model->tool_notes ?? []))),
                'tags' => array_values(array_filter(array_map(static fn ($tag): string => trim((string) $tag), (array) ($model->tags ?? [])))),
                'updated_at' => optional($model->updated_at)->toIso8601String(),
                'preview_url' => route('academy.prompts.show', ['slug' => $model->slug]),
                'edit_url' => route('admin.academy.prompts.edit', ['academyPromptTemplate' => $model]),
                'destroy_url' => route('admin.academy.prompts.destroy', ['academyPromptTemplate' => $model]),
            ],
            'packs' => [
                'id' => (int) $model->id,
                'title' => (string) $model->title,
                'access_level' => (string) $model->access_level,
                'featured' => (bool) $model->featured,
                'active' => (bool) $model->active,
                'edit_url' => route('admin.academy.packs.edit', ['academyPromptPack' => $model]),
                'destroy_url' => route('admin.academy.packs.destroy', ['academyPromptPack' => $model]),
            ],
            'challenges' => [
                'id' => (int) $model->id,
                'title' => (string) $model->title,
                'status' => (string) $model->status,
                'access_level' => (string) $model->access_level,
                'featured' => (bool) $model->featured,
                'active' => (bool) $model->active,
                'edit_url' => route('admin.academy.challenges.edit', ['academyChallenge' => $model]),
                'destroy_url' => route('admin.academy.challenges.destroy', ['academyChallenge' => $model]),
            ],
            'badges' => [
                'id' => (int) $model->id,
                'name' => (string) $model->name,
                'badge_type' => (string) $model->badge_type,
                'slug' => (string) $model->slug,
                'active' => (bool) $model->active,
                'edit_url' => route('admin.academy.badges.edit', ['academyBadge' => $model]),
                'destroy_url' => route('admin.academy.badges.destroy', ['academyBadge' => $model]),
            ],
            default => [],
        };
    }

    private function serializeFormRecord(string $resource, Model $record): array
    {
        return match ($resource) {
            'courses' => [
                'title' => (string) ($record->title ?? ''),
                'slug' => (string) ($record->slug ?? ''),
                'subtitle' => (string) ($record->subtitle ?? ''),
                'excerpt' => (string) ($record->excerpt ?? ''),
                'description' => (string) ($record->description ?? ''),
                'cover_image' => (string) ($record->cover_image ?? ''),
                'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->cover_image ?? '')),
                'teaser_image' => (string) ($record->teaser_image ?? ''),
                'teaser_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->teaser_image ?? '')),
                'access_level' => (string) ($record->access_level ?? 'free'),
                'difficulty' => (string) ($record->difficulty ?? 'beginner'),
                'status' => (string) ($record->status ?? 'draft'),
                'order_num' => (int) ($record->order_num ?? 0),
                'estimated_minutes' => $record->estimated_minutes,
                'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'),
                'seo_title' => (string) ($record->seo_title ?? ''),
                'seo_description' => (string) ($record->seo_description ?? ''),
                'meta_keywords' => (string) ($record->meta_keywords ?? ''),
                'og_title' => (string) ($record->og_title ?? ''),
                'og_description' => (string) ($record->og_description ?? ''),
                'og_image' => (string) ($record->og_image ?? ''),
                'is_featured' => (bool) ($record->is_featured ?? false),
            ],
            'categories' => [
                'type' => (string) ($record->type ?? 'lesson'),
                'name' => (string) ($record->name ?? ''),
                'slug' => (string) ($record->slug ?? ''),
                'description' => (string) ($record->description ?? ''),
                'icon' => (string) ($record->icon ?? ''),
                'order_num' => (int) ($record->order_num ?? 0),
                'active' => (bool) ($record->active ?? true),
            ],
            'lessons' => [
                'category_id' => $record->category_id,
                'course_ids' => $record instanceof AcademyLesson
                    ? $record->courses()->pluck('academy_courses.id')->map(static fn ($id): string => (string) $id)->values()->all()
                    : [],
                'title' => (string) ($record->title ?? ''),
                'slug' => (string) ($record->slug ?? ''),
                'lesson_number' => $record->lesson_number,
                'course_order' => $record->course_order,
                'series_name' => (string) ($record->series_name ?? ''),
                'excerpt' => (string) ($record->excerpt ?? ''),
                'content' => (string) ($record->content ?? ''),
                'content_markdown' => (string) ($record->content_markdown ?? ''),
                'difficulty' => (string) ($record->difficulty ?? 'beginner'),
                'access_level' => (string) ($record->access_level ?? 'free'),
                'lesson_type' => (string) ($record->lesson_type ?? 'article'),
                'cover_image' => (string) ($record->cover_image ?? ''),
                'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->cover_image ?? '')),
                'article_cover_image' => (string) ($record->article_cover_image ?? ''),
                'article_cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->article_cover_image ?? '')),
                'tags' => implode(', ', (array) ($record->tags ?? [])),
                'video_url' => (string) ($record->video_url ?? ''),
                'reading_minutes' => (int) ($record->reading_minutes ?? 5),
                'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'),
                'seo_title' => (string) ($record->seo_title ?? ''),
                'seo_description' => (string) ($record->seo_description ?? ''),
                'featured' => (bool) ($record->featured ?? false),
                'active' => (bool) ($record->active ?? true),
                'blocks' => $record instanceof AcademyLesson
                    ? $record->blocks->map(fn (AcademyLessonBlock $block): array => $this->serializeLessonBlock($block))->values()->all()
                    : [],
            ],
            'prompts' => [
                'category_id' => $record->category_id,
                'title' => (string) ($record->title ?? ''),
                'slug' => (string) ($record->slug ?? ''),
                'excerpt' => (string) ($record->excerpt ?? ''),
                'prompt' => (string) ($record->prompt ?? ''),
                'negative_prompt' => (string) ($record->negative_prompt ?? ''),
                'usage_notes' => (string) ($record->usage_notes ?? ''),
                'workflow_notes' => (string) ($record->workflow_notes ?? ''),
                'documentation' => $this->encodePrettyJsonForForm($record->documentation),
                'placeholders' => $this->encodePrettyJsonForForm($record->placeholders),
                'helper_prompts' => $this->encodePrettyJsonForForm($record->helper_prompts),
                'prompt_variants' => $this->encodePrettyJsonForForm($record->prompt_variants),
                'difficulty' => (string) ($record->difficulty ?? 'beginner'),
                'access_level' => (string) ($record->access_level ?? 'free'),
                'aspect_ratio' => (string) ($record->aspect_ratio ?? ''),
                'tags' => implode(', ', (array) ($record->tags ?? [])),
                'tool_notes' => $this->serializePromptToolNotes((array) ($record->tool_notes ?? [])),
                'preview_image' => (string) ($record->preview_image ?? ''),
                'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($record->preview_image ?? '')),
                'preview_image_file' => null,
                'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'),
                'seo_title' => (string) ($record->seo_title ?? ''),
                'seo_description' => (string) ($record->seo_description ?? ''),
                'featured' => (bool) ($record->featured ?? false),
                'prompt_of_week' => (bool) ($record->prompt_of_week ?? false),
                'active' => (bool) ($record->active ?? true),
            ],
            'packs' => [
                'title' => (string) ($record->title ?? ''),
                'slug' => (string) ($record->slug ?? ''),
                'excerpt' => (string) ($record->excerpt ?? ''),
                'description' => (string) ($record->description ?? ''),
                'access_level' => (string) ($record->access_level ?? 'creator'),
                'one_time_price_cents' => $record->one_time_price_cents,
                'currency' => (string) ($record->currency ?? 'EUR'),
                'cover_image' => (string) ($record->cover_image ?? ''),
                'tags' => implode(', ', (array) ($record->tags ?? [])),
                'prompt_ids' => $record instanceof AcademyPromptPack ? $record->prompts->pluck('id')->all() : [],
                'published_at' => optional($record->published_at)?->format('Y-m-d\TH:i'),
                'featured' => (bool) ($record->featured ?? false),
                'active' => (bool) ($record->active ?? true),
            ],
            'challenges' => [
                'title' => (string) ($record->title ?? ''),
                'slug' => (string) ($record->slug ?? ''),
                'excerpt' => (string) ($record->excerpt ?? ''),
                'description' => (string) ($record->description ?? ''),
                'brief' => (string) ($record->brief ?? ''),
                'rules' => (string) ($record->rules ?? ''),
                'access_level' => (string) ($record->access_level ?? 'free'),
                'status' => (string) ($record->status ?? 'draft'),
                'starts_at' => optional($record->starts_at)?->format('Y-m-d\TH:i'),
                'ends_at' => optional($record->ends_at)?->format('Y-m-d\TH:i'),
                'voting_starts_at' => optional($record->voting_starts_at)?->format('Y-m-d\TH:i'),
                'voting_ends_at' => optional($record->voting_ends_at)?->format('Y-m-d\TH:i'),
                'cover_image' => (string) ($record->cover_image ?? ''),
                'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->cover_image ?? '')),
                'prize_text' => (string) ($record->prize_text ?? ''),
                'required_tags' => implode(', ', (array) ($record->required_tags ?? [])),
                'allowed_categories' => implode(', ', (array) ($record->allowed_categories ?? [])),
                'featured' => (bool) ($record->featured ?? false),
                'active' => (bool) ($record->active ?? true),
            ],
            'badges' => [
                'name' => (string) ($record->name ?? ''),
                'slug' => (string) ($record->slug ?? ''),
                'description' => (string) ($record->description ?? ''),
                'icon' => (string) ($record->icon ?? ''),
                'badge_type' => (string) ($record->badge_type ?? 'achievement'),
                'rules' => json_encode((array) ($record->rules ?? []), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
                'active' => (bool) ($record->active ?? true),
            ],
            default => [],
        };
    }

    /**
     * @return array<string, mixed>
     */
    private function persistLessonAttributes(UpsertAcademyLessonRequest $request, ?AcademyLesson $lesson = null): array
    {
        $validated = $request->validated();
        unset($validated['course_ids']);
        unset($validated['blocks']);
        $contentSource = in_array((string) ($validated['content_source'] ?? ''), ['html', 'markdown'], true)
            ? (string) $validated['content_source']
            : ((string) ($lesson?->content_markdown ?? '') !== '' ? 'markdown' : 'html');
        $contentMarkdown = $this->nullableTrimmedString($validated['content_markdown'] ?? null);
        $contentHtml = $this->nullableTrimmedString($validated['content'] ?? null);
        $currentCoverImage = trim((string) ($lesson?->cover_image ?? ''));
        $currentArticleCoverImage = trim((string) ($lesson?->article_cover_image ?? ''));
        $nextCoverImage = filled($validated['cover_image'] ?? null)
            ? trim((string) $validated['cover_image'])
            : null;
        $nextArticleCoverImage = filled($validated['article_cover_image'] ?? null)
            ? trim((string) $validated['article_cover_image'])
            : null;

        if ($currentCoverImage !== '' && $currentCoverImage !== (string) $nextCoverImage) {
            $this->deleteStoredLessonCoverIfLocal($currentCoverImage);
        }

        if ($currentArticleCoverImage !== '' && $currentArticleCoverImage !== (string) $nextArticleCoverImage) {
            $this->deleteStoredLessonCoverIfLocal($currentArticleCoverImage);
        }

        $validated['cover_image'] = $nextCoverImage;
        $validated['article_cover_image'] = $nextArticleCoverImage;
        $validated['content_markdown'] = $contentSource === 'markdown' ? $contentMarkdown : null;
        $validated['content'] = $contentSource === 'markdown' ? $contentHtml : $contentHtml;
        unset($validated['content_source']);

        if ($contentSource === 'markdown' && $contentMarkdown !== null) {
            $validated['content'] = $this->lessonMarkdownRenderer->render($contentMarkdown);
        }

        $validated['reading_minutes'] = $this->estimateLessonReadingMinutes(
            (string) ($validated['content'] ?? ''),
            (string) ($validated['content_markdown'] ?? ''),
        );

        // Auto-publish: if marked active but no published_at set, default to now.
        if (! empty($validated['active']) && empty($validated['published_at'])) {
            $validated['published_at'] = now();
        }

        return $validated;
    }

    /**
     * @param  array<int, int>  $courseIds
     */
    private function syncLessonCourses(AcademyLesson $lesson, array $courseIds): void
    {
        $selectedCourseIds = collect($courseIds)
            ->map(static fn ($courseId): int => (int) $courseId)
            ->filter(static fn (int $courseId): bool => $courseId > 0)
            ->unique()
            ->values();

        $existingCourseLessons = $lesson->courseLessons()->get()->keyBy(fn (AcademyCourseLesson $courseLesson): int => (int) $courseLesson->course_id);
        $affectedCourseIds = $existingCourseLessons->keys()->map(static fn ($courseId): int => (int) $courseId)
            ->merge($selectedCourseIds)
            ->unique()
            ->values();

        foreach ($selectedCourseIds as $courseId) {
            if ($existingCourseLessons->has($courseId)) {
                continue;
            }

            AcademyCourseLesson::query()->create([
                'course_id' => $courseId,
                'lesson_id' => $lesson->id,
                'section_id' => null,
                'order_num' => (int) ((AcademyCourseLesson::query()->where('course_id', $courseId)->max('order_num') ?? -1) + 1),
                'is_required' => true,
                'access_override' => null,
                'unlock_after_lesson_id' => null,
            ]);
        }

        $existingCourseLessons
            ->reject(fn (AcademyCourseLesson $courseLesson, int $courseId): bool => $selectedCourseIds->contains($courseId))
            ->each(fn (AcademyCourseLesson $courseLesson): bool|null => $courseLesson->delete());

        if ($affectedCourseIds->isEmpty()) {
            return;
        }

        $affectedCourseIds->each(fn (int $courseId): bool => tap(true, fn () => $this->courseLessonOrdering->syncCourse($courseId)));

        $counts = AcademyCourseLesson::query()
            ->selectRaw('course_id, count(*) as aggregate')
            ->whereIn('course_id', $affectedCourseIds->all())
            ->groupBy('course_id')
            ->pluck('aggregate', 'course_id');

        AcademyCourse::query()
            ->whereIn('id', $affectedCourseIds->all())
            ->get()
            ->each(function (AcademyCourse $course) use ($counts): void {
                $course->forceFill([
                    'lessons_count_cache' => (int) ($counts[$course->id] ?? 0),
                ])->save();
            });
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function courseOptions(): array
    {
        return AcademyCourse::query()
            ->orderByDesc('is_featured')
            ->orderBy('order_num')
            ->orderBy('title')
            ->get()
            ->map(fn (AcademyCourse $course): array => [
                'value' => (string) $course->id,
                'label' => (string) $course->title,
            ])
            ->values()
            ->all();
    }

    /**
     * @return array<string, array<string, int|array<int, int>>>
     */
    private function serializeLessonNumberingContext(?AcademyLesson $currentLesson): array
    {
        $otherLessons = AcademyLesson::query()
            ->when($currentLesson?->exists, fn ($query) => $query->whereKeyNot($currentLesson->id))
            ->get(['lesson_number', 'course_order']);

        return [
            'lesson_number' => $this->buildSequentialNumberSummary($otherLessons->pluck('lesson_number')->all()),
            'course_order' => $this->buildSequentialNumberSummary($otherLessons->pluck('course_order')->all()),
        ];
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function serializeLessonEditorCourses(?AcademyLesson $currentLesson): array
    {
        return AcademyCourse::query()
            ->with([
                'courseLessons' => fn ($query) => $query
                    ->with(['lesson:id,title,slug,lesson_number,series_name', 'section:id,title'])
                    ->orderBy('order_num')
                    ->orderBy('id'),
            ])
            ->orderByDesc('is_featured')
            ->orderBy('order_num')
            ->orderBy('title')
            ->get()
            ->map(function (AcademyCourse $course) use ($currentLesson): array {
                $courseLessons = $course->courseLessons
                    ->sortBy([['order_num', 'asc'], ['id', 'asc']])
                    ->values();

                return [
                    'value' => (string) $course->id,
                    'label' => (string) $course->title,
                    'description' => sprintf('%s · %s', (string) $course->difficulty, (string) $course->status),
                    'status' => (string) $course->status,
                    'difficulty' => (string) $course->difficulty,
                    'access_level' => (string) $course->access_level,
                    'lesson_count' => $courseLessons->count(),
                    'next_order_num' => (int) (($courseLessons->max('order_num') ?? -1) + 1),
                    'attach_url' => route('admin.academy.courses.lessons.attach', ['academyCourse' => $course]),
                    'reorder_url' => route('admin.academy.courses.reorder', ['academyCourse' => $course]),
                    'builder_url' => route('admin.academy.courses.builder.edit', ['academyCourse' => $course]),
                    'edit_url' => route('admin.academy.courses.edit', ['academyCourse' => $course]),
                    'preview_url' => route('academy.courses.show', ['course' => $course->slug]),
                    'lessons' => $courseLessons->map(function (AcademyCourseLesson $courseLesson) use ($currentLesson, $course): array {
                        $lesson = $courseLesson->lesson;

                        return [
                            'id' => (int) $courseLesson->id,
                            'lesson_id' => (int) $courseLesson->lesson_id,
                            'title' => (string) ($lesson?->title ?? 'Untitled lesson'),
                            'slug' => (string) ($lesson?->slug ?? ''),
                            'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null,
                            'order_num' => (int) ($courseLesson->order_num ?? 0),
                            'display_order' => (int) ($courseLesson->order_num ?? 0) + 1,
                            'formatted_lesson_number' => $lesson?->formatted_lesson_number,
                            'series_name' => (string) ($lesson?->series_name ?? ''),
                            'section_title' => (string) ($courseLesson->section?->title ?? ''),
                            'is_required' => (bool) $courseLesson->is_required,
                            'is_current' => $currentLesson?->exists
                                ? (int) $courseLesson->lesson_id === (int) $currentLesson->id
                                : false,
                            'destroy_url' => route('admin.academy.courses.lessons.destroy', ['academyCourse' => $course, 'academyCourseLesson' => $courseLesson]),
                            'edit_url' => $lesson instanceof AcademyLesson
                                ? route('admin.academy.lessons.edit', ['academyLesson' => $lesson])
                                : null,
                        ];
                    })->all(),
                ];
            })
            ->values()
            ->all();
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function serializeLessonRevisions(AcademyLesson $lesson): array
    {
        return AcademyLessonRevision::query()
            ->with('user')
            ->where('lesson_id', $lesson->id)
            ->latest('id')
            ->limit(12)
            ->get()
            ->map(function (AcademyLessonRevision $revision) use ($lesson): array {
                $snapshot = is_array($revision->snapshot_json) ? $revision->snapshot_json : [];
                $fields = is_array($snapshot['fields'] ?? null) ? $snapshot['fields'] : [];

                return [
                    'id' => (int) $revision->id,
                    'created_at' => $revision->created_at?->toISOString(),
                    'created_label' => $revision->created_at?->diffForHumans(),
                    'actor_name' => (string) ($revision->user?->name ?? 'Staff'),
                    'change_note' => (string) ($revision->change_note ?? ''),
                    'restore_url' => route('admin.academy.lessons.revisions.restore', [
                        'academyLesson' => $lesson,
                        'academyLessonRevision' => $revision,
                    ]),
                    'snapshot' => [
                        'title' => (string) ($fields['title'] ?? ''),
                        'excerpt' => (string) ($fields['excerpt'] ?? ''),
                        'content_preview' => Str::limit(strip_tags((string) ($fields['content'] ?? '')), 180),
                        'course_count' => count((array) ($snapshot['course_ids'] ?? [])),
                        'block_count' => count((array) ($snapshot['blocks'] ?? [])),
                    ],
                ];
            })
            ->values()
            ->all();
    }

    /**
     * @param  array<int, mixed>  $values
     * @return array<string, int|array<int, int>>
     */
    private function buildSequentialNumberSummary(array $values): array
    {
        $numbers = collect($values)
            ->map(static fn ($value): int => (int) $value)
            ->filter(static fn (int $value): bool => $value > 0)
            ->unique()
            ->sort()
            ->values();

        $missing = [];
        $expected = 1;

        foreach ($numbers as $number) {
            while ($expected < $number) {
                $missing[] = $expected;
                $expected += 1;
            }

            $expected = $number + 1;
        }

        return [
            'suggested' => (int) ($missing[0] ?? (($numbers->last() ?? 0) + 1)),
            'highest' => (int) ($numbers->last() ?? 0),
            'used_count' => $numbers->count(),
            'missing' => array_slice($missing, 0, 8),
        ];
    }

    private function createLessonRevision(AcademyLesson $lesson, ?User $user, ?string $changeNote = null): AcademyLessonRevision
    {
        $lesson->loadMissing(['blocks.comparisonResults', 'courses']);

        return AcademyLessonRevision::query()->create([
            'lesson_id' => $lesson->id,
            'user_id' => $user?->id,
            'change_note' => $this->nullableTrimmedString($changeNote),
            'snapshot_json' => $this->serializeLessonRevisionSnapshot($lesson),
        ]);
    }

    /**
     * @return array<string, mixed>
     */
    private function serializeLessonRevisionSnapshot(AcademyLesson $lesson): array
    {
        return [
            'content_source' => (string) ($lesson->content_markdown ? 'markdown' : 'html'),
            'fields' => [
                'category_id' => $lesson->category_id,
                'title' => (string) $lesson->title,
                'slug' => (string) $lesson->slug,
                'lesson_number' => $lesson->lesson_number,
                'course_order' => $lesson->course_order,
                'series_name' => (string) ($lesson->series_name ?? ''),
                'excerpt' => (string) ($lesson->excerpt ?? ''),
                'content' => (string) ($lesson->content ?? ''),
                'content_markdown' => (string) ($lesson->content_markdown ?? ''),
                'difficulty' => (string) $lesson->difficulty,
                'access_level' => (string) $lesson->access_level,
                'lesson_type' => (string) $lesson->lesson_type,
                'cover_image' => (string) ($lesson->cover_image ?? ''),
                'article_cover_image' => (string) ($lesson->article_cover_image ?? ''),
                'tags' => array_values((array) ($lesson->tags ?? [])),
                'video_url' => (string) ($lesson->video_url ?? ''),
                'reading_minutes' => (int) ($lesson->reading_minutes ?? 5),
                'featured' => (bool) ($lesson->featured ?? false),
                'active' => (bool) ($lesson->active ?? true),
                'published_at' => $lesson->published_at?->toISOString(),
                'seo_title' => (string) ($lesson->seo_title ?? ''),
                'seo_description' => (string) ($lesson->seo_description ?? ''),
            ],
            'course_ids' => $lesson->courses->pluck('id')->map(static fn ($id): int => (int) $id)->values()->all(),
            'blocks' => $lesson->blocks->map(fn (AcademyLessonBlock $block): array => $this->serializeLessonBlock($block))->values()->all(),
        ];
    }

    private function applyLessonRevisionSnapshot(AcademyLesson $lesson, AcademyLessonRevision $revision, ?string $field = null): void
    {
        $snapshot = is_array($revision->snapshot_json) ? $revision->snapshot_json : [];
        $fields = is_array($snapshot['fields'] ?? null) ? $snapshot['fields'] : [];

        if ($field === 'course_ids') {
            $this->syncLessonCourses($lesson, array_values((array) ($snapshot['course_ids'] ?? [])));

            return;
        }

        if ($field === 'blocks') {
            $this->syncLessonBlocks($lesson, array_values((array) ($snapshot['blocks'] ?? [])));

            return;
        }

        if ($field === 'content') {
            $contentSource = (string) ($snapshot['content_source'] ?? 'html');
            $lesson->forceFill([
                'content' => (string) ($fields['content'] ?? ''),
                'content_markdown' => $contentSource === 'markdown'
                    ? $this->nullableTrimmedString($fields['content_markdown'] ?? null)
                    : null,
            ])->save();

            return;
        }

        if (is_string($field) && $field !== '') {
            $lesson->forceFill([$field => $this->normalizeLessonRevisionFieldValue($field, $fields[$field] ?? null)])->save();

            return;
        }

        $lesson->forceFill([
            'category_id' => $fields['category_id'] ?? null,
            'title' => (string) ($fields['title'] ?? ''),
            'slug' => (string) ($fields['slug'] ?? ''),
            'lesson_number' => $fields['lesson_number'] ?? null,
            'course_order' => $fields['course_order'] ?? null,
            'series_name' => (string) ($fields['series_name'] ?? ''),
            'excerpt' => (string) ($fields['excerpt'] ?? ''),
            'content' => (string) ($fields['content'] ?? ''),
            'content_markdown' => (string) ($snapshot['content_source'] ?? 'html') === 'markdown'
                ? $this->nullableTrimmedString($fields['content_markdown'] ?? null)
                : null,
            'difficulty' => (string) ($fields['difficulty'] ?? 'beginner'),
            'access_level' => (string) ($fields['access_level'] ?? 'free'),
            'lesson_type' => (string) ($fields['lesson_type'] ?? 'article'),
            'cover_image' => $this->nullableTrimmedString($fields['cover_image'] ?? null),
            'article_cover_image' => $this->nullableTrimmedString($fields['article_cover_image'] ?? null),
            'tags' => array_values((array) ($fields['tags'] ?? [])),
            'video_url' => $this->nullableTrimmedString($fields['video_url'] ?? null),
            'reading_minutes' => (int) ($fields['reading_minutes'] ?? 5),
            'featured' => (bool) ($fields['featured'] ?? false),
            'active' => (bool) ($fields['active'] ?? true),
            'published_at' => filled($fields['published_at'] ?? null) ? Carbon::parse((string) $fields['published_at']) : null,
            'seo_title' => $this->nullableTrimmedString($fields['seo_title'] ?? null),
            'seo_description' => $this->nullableTrimmedString($fields['seo_description'] ?? null),
        ])->save();

        $this->syncLessonCourses($lesson, array_values((array) ($snapshot['course_ids'] ?? [])));
        $this->syncLessonBlocks($lesson, array_values((array) ($snapshot['blocks'] ?? [])));
    }

    private function normalizeLessonRevisionFieldValue(string $field, mixed $value): mixed
    {
        return match ($field) {
            'category_id', 'lesson_number', 'course_order' => filled($value) ? (int) $value : null,
            'reading_minutes' => max(1, (int) ($value ?? 5)),
            'featured', 'active' => (bool) $value,
            'published_at' => filled($value) ? Carbon::parse((string) $value) : null,
            'cover_image', 'article_cover_image', 'video_url', 'seo_title', 'seo_description' => $this->nullableTrimmedString($value),
            'tags' => array_values((array) ($value ?? [])),
            default => is_string($value) ? $value : $value,
        };
    }

    private function estimateLessonReadingMinutes(?string $contentHtml, ?string $contentMarkdown): int
    {
        $source = trim((string) ($contentMarkdown ?: ''));

        if ($source === '') {
            $source = html_entity_decode(strip_tags((string) ($contentHtml ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8');
        }

        $normalized = preg_replace('/\s+/u', ' ', trim((string) $source));

        if (! is_string($normalized) || $normalized === '') {
            return 1;
        }

        $words = preg_split('/\s+/u', $normalized, -1, PREG_SPLIT_NO_EMPTY);
        $wordCount = is_array($words) ? count($words) : 0;

        return max(1, (int) ceil($wordCount / 180));
    }

    private function persistCourseAttributes(UpsertAcademyCourseRequest $request, ?AcademyCourse $course = null): array
    {
        $validated = $request->validated();
        $currentCoverImage = trim((string) ($course?->cover_image ?? ''));
        $currentTeaserImage = trim((string) ($course?->teaser_image ?? ''));
        $nextCoverImage = $this->nullableTrimmedString($validated['cover_image'] ?? null);
        $nextTeaserImage = $this->nullableTrimmedString($validated['teaser_image'] ?? null);

        if ($currentCoverImage !== '' && $currentCoverImage !== (string) $nextCoverImage) {
            $this->deleteStoredLessonCoverIfLocal($currentCoverImage);
        }

        if ($currentTeaserImage !== '' && $currentTeaserImage !== (string) $nextTeaserImage) {
            $this->deleteStoredLessonCoverIfLocal($currentTeaserImage);
        }

        $validated['cover_image'] = $nextCoverImage;
        $validated['teaser_image'] = $nextTeaserImage;
        $validated['seo_title'] = $this->nullableTrimmedString($validated['seo_title'] ?? null);
        $validated['seo_description'] = $this->nullableTrimmedString($validated['seo_description'] ?? null);
        $validated['meta_keywords'] = $this->nullableTrimmedString($validated['meta_keywords'] ?? null);
        $validated['og_title'] = $this->nullableTrimmedString($validated['og_title'] ?? null);
        $validated['og_description'] = $this->nullableTrimmedString($validated['og_description'] ?? null);
        $validated['og_image'] = $this->nullableTrimmedString($validated['og_image'] ?? null);

        if ((string) ($validated['status'] ?? '') === 'published' && empty($validated['published_at'])) {
            $validated['published_at'] = now();
        }

        return $validated;
    }

    private function saveCourseFromRequest(UpsertAcademyCourseRequest $request, ?AcademyCourse $course = null): AcademyCourse
    {
        $course ??= new AcademyCourse;
        $course->fill($this->persistCourseAttributes($request, $course))->save();

        return $course;
    }

    /**
     * @return array<string, mixed>
     */
    /**
     * @return array<int, array<string, mixed>>
     */
    private function serializeCourseEditorSections(AcademyCourse $course): array
    {
        $course->loadMissing(['sections']);

        return $course->sections
            ->sortBy([['order_num', 'asc'], ['id', 'asc']])
            ->values()
            ->map(fn (AcademyCourseSection $section): array => [
                'id' => (int) $section->id,
                'title' => (string) $section->title,
                'slug' => (string) ($section->slug ?? ''),
                'description' => (string) ($section->description ?? ''),
                'order_num' => (int) ($section->order_num ?? 0),
                'is_visible' => (bool) ($section->is_visible ?? true),
                'update_url' => route('admin.academy.courses.sections.update', [
                    'academyCourse' => $course,
                    'academyCourseSection' => $section,
                ]),
                'destroy_url' => route('admin.academy.courses.sections.destroy', [
                    'academyCourse' => $course,
                    'academyCourseSection' => $section,
                ]),
            ])
            ->all();
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function serializeCourseEditorLessons(AcademyCourse $course): array
    {
        $course->loadMissing(['courseLessons.lesson', 'courseLessons.section']);

        return $course->courseLessons
            ->sortBy([['order_num', 'asc'], ['id', 'asc']])
            ->values()
            ->map(function (AcademyCourseLesson $courseLesson) use ($course): array {
                $lesson = $courseLesson->lesson;
                $publicationMeta = $this->serializeLessonPublicationMeta($lesson instanceof AcademyLesson ? $lesson : null);

                return array_merge([
                    'id' => (int) $courseLesson->id,
                    'lesson_id' => (int) $courseLesson->lesson_id,
                    'title' => (string) ($lesson?->title ?? 'Untitled lesson'),
                    'slug' => (string) ($lesson?->slug ?? ''),
                    'cover_image' => (string) ($lesson?->cover_image ?? ''),
                    'cover_image_url' => $lesson instanceof AcademyLesson
                        ? $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?: $lesson->article_cover_image ?? ''))
                        : null,
                    'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null,
                    'section_title' => (string) ($courseLesson->section?->title ?? ''),
                    'order_num' => (int) ($courseLesson->order_num ?? 0),
                    'display_order' => (int) ($courseLesson->order_num ?? 0) + 1,
                    'formatted_lesson_number' => $lesson instanceof AcademyLesson ? $lesson->formatted_lesson_number : null,
                    'is_required' => (bool) $courseLesson->is_required,
                    'difficulty' => (string) ($lesson?->difficulty ?? ''),
                    'access_level' => (string) ($lesson?->access_level ?? ''),
                    'active' => (bool) ($lesson?->active ?? false),
                    'destroy_url' => route('admin.academy.courses.lessons.destroy', [
                        'academyCourse' => $course,
                        'academyCourseLesson' => $courseLesson,
                    ]),
                    'edit_url' => $lesson instanceof AcademyLesson
                        ? route('admin.academy.lessons.edit', ['academyLesson' => $lesson])
                        : null,
                ], $publicationMeta);
            })
            ->all();
    }

    /**
     * @param  array<string, mixed>  $lessonData
     * @param  array<string, mixed>  $defaults
     * @param  array<int, string>  $reservedSlugs
     * @return array<string, mixed>
     */
    private function buildImportedCourseLessonAttributes(AcademyCourse $course, array $lessonData, array $defaults, array &$reservedSlugs): array
    {
        $title = trim((string) ($lessonData['title'] ?? ''));
        $slugSource = $this->nullableTrimmedString($lessonData['slug'] ?? null) ?? $title;
        $excerpt = $this->nullableTrimmedString($lessonData['excerpt'] ?? null)
            ?? $this->nullableTrimmedString($lessonData['goal'] ?? null);
        $difficulty = $this->nullableTrimmedString($lessonData['difficulty'] ?? null)
            ?? $this->nullableTrimmedString($defaults['difficulty'] ?? null)
            ?? $this->nullableTrimmedString($course->difficulty)
            ?? 'beginner';
        $accessLevel = $this->nullableTrimmedString($lessonData['access_level'] ?? null)
            ?? $this->nullableTrimmedString($defaults['access_level'] ?? null)
            ?? 'free';
        $lessonType = $this->nullableTrimmedString($lessonData['lesson_type'] ?? null)
            ?? $this->nullableTrimmedString($defaults['lesson_type'] ?? null)
            ?? 'article';
        $seriesName = $this->nullableTrimmedString($lessonData['series_name'] ?? null)
            ?? $this->nullableTrimmedString($defaults['series_name'] ?? null)
            ?? $this->nullableTrimmedString($course->title);
        $active = array_key_exists('active', $lessonData)
            ? (bool) $lessonData['active']
            : (array_key_exists('active', $defaults) ? (bool) $defaults['active'] : false);

        return [
            'category_id' => $this->resolveImportedLessonCategoryId($lessonData, $defaults),
            'title' => $title,
            'slug' => $this->reserveImportedLessonSlug($slugSource, $reservedSlugs),
            'lesson_number' => null,
            'course_order' => null,
            'series_name' => $seriesName,
            'excerpt' => $excerpt,
            'content' => null,
            'content_markdown' => null,
            'difficulty' => $difficulty,
            'access_level' => $accessLevel,
            'lesson_type' => $lessonType,
            'cover_image' => null,
            'article_cover_image' => null,
            'tags' => collect((array) ($lessonData['tags'] ?? []))
                ->map(fn ($tag): string => trim((string) $tag))
                ->filter(fn (string $tag): bool => $tag !== '')
                ->values()
                ->all(),
            'video_url' => null,
            'reading_minutes' => 5,
            'featured' => false,
            'active' => $active,
            'published_at' => null,
            'seo_title' => null,
            'seo_description' => $excerpt,
        ];
    }

    /**
     * @param  array<string, mixed>  $lessonData
     * @param  array<string, mixed>  $defaults
     */
    private function resolveImportedLessonCategoryId(array $lessonData, array $defaults): ?int
    {
        foreach ([$lessonData, $defaults] as $source) {
            if ($source === []) {
                continue;
            }

            $categoryId = $source['category_id'] ?? null;
            if ($categoryId !== null && AcademyCategory::query()->where('type', 'lesson')->whereKey((int) $categoryId)->exists()) {
                return (int) $categoryId;
            }

            $categorySlug = $this->nullableTrimmedString($source['category_slug'] ?? null);
            if ($categorySlug !== null) {
                $category = AcademyCategory::query()->where('type', 'lesson')->where('slug', $categorySlug)->first();
                if ($category instanceof AcademyCategory) {
                    return (int) $category->id;
                }
            }

            $categoryName = $this->nullableTrimmedString($source['category'] ?? null);
            if ($categoryName !== null) {
                $category = AcademyCategory::query()->where('type', 'lesson')->whereRaw('lower(name) = ?', [Str::lower($categoryName)])->first();
                if ($category instanceof AcademyCategory) {
                    return (int) $category->id;
                }
            }
        }

        return null;
    }

    /**
     * @param  array<int, string>  $reservedSlugs
     */
    private function reserveImportedLessonSlug(string $source, array &$reservedSlugs): string
    {
        $base = Str::slug($source);

        if ($base === '') {
            $base = 'academy-lesson';
        }

        $candidate = $base;
        $suffix = 2;

        while (in_array($candidate, $reservedSlugs, true)) {
            $candidate = $base.'-'.$suffix;
            $suffix++;
        }

        $reservedSlugs[] = $candidate;

        return $candidate;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function categoriesForEditor(string $type): array
    {
        return AcademyCategory::query()
            ->where('type', $type)
            ->orderBy('order_num')
            ->orderBy('name')
            ->get()
            ->map(fn (AcademyCategory $category): array => $this->serializeCategoryOption($category))
            ->values()
            ->all();
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function serializeCourseAvailableLessons(AcademyCourse $course): array
    {
        return AcademyLesson::query()
            ->whereDoesntHave('courseLessons')
            ->with('category')
            ->orderBy('title')
            ->get()
            ->map(function (AcademyLesson $lesson): array {
                $publicationMeta = $this->serializeLessonPublicationMeta($lesson);

                return array_merge([
                    'id' => (int) $lesson->id,
                    'title' => (string) $lesson->title,
                    'slug' => (string) $lesson->slug,
                    'cover_image' => (string) ($lesson->cover_image ?? ''),
                    'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?: $lesson->article_cover_image ?? '')),
                    'difficulty' => (string) $lesson->difficulty,
                    'access_level' => (string) $lesson->access_level,
                    'active' => (bool) $lesson->active,
                    'category' => $lesson->category ? (string) $lesson->category->name : '',
                    'edit_url' => route('admin.academy.lessons.edit', ['academyLesson' => $lesson]),
                    'attached' => false,
                ], $publicationMeta);
            })
            ->values()
            ->all();
    }

    /**
     * @return array<string, string|null>
     */
    private function serializeLessonPublicationMeta(?AcademyLesson $lesson): array
    {
        $publishedAt = $lesson?->published_at instanceof Carbon
            ? $lesson->published_at->copy()
            : null;

        if (! $publishedAt) {
            return [
                'published_at' => null,
                'publication_state' => 'draft',
                'publication_label' => 'Unscheduled',
            ];
        }

        if ($publishedAt->isFuture()) {
            return [
                'published_at' => $publishedAt->toIso8601String(),
                'publication_state' => 'scheduled',
                'publication_label' => 'Publishes '.$publishedAt->format('Y-m-d H:i'),
            ];
        }

        return [
            'published_at' => $publishedAt->toIso8601String(),
            'publication_state' => 'published',
            'publication_label' => 'Published',
        ];
    }

    private function serializeCourseOutlineSummary(AcademyCourse $course): array
    {
        $course->loadMissing(['sections', 'courseLessons']);

        $sections = $course->sections
            ->sortBy([['order_num', 'asc'], ['id', 'asc']])
            ->values();

        $courseLessons = $course->courseLessons;

        return [
            'section_count' => $sections->count(),
            'visible_section_count' => $sections->where('is_visible', true)->count(),
            'lesson_count' => $courseLessons->count(),
            'required_lesson_count' => $courseLessons->where('is_required', true)->count(),
            'unsectioned_lesson_count' => $courseLessons->whereNull('section_id')->count(),
            'sections' => $sections->map(function ($section) use ($courseLessons): array {
                return [
                    'id' => (int) $section->id,
                    'title' => (string) $section->title,
                    'is_visible' => (bool) $section->is_visible,
                    'lesson_count' => $courseLessons->where('section_id', $section->id)->count(),
                ];
            })->all(),
        ];
    }

    /**
     * @param  array<int, array<string, mixed>>  $blocks
     */
    private function syncLessonBlocks(AcademyLesson $lesson, array $blocks): void
    {
        $lesson->loadMissing(['blocks.comparisonResults']);

        $existingBlocks = $lesson->blocks->keyBy(fn (AcademyLessonBlock $block): int => (int) $block->id);
        $retainedBlockIds = [];

        foreach ($blocks as $index => $blockData) {
            $blockId = isset($blockData['id']) ? (int) $blockData['id'] : null;
            $block = $blockId !== null ? $existingBlocks->get($blockId) : null;

            if (! $block instanceof AcademyLessonBlock) {
                $block = new AcademyLessonBlock;
                $block->lesson()->associate($lesson);
            }

            $payload = is_array($blockData['payload'] ?? null) ? $blockData['payload'] : [];
            $block->fill([
                'type' => (string) ($blockData['type'] ?? 'ai_comparison'),
                'title' => $this->nullableTrimmedString($blockData['title'] ?? null) ?? $this->nullableTrimmedString($payload['title'] ?? null),
                'payload' => $this->normalizeLessonBlockPayload($payload),
                'sort_order' => (int) ($blockData['sort_order'] ?? $index),
                'active' => (bool) ($blockData['active'] ?? true),
            ]);
            $block->save();

            $retainedBlockIds[] = (int) $block->id;
            $this->syncLessonBlockComparisonResults($block, is_array($blockData['comparison_results'] ?? null) ? $blockData['comparison_results'] : []);
        }

        $lesson->blocks
            ->filter(fn (AcademyLessonBlock $block): bool => ! in_array((int) $block->id, $retainedBlockIds, true))
            ->each(function (AcademyLessonBlock $block): void {
                foreach ($block->comparisonResults as $result) {
                    $this->deleteStoredLessonMediaIfLocal($result->image_path);
                    $this->deleteStoredLessonMediaIfLocal($result->thumb_path);
                }

                $block->delete();
            });
    }

    /**
     * @param  array<int, array<string, mixed>>  $results
     */
    private function syncLessonBlockComparisonResults(AcademyLessonBlock $block, array $results): void
    {
        $existingResults = $block->comparisonResults->keyBy(fn (AcademyAiComparisonResult $result): int => (int) $result->id);
        $retainedResultIds = [];

        foreach ($results as $index => $resultData) {
            $resultId = isset($resultData['id']) ? (int) $resultData['id'] : null;
            $result = $resultId !== null ? $existingResults->get($resultId) : null;

            if (! $result instanceof AcademyAiComparisonResult) {
                $result = new AcademyAiComparisonResult;
                $result->block()->associate($block);
            }

            $previousImagePath = (string) ($result->image_path ?? '');
            $previousThumbPath = (string) ($result->thumb_path ?? '');
            $nextImagePath = $this->nullableTrimmedString($resultData['image_path'] ?? null);
            $nextThumbPath = $this->nullableTrimmedString($resultData['thumb_path'] ?? null);

            $result->fill([
                'provider' => $this->nullableTrimmedString($resultData['provider'] ?? null),
                'model_name' => $this->nullableTrimmedString($resultData['model_name'] ?? null),
                'image_path' => $nextImagePath,
                'thumb_path' => $nextThumbPath,
                'settings' => $this->nullableTrimmedString($resultData['settings'] ?? null),
                'strengths' => $this->nullableTrimmedString($resultData['strengths'] ?? null),
                'weaknesses' => $this->nullableTrimmedString($resultData['weaknesses'] ?? null),
                'best_for' => $this->nullableTrimmedString($resultData['best_for'] ?? null),
                'score' => $resultData['score'] ?? null,
                'sort_order' => (int) ($resultData['sort_order'] ?? $index),
                'active' => (bool) ($resultData['active'] ?? true),
            ]);
            $result->save();

            if ($previousImagePath !== '' && $previousImagePath !== (string) $nextImagePath) {
                $this->deleteStoredLessonMediaIfLocal($previousImagePath);
            }

            if ($previousThumbPath !== '' && $previousThumbPath !== (string) $nextThumbPath) {
                $this->deleteStoredLessonMediaIfLocal($previousThumbPath);
            }

            $retainedResultIds[] = (int) $result->id;
        }

        $block->comparisonResults
            ->filter(fn (AcademyAiComparisonResult $result): bool => ! in_array((int) $result->id, $retainedResultIds, true))
            ->each(function (AcademyAiComparisonResult $result): void {
                $this->deleteStoredLessonMediaIfLocal($result->image_path);
                $this->deleteStoredLessonMediaIfLocal($result->thumb_path);
                $result->delete();
            });
    }

    /**
     * @param  array<string, mixed>  $payload
     * @return array<string, mixed>
     */
    private function normalizeLessonBlockPayload(array $payload): array
    {
        $criteria = collect($payload['criteria'] ?? [])
            ->map(fn ($criterion): string => trim((string) $criterion))
            ->filter(static fn (string $criterion): bool => $criterion !== '')
            ->values()
            ->all();

        return [
            'title' => $this->nullableTrimmedString($payload['title'] ?? null),
            'intro' => $this->nullableTrimmedString($payload['intro'] ?? null),
            'prompt' => $this->nullableTrimmedString($payload['prompt'] ?? null),
            'negative_prompt' => $this->nullableTrimmedString($payload['negative_prompt'] ?? null),
            'aspect_ratio' => $this->nullableTrimmedString($payload['aspect_ratio'] ?? null),
            'criteria' => $criteria,
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function serializeLessonBlock(AcademyLessonBlock $block): array
    {
        $payload = is_array($block->payload) ? $block->payload : [];

        return [
            'id' => (int) $block->id,
            'type' => (string) $block->type,
            'title' => (string) ($block->title ?? ''),
            'payload' => $this->normalizeLessonBlockPayload($payload),
            'sort_order' => (int) $block->sort_order,
            'active' => (bool) $block->active,
            'comparison_results' => $block->comparisonResults->map(fn (AcademyAiComparisonResult $result): array => [
                'id' => (int) $result->id,
                'provider' => (string) ($result->provider ?? ''),
                'model_name' => (string) ($result->model_name ?? ''),
                'image_path' => (string) $result->image_path,
                'image_url' => $this->resolveLessonMediaUrl((string) $result->image_path),
                'thumb_path' => (string) ($result->thumb_path ?? ''),
                'thumb_url' => $this->resolveLessonMediaUrl((string) ($result->thumb_path ?? '')),
                'settings' => (string) ($result->settings ?? ''),
                'strengths' => (string) ($result->strengths ?? ''),
                'weaknesses' => (string) ($result->weaknesses ?? ''),
                'best_for' => (string) ($result->best_for ?? ''),
                'score' => $result->score,
                'sort_order' => (int) $result->sort_order,
                'active' => (bool) $result->active,
            ])->values()->all(),
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function persistPromptAttributes(UpsertAcademyPromptTemplateRequest $request, ?AcademyPromptTemplate $prompt = null): array
    {
        $validated = $request->validated();
        unset($validated['preview_image_file']);
        $newCategoryName = trim((string) ($validated['new_category_name'] ?? ''));
        unset($validated['new_category_name']);

        if ($newCategoryName !== '') {
            $validated['category_id'] = $this->resolveOrCreatePromptCategoryId($newCategoryName);
        }

        $validated['documentation'] = $this->normalizePromptDocumentation($validated['documentation'] ?? null);
        $validated['placeholders'] = $this->normalizePromptPlaceholders($validated['placeholders'] ?? null);
        $validated['helper_prompts'] = $this->normalizePromptHelperPrompts($validated['helper_prompts'] ?? null);
        $validated['prompt_variants'] = $this->normalizePromptVariants($validated['prompt_variants'] ?? null);
        $validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? []));
        $previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? []));

        $currentPreviewImage = (string) ($prompt?->preview_image ?? '');
        $previewImageFile = $this->promptPreviewImageUpload($request);

        if ($previewImageFile instanceof UploadedFile) {
            $this->deleteStoredPromptPreviewIfLocal($currentPreviewImage);
            $validated['preview_image'] = $this->storePromptPreviewImage($previewImageFile);
        } else {
            $validated['preview_image'] = filled($validated['preview_image'] ?? null)
                ? trim((string) $validated['preview_image'])
                : null;
        }

        // Auto-publish: if marked active but no published_at set, default to now.
        if (! empty($validated['active']) && empty($validated['published_at'])) {
            $validated['published_at'] = now();
        }

        $this->deleteRemovedPromptComparisonMedia($previousToolNotes, (array) ($validated['tool_notes'] ?? []));

        return $validated;
    }

    private function resolveOrCreatePromptCategoryId(string $name): int
    {
        $normalizedName = trim($name);

        $existing = AcademyCategory::query()
            ->where('type', 'prompt')
            ->whereRaw('LOWER(name) = ?', [mb_strtolower($normalizedName)])
            ->first();

        if ($existing instanceof AcademyCategory) {
            return (int) $existing->id;
        }

        $maxOrder = (int) (AcademyCategory::query()->where('type', 'prompt')->max('order_num') ?? 0);

        $category = new AcademyCategory();
        $category->forceFill([
            'type' => 'prompt',
            'name' => $normalizedName,
            'slug' => Str::slug($normalizedName),
            'description' => null,
            'order_num' => $maxOrder + 1,
            'active' => true,
        ])->save();

        return (int) $category->id;
    }

    /**
     * @param  array<int, mixed>  $notes
     * @return array<int, array<string, mixed>>
     */
    private function serializePromptToolNotes(array $notes): array
    {
        return collect($this->normalizePromptToolNotes($notes))
            ->map(fn (array $note): array => [
                ...$note,
                'image_url' => $this->resolveLessonMediaUrl($note['image_path'] ?? null),
                'thumb_url' => $this->resolveLessonMediaUrl($note['thumb_path'] ?? null),
            ])
            ->values()
            ->all();
    }

    private function encodePrettyJsonForForm(mixed $value): string
    {
        if ($value === null || $value === [] || $value === '') {
            return '';
        }

        return (string) json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    }

    /**
     * @return array<string, mixed>|null
     */
    private function normalizePromptDocumentation(mixed $documentation): ?array
    {
        if (! is_array($documentation)) {
            return null;
        }

        $listFields = ['best_for', 'how_to_use', 'required_inputs', 'workflow', 'tips', 'common_mistakes', 'data_accuracy_notes'];
        $normalized = [
            'summary' => $this->nullableTrimmedString($documentation['summary'] ?? null),
            'display_notes' => $this->nullableTrimmedString($documentation['display_notes'] ?? null),
        ];

        foreach ($listFields as $field) {
            $normalized[$field] = $this->normalizePromptStringList($documentation[$field] ?? []);
        }

        $hasContent = $normalized['summary'] !== null
            || $normalized['display_notes'] !== null
            || collect($listFields)->contains(fn (string $field): bool => $normalized[$field] !== []);

        return $hasContent ? $normalized : null;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function normalizePromptPlaceholders(mixed $placeholders): array
    {
        if (! is_array($placeholders)) {
            return [];
        }

        return collect($placeholders)
            ->filter(static fn ($placeholder): bool => is_array($placeholder))
            ->map(function (array $placeholder): array {
                return [
                    'key' => $this->nullableTrimmedString($placeholder['key'] ?? null),
                    'label' => $this->nullableTrimmedString($placeholder['label'] ?? null),
                    'description' => $this->nullableTrimmedString($placeholder['description'] ?? null),
                    'required' => filter_var($placeholder['required'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
                    'example' => $this->normalizePromptJsonValue($placeholder['example'] ?? null),
                    'default' => $this->normalizePromptJsonValue($placeholder['default'] ?? null),
                    'type' => $this->nullableTrimmedString($placeholder['type'] ?? null),
                ];
            })
            ->filter(function (array $placeholder): bool {
                return collect([
                    $placeholder['key'] ?? null,
                    $placeholder['label'] ?? null,
                    $placeholder['description'] ?? null,
                    $placeholder['example'] ?? null,
                    $placeholder['default'] ?? null,
                    $placeholder['type'] ?? null,
                ])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
            })
            ->values()
            ->all();
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function normalizePromptHelperPrompts(mixed $helperPrompts): array
    {
        if (! is_array($helperPrompts)) {
            return [];
        }

        return collect($helperPrompts)
            ->filter(static fn ($helperPrompt): bool => is_array($helperPrompt))
            ->map(function (array $helperPrompt): array {
                return [
                    'title' => $this->nullableTrimmedString($helperPrompt['title'] ?? null),
                    'type' => $this->nullableTrimmedString($helperPrompt['type'] ?? null) ?? 'other',
                    'description' => $this->nullableTrimmedString($helperPrompt['description'] ?? null),
                    'prompt' => $this->nullableTrimmedString($helperPrompt['prompt'] ?? null),
                    'expected_output' => $this->nullableTrimmedString($helperPrompt['expected_output'] ?? null) ?? 'text',
                    'active' => filter_var($helperPrompt['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
                ];
            })
            ->filter(function (array $helperPrompt): bool {
                return collect([
                    $helperPrompt['title'] ?? null,
                    $helperPrompt['description'] ?? null,
                    $helperPrompt['prompt'] ?? null,
                ])->contains(fn ($item): bool => $item !== null && $item !== '');
            })
            ->values()
            ->all();
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function normalizePromptVariants(mixed $variants): array
    {
        if (! is_array($variants)) {
            return [];
        }

        return collect($variants)
            ->filter(static fn ($variant): bool => is_array($variant))
            ->map(function (array $variant): array {
                return [
                    'title' => $this->nullableTrimmedString($variant['title'] ?? null),
                    'slug' => $this->nullableTrimmedString($variant['slug'] ?? null),
                    'description' => $this->nullableTrimmedString($variant['description'] ?? null),
                    'prompt' => $this->nullableTrimmedString($variant['prompt'] ?? null),
                    'negative_prompt' => $this->nullableTrimmedString($variant['negative_prompt'] ?? null),
                    'recommended' => filter_var($variant['recommended'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
                    'recommended_for' => $this->normalizePromptStringList($variant['recommended_for'] ?? []),
                    'risk_notes' => $this->normalizePromptStringList($variant['risk_notes'] ?? []),
                    'active' => filter_var($variant['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
                ];
            })
            ->filter(function (array $variant): bool {
                return collect([
                    $variant['title'] ?? null,
                    $variant['description'] ?? null,
                    $variant['prompt'] ?? null,
                    $variant['negative_prompt'] ?? null,
                ])->contains(fn ($item): bool => $item !== null && $item !== '');
            })
            ->values()
            ->all();
    }

    /**
     * @return array<int, string>
     */
    private function normalizePromptStringList(mixed $value): array
    {
        if (! is_array($value)) {
            $value = $value === null ? [] : [$value];
        }

        return collect($value)
            ->map(fn ($item): string => trim((string) $item))
            ->filter(static fn (string $item): bool => $item !== '')
            ->values()
            ->all();
    }

    private function normalizePromptJsonValue(mixed $value): mixed
    {
        if (! is_string($value)) {
            return $value;
        }

        $trimmed = trim($value);

        return $trimmed !== '' ? $trimmed : null;
    }

    /**
     * @param  array<int, mixed>  $notes
     * @return array<int, array<string, string>>
     */
    private function normalizePromptToolNotes(array $notes): array
    {
        return collect($notes)
            ->map(function ($note): ?array {
                if (is_string($note)) {
                    $normalized = [
                        'display_type' => '',
                        'provider' => '',
                        'model_name' => '',
                        'notes' => trim($note),
                        'strengths' => '',
                        'weaknesses' => '',
                        'best_for' => '',
                        'image_path' => '',
                        'thumb_path' => '',
                        'settings' => '',
                        'score' => null,
                        'active' => true,
                    ];

                    return $normalized['notes'] !== '' ? $normalized : null;
                }

                if (! is_array($note)) {
                    return null;
                }

                $normalized = [
                    'display_type' => trim((string) ($note['display_type'] ?? '')),
                    'provider' => trim((string) ($note['provider'] ?? '')),
                    'model_name' => trim((string) ($note['model_name'] ?? '')),
                    'notes' => trim((string) ($note['notes'] ?? '')),
                    'strengths' => trim((string) ($note['strengths'] ?? '')),
                    'weaknesses' => trim((string) ($note['weaknesses'] ?? '')),
                    'best_for' => trim((string) ($note['best_for'] ?? '')),
                    'image_path' => trim((string) ($note['image_path'] ?? '')),
                    'thumb_path' => trim((string) ($note['thumb_path'] ?? '')),
                    'settings' => trim((string) ($note['settings'] ?? '')),
                    'score' => filled($note['score'] ?? null) ? max(1, min(10, (int) $note['score'])) : null,
                    'active' => filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
                ];

                $hasContent = collect([
                    $normalized['display_type'],
                    $normalized['provider'],
                    $normalized['model_name'],
                    $normalized['notes'],
                    $normalized['strengths'],
                    $normalized['weaknesses'],
                    $normalized['best_for'],
                    $normalized['image_path'],
                    $normalized['thumb_path'],
                    $normalized['settings'],
                ])->contains(fn (string $value): bool => $value !== '');

                return $hasContent || $normalized['score'] !== null ? $normalized : null;
            })
            ->filter()
            ->values()
            ->all();
    }

    /**
     * @param  array<int, array<string, mixed>>  $previousNotes
     * @param  array<int, array<string, mixed>>  $nextNotes
     */
    private function deleteRemovedPromptComparisonMedia(array $previousNotes, array $nextNotes): void
    {
        $previousPaths = collect($previousNotes)
            ->flatMap(fn (array $note): array => [
                trim((string) ($note['image_path'] ?? '')),
                trim((string) ($note['thumb_path'] ?? '')),
            ])
            ->filter()
            ->unique();

        $nextPaths = collect($nextNotes)
            ->flatMap(fn (array $note): array => [
                trim((string) ($note['image_path'] ?? '')),
                trim((string) ($note['thumb_path'] ?? '')),
            ])
            ->filter()
            ->unique()
            ->all();

        $previousPaths
            ->reject(fn (string $path): bool => in_array($path, $nextPaths, true))
            ->each(function (string $path): void {
                $this->deleteStoredLessonMediaIfLocal($path);
            });
    }

    private function promptPreviewImageUpload(UpsertAcademyPromptTemplateRequest $request): ?UploadedFile
    {
        $file = $request->file('preview_image_file');

        if (! $file instanceof UploadedFile) {
            return null;
        }

        $pathName = trim((string) $file->getPathname());
        if ($file->isValid() && $pathName !== '' && is_file($pathName) && is_readable($pathName)) {
            return $file;
        }

        throw ValidationException::withMessages([
            'preview_image_file' => $this->promptPreviewImageUploadErrorMessage($file),
        ]);
    }

    private function storePromptPreviewImage(UploadedFile $file): string
    {
        $pathName = trim((string) $file->getPathname());
        if ($pathName === '' || ! is_file($pathName) || ! is_readable($pathName)) {
            throw ValidationException::withMessages([
                'preview_image_file' => $this->promptPreviewImageUploadErrorMessage($file),
            ]);
        }

        if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) {
            throw ValidationException::withMessages([
                'preview_image_file' => 'The server is missing WebP image support. Enable the GD WebP extension to upload prompt preview images.',
            ]);
        }

        $binary = @file_get_contents($pathName);
        if ($binary === false) {
            throw ValidationException::withMessages([
                'preview_image_file' => 'The uploaded preview image could not be opened for conversion. Please choose the file again and retry.',
            ]);
        }

        $image = @imagecreatefromstring($binary);
        if (! $image instanceof \GdImage) {
            throw ValidationException::withMessages([
                'preview_image_file' => 'The uploaded preview image format could not be converted. Please use JPG, PNG, or WEBP.',
            ]);
        }

        try {
            if (! imageistruecolor($image)) {
                imagepalettetotruecolor($image);
            }

            imagealphablending($image, true);
            imagesavealpha($image, true);

            ob_start();
            $converted = imagewebp($image, null, self::PROMPT_PREVIEW_WEBP_QUALITY);
            $webpBinary = ob_get_clean();

            if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
                throw ValidationException::withMessages([
                    'preview_image_file' => 'The uploaded preview image could not be converted to WebP. Please try a different image.',
                ]);
            }

            $storedPath = self::PROMPT_PREVIEW_PREFIX.'/'.pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME).'.webp';
            Storage::disk($this->promptPreviewImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']);

            $sourceWidth = imagesx($image);
            $sourceHeight = imagesy($image);

            foreach (self::PROMPT_PREVIEW_VARIANT_WIDTHS as $variant => $targetWidth) {
                $variantBinary = $this->encodePromptPreviewVariant($image, $targetWidth, $sourceWidth, $sourceHeight);

                if ($variantBinary === null) {
                    continue;
                }

                Storage::disk($this->promptPreviewImageDisk())->put(
                    $this->promptPreviewVariantPath($storedPath, $variant),
                    $variantBinary,
                    ['visibility' => 'public']
                );
            }
        } finally {
            imagedestroy($image);
        }

        return $storedPath;
    }

    private function encodePromptPreviewVariant(\GdImage $source, int $targetWidth, int $sourceWidth, int $sourceHeight): ?string
    {
        if ($sourceWidth <= $targetWidth || $sourceWidth < 1 || $sourceHeight < 1) {
            return null;
        }

        $targetHeight = max(1, (int) round(($sourceHeight / $sourceWidth) * $targetWidth));
        $variant = imagecreatetruecolor($targetWidth, $targetHeight);

        if (! $variant instanceof \GdImage) {
            throw ValidationException::withMessages([
                'preview_image_file' => 'The uploaded preview image could not be resized. Please try a different image.',
            ]);
        }

        imagealphablending($variant, false);
        imagesavealpha($variant, true);
        $transparent = imagecolorallocatealpha($variant, 0, 0, 0, 127);
        imagefilledrectangle($variant, 0, 0, $targetWidth, $targetHeight, $transparent);
        imagecopyresampled($variant, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight);

        try {
            ob_start();
            $converted = imagewebp($variant, null, self::PROMPT_PREVIEW_WEBP_QUALITY);
            $webpBinary = ob_get_clean();

            if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
                throw ValidationException::withMessages([
                    'preview_image_file' => 'The uploaded preview image could not be converted to WebP. Please try a different image.',
                ]);
            }

            return $webpBinary;
        } finally {
            imagedestroy($variant);
        }
    }

    private function promptPreviewVariantPath(string $path, string $variant): string
    {
        $directory = pathinfo($path, PATHINFO_DIRNAME);
        $filename = pathinfo($path, PATHINFO_FILENAME);
        $baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;

        return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant);
    }

    private function canonicalPromptPreviewPath(string $path): string
    {
        $directory = pathinfo($path, PATHINFO_DIRNAME);
        $filename = pathinfo($path, PATHINFO_FILENAME);
        $baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;

        return sprintf('%s/%s.webp', $directory, $baseFilename);
    }

    private function deleteStoredPromptPreviewIfLocal(?string $path): void
    {
        $path = trim((string) $path);
        if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
            return;
        }

        if (! str_starts_with($path, self::PROMPT_PREVIEW_PREFIX.'/')) {
            return;
        }

        $disk = $this->promptPreviewImageDisk();
        $basePath = $this->canonicalPromptPreviewPath($path);
        $paths = [
            $basePath,
            $this->promptPreviewVariantPath($basePath, 'thumb'),
            $this->promptPreviewVariantPath($basePath, 'md'),
        ];

        Storage::disk($disk)->delete(array_values(array_unique($paths)));
    }

    private function promptPreviewImageUploadErrorMessage(UploadedFile $file): string
    {
        return match ($file->getError()) {
            UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded preview image exceeds the server upload limit.',
            UPLOAD_ERR_PARTIAL => 'The uploaded preview image was only partially received. Please retry the upload.',
            UPLOAD_ERR_NO_TMP_DIR => 'The server upload temp directory is unavailable. Check PHP upload temp configuration.',
            UPLOAD_ERR_CANT_WRITE => 'The server could not write the uploaded preview image to temporary storage.',
            UPLOAD_ERR_EXTENSION => 'A PHP extension blocked the preview image upload.',
            default => 'The uploaded preview image could not be read. Please choose the file again and retry.',
        };
    }

    private function promptPreviewImageDisk(): string
    {
        return (string) config('uploads.object_storage.disk', 's3');
    }

    private function resolvePromptPreviewImageUrl(?string $previewImage): ?string
    {
        $previewImage = trim((string) $previewImage);

        if ($previewImage === '') {
            return null;
        }

        if (str_starts_with($previewImage, 'http://') || str_starts_with($previewImage, 'https://') || str_starts_with($previewImage, '/')) {
            return $previewImage;
        }

        return Storage::disk($this->promptPreviewImageDisk())->url($previewImage);
    }

    private function resolveLessonMediaUrl(?string $path): ?string
    {
        $path = trim((string) $path);

        if ($path === '') {
            return null;
        }

        if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
            return $path;
        }

        return Storage::disk($this->promptPreviewImageDisk())->url($path);
    }

    private function deleteStoredLessonCoverIfLocal(?string $path): void
    {
        $path = trim((string) $path);
        if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
            return;
        }

        if (! str_starts_with($path, 'academy/lessons/covers/')) {
            return;
        }

        $disk = $this->promptPreviewImageDisk();

        if (Storage::disk($disk)->exists($path)) {
            Storage::disk($disk)->delete($path);
        }
    }

    private function deleteStoredLessonMediaIfLocal(?string $path): void
    {
        $path = trim((string) $path);
        if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
            return;
        }

        if (! str_starts_with($path, 'academy/lessons/body/') && ! str_starts_with($path, 'academy/lessons/covers/')) {
            return;
        }

        $disk = $this->promptPreviewImageDisk();

        if (Storage::disk($disk)->exists($path)) {
            Storage::disk($disk)->delete($path);
        }
    }

    private function nullableTrimmedString(mixed $value): ?string
    {
        $trimmed = trim((string) $value);

        return $trimmed === '' ? null : $trimmed;
    }

    private function resolveLessonCoverImageUrl(?string $coverImage): ?string
    {
        $coverImage = trim((string) $coverImage);

        if ($coverImage === '') {
            return null;
        }

        if (str_starts_with($coverImage, 'http://') || str_starts_with($coverImage, 'https://') || str_starts_with($coverImage, '/')) {
            return $coverImage;
        }

        return Storage::disk($this->promptPreviewImageDisk())->url($coverImage);
    }

    private function serializeCategoryOption(AcademyCategory $category): array
    {
        return [
            'id' => (int) $category->id,
            'value' => (int) $category->id,
            'label' => (string) $category->name,
            'name' => (string) $category->name,
            'slug' => (string) $category->slug,
            'description' => (string) ($category->description ?? ''),
            'order_num' => (int) ($category->order_num ?? 0),
            'active' => (bool) ($category->active ?? true),
            'edit_url' => route('admin.academy.categories.edit', ['academyCategory' => $category]),
        ];
    }

    private function routeParams(string $resource, Model $record): array
    {
        return match ($resource) {
            'courses' => ['academyCourse' => $record],
            'categories' => ['academyCategory' => $record],
            'lessons' => ['academyLesson' => $record],
            'prompts' => ['academyPromptTemplate' => $record],
            'packs' => ['academyPromptPack' => $record],
            'challenges' => ['academyChallenge' => $record],
            'badges' => ['academyBadge' => $record],
            default => [],
        };
    }

    private function categoryOptions(string $type): array
    {
        return AcademyCategory::query()
            ->where('type', $type)
            ->orderBy('order_num')
            ->orderBy('name')
            ->get()
            ->map(fn (AcademyCategory $category): array => ['value' => $category->id, 'label' => $category->name])
            ->prepend(['value' => '', 'label' => 'No category'])
            ->values()
            ->all();
    }

    private function promptOptions(): array
    {
        return AcademyPromptTemplate::query()
            ->orderBy('title')
            ->get()
            ->map(fn (AcademyPromptTemplate $prompt): array => ['value' => $prompt->id, 'label' => $prompt->title])
            ->values()
            ->all();
    }

    private function difficultyOptions(): array
    {
        return collect((array) config('academy.difficulty_levels', []))
            ->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])
            ->values()
            ->all();
    }

    private function courseDifficultyOptions(): array
    {
        return collect(['beginner', 'intermediate', 'advanced'])
            ->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])
            ->values()
            ->all();
    }

    private function accessOptions(): array
    {
        return [
            ['value' => 'free', 'label' => 'Free'],
            ['value' => 'creator', 'label' => 'Creator'],
            ['value' => 'pro', 'label' => 'Pro'],
        ];
    }

    private function courseAccessOptions(): array
    {
        return [
            ['value' => 'free', 'label' => 'Free'],
            ['value' => 'premium', 'label' => 'Premium'],
            ['value' => 'mixed', 'label' => 'Mixed'],
        ];
    }

    private function courseStatusOptions(): array
    {
        return [
            ['value' => 'draft', 'label' => 'Draft'],
            ['value' => 'review', 'label' => 'Review'],
            ['value' => 'published', 'label' => 'Published'],
            ['value' => 'archived', 'label' => 'Archived'],
        ];
    }

    private function syncPackItems(AcademyPromptPack $pack, array $promptIds): void
    {
        AcademyPromptPackItem::query()->where('pack_id', $pack->id)->delete();

        foreach (array_values($promptIds) as $index => $promptId) {
            AcademyPromptPackItem::query()->create([
                'pack_id' => $pack->id,
                'prompt_template_id' => (int) $promptId,
                'order_num' => $index,
            ]);
        }
    }
}
