<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use DOMDocument;
use DOMElement;
use DOMNode;
use App\Models\Artwork;
use App\Models\StoryComment;
use App\Models\Story;
use App\Models\StoryTag;
use App\Models\StoryView;
use App\Models\User;
use App\Services\SocialService;
use App\Services\StoryPublicationService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;

class StoryController extends Controller
{
    public function index(Request $request): View
    {
        $featured = Cache::remember('stories:feed:featured', 300, function () {
            return Story::published()
                ->with(['creator.profile', 'tags'])
                ->orderByDesc('likes_count')
                ->orderByDesc('published_at')
                ->first();
        });

        $trendingStories = Cache::remember('stories:feed:trending', 300, function () {
            $now = now();

            return Story::published()
                ->with(['creator.profile', 'tags'])
                ->latest('published_at')
                ->limit(60)
                ->get()
                ->sortByDesc(function (Story $story) use ($now): int {
                    $daysOld = (int) ($story->published_at?->diffInDays($now) ?? 30);
                    $recentBonus = max(0, 30 - $daysOld);

                    return ((int) $story->views)
                        + ((int) $story->likes_count * 3)
                        + ((int) $story->comments_count * 4)
                        + min(20, max(1, (int) $story->reading_time))
                        + $recentBonus;
                })
                ->take(6)
                ->values();
        });

        $latestStories = Story::published()
            ->with(['creator.profile', 'tags'])
            ->latest('published_at')
            ->paginate(12)
            ->withQueryString();

        return view('web.stories.index', [
            'featured' => $featured,
            'trendingStories' => $trendingStories,
            'latestStories' => $latestStories,
            'categories' => $this->storyCategories(),
            'page_title' => 'Creator Stories - Skinbase',
            'page_meta_description' => 'Long-form creator stories, tutorials, interviews and project breakdowns on Skinbase.',
            'page_canonical' => route('stories.index'),
            'page_robots' => 'index,follow',
            'breadcrumbs' => collect([
                (object) ['name' => 'Stories', 'url' => route('stories.index')],
            ]),
        ]);
    }

    public function show(Request $request, string $slug): View
    {
        $story = Story::published()
            ->with(['creator.profile', 'creator.statistics', 'tags'])
            ->where('slug', $slug)
            ->firstOrFail();

        $storyContentHtml = $this->renderStoryContent((string) $story->content);

        StoryView::query()->create([
            'story_id' => $story->id,
            'user_id' => $request->user()?->id,
            'ip_address' => (string) $request->ip(),
            'created_at' => now(),
        ]);

        $story->increment('views');

        $relatedStories = Story::published()
            ->with(['creator.profile', 'tags'])
            ->where('id', '!=', $story->id)
            ->where(function ($query) use ($story): void {
                $query->where('creator_id', $story->creator_id)
                    ->orWhereHas('tags', function ($tagsQuery) use ($story): void {
                        $tagsQuery->whereIn('story_tags.id', $story->tags->pluck('id')->all());
                    });
            })
            ->latest('published_at')
            ->limit(6)
            ->get();

        $relatedArtworks = collect();
        if ($story->creator_id !== null) {
            $relatedArtworks = Artwork::query()
                ->where('user_id', $story->creator_id)
                ->where('is_public', true)
                ->where('is_approved', true)
                ->latest('published_at')
                ->limit(4)
                ->get(['id', 'title', 'slug']);
        }

        $social = app(SocialService::class);
        $initialComments = Schema::hasTable('story_comments')
            ? StoryComment::query()
                ->with(['user.profile', 'approvedReplies'])
                ->where('story_id', $story->id)
                ->where('is_approved', true)
                ->whereNull('parent_id')
                ->whereNull('deleted_at')
                ->latest('created_at')
                ->limit(10)
                ->get()
                ->map(fn (StoryComment $comment) => $social->formatComment($comment, $request->user()?->id, true))
                ->values()
                ->all()
            : [];

        $storyState = $social->storyStateFor($request->user(), $story);

        $storySocialProps = [
            'story' => [
                'id' => (int) $story->id,
                'slug' => (string) $story->slug,
                'title' => (string) $story->title,
            ],
            'creator' => $story->creator ? [
                'id' => (int) $story->creator->id,
                'username' => (string) ($story->creator->username ?? ''),
                'display_name' => (string) ($story->creator->name ?: $story->creator->username ?: 'Creator'),
                'avatar_url' => AvatarUrl::forUser((int) $story->creator->id, $story->creator->profile?->avatar_hash, 128),
                'followers_count' => (int) ($story->creator->statistics?->followers_count ?? 0),
                'profile_url' => $story->creator->username ? '/@' . $story->creator->username : null,
            ] : null,
            'state' => $storyState,
            'comments' => $initialComments,
            'is_authenticated' => $request->user() !== null,
        ];

        return view('web.stories.show', [
            'story' => $story,
            'safeContent' => $storyContentHtml,
            'relatedStories' => $relatedStories,
            'relatedArtworks' => $relatedArtworks,
            'storySocialProps' => $storySocialProps,
            'page_title' => $story->title . ' - Skinbase Stories',
            'page_meta_description' => $story->excerpt ?: Str::limit(strip_tags((string) $story->content), 160),
            'page_canonical' => route('stories.show', $story->slug),
            'page_robots' => 'index,follow',
        ]);
    }

    public function create(Request $request): View
    {
        $tags = StoryTag::query()->orderBy('name')->limit(80)->get(['id', 'name', 'slug']);

        return view('web.stories.editor', [
            'story' => new Story([
                'status' => 'draft',
                'story_type' => 'creator_story',
                'content' => '',
            ]),
            'mode' => 'create',
            'tags' => $tags,
            'storyTypes' => $this->storyCategories(),
            'page_title' => 'Create Story - Skinbase',
            'page_meta_description' => 'Write and publish a creator story on Skinbase.',
            'page_robots' => 'noindex,nofollow',
        ]);
    }

    public function store(Request $request): RedirectResponse
    {
        $validated = $this->validateStoryPayload($request);
        $resolved = $this->resolveWorkflowState($request, $validated, true);
        $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []);

        $baseSlug = Str::slug($validated['title']);
        $slug = $baseSlug;
        $suffix = 2;
        while (Story::query()->where('slug', $slug)->exists()) {
            $slug = $baseSlug . '-' . $suffix;
            $suffix++;
        }

        $readingTime = $this->estimateReadingTimeFromSerializedContent($serializedContent);

        $story = Story::query()->create([
            'creator_id' => (int) $request->user()->id,
            'title' => $validated['title'],
            'slug' => $slug,
            'cover_image' => $validated['cover_image'] ?? null,
            'excerpt' => $validated['excerpt'] ?? null,
            'content' => $serializedContent,
            'story_type' => $validated['story_type'],
            'reading_time' => $readingTime,
            'status' => $resolved['status'],
            'published_at' => $resolved['published_at'],
            'scheduled_for' => $resolved['scheduled_for'],
            'meta_title' => $validated['meta_title'] ?? $validated['title'],
            'meta_description' => $validated['meta_description'] ?? Str::limit(strip_tags((string) $validated['excerpt']), 160),
            'canonical_url' => $validated['canonical_url'] ?? null,
            'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
            'submitted_for_review_at' => $resolved['status'] === 'pending_review' ? now() : null,
        ]);

        $story->tags()->sync($this->resolveTagIds($validated));

        if ($resolved['status'] === 'published') {
            app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
        }

        if ($resolved['status'] === 'published') {
            return redirect()->route('stories.show', ['slug' => $story->slug])
                ->with('status', 'Story published.');
        }

        return redirect()->route('creator.stories.edit', ['story' => $story->id])
            ->with('status', $resolved['status'] === 'pending_review' ? 'Story submitted for review.' : 'Draft saved.');
    }

    public function dashboard(Request $request): View
    {
        $creatorId = (int) $request->user()->id;

        $drafts = Story::query()
            ->where('creator_id', $creatorId)
            ->whereIn('status', ['draft', 'pending_review', 'rejected'])
            ->latest('updated_at')
            ->limit(20)
            ->get();

        $published = Story::query()
            ->where('creator_id', $creatorId)
            ->whereIn('status', ['published', 'scheduled'])
            ->latest('published_at')
            ->limit(20)
            ->get();

        $archived = Story::query()
            ->where('creator_id', $creatorId)
            ->where('status', 'archived')
            ->latest('updated_at')
            ->limit(20)
            ->get();

        return view('web.stories.dashboard', [
            'drafts' => $drafts,
            'publishedStories' => $published,
            'archivedStories' => $archived,
            'page_title' => 'My Stories - Skinbase',
            'page_meta_description' => 'Manage your drafts, published stories, and archived stories.',
            'page_robots' => 'noindex,nofollow',
        ]);
    }

    public function edit(Request $request, Story $story): View
    {
        abort_unless($this->canManageStory($request, $story), 403);

        return view('web.stories.editor', [
            'story' => $story->load('tags'),
            'mode' => 'edit',
            'tags' => StoryTag::query()->orderBy('name')->limit(120)->get(['id', 'name', 'slug']),
            'storyTypes' => $this->storyCategories(),
            'page_title' => 'Edit Story - Skinbase',
            'page_meta_description' => 'Update your story draft and publishing settings.',
            'page_robots' => 'noindex,nofollow',
        ]);
    }

    public function update(Request $request, Story $story): RedirectResponse
    {
        abort_unless($this->canManageStory($request, $story), 403);

        $wasPublished = $story->published_at !== null || $story->status === 'published';

        $validated = $this->validateStoryPayload($request);
        $resolved = $this->resolveWorkflowState($request, $validated, false);
        $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []);

        $story->update([
            'title' => $validated['title'],
            'excerpt' => $validated['excerpt'] ?? null,
            'cover_image' => $validated['cover_image'] ?? null,
            'content' => $serializedContent,
            'story_type' => $validated['story_type'],
            'reading_time' => $this->estimateReadingTimeFromSerializedContent($serializedContent),
            'status' => $resolved['status'],
            'published_at' => $resolved['published_at'],
            'scheduled_for' => $resolved['scheduled_for'],
            'meta_title' => $validated['meta_title'] ?? $validated['title'],
            'meta_description' => $validated['meta_description'] ?? Str::limit(strip_tags((string) $validated['excerpt']), 160),
            'canonical_url' => $validated['canonical_url'] ?? null,
            'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
            'submitted_for_review_at' => $resolved['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at,
        ]);

        if ($story->slug === '' || Str::slug($story->title) !== $story->slug) {
            $story->update(['slug' => $this->uniqueSlug($story->title, $story->id)]);
        }

        $story->tags()->sync($this->resolveTagIds($validated));

        if (! $wasPublished && $resolved['status'] === 'published') {
            app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
        }

        return back()->with('status', 'Story updated.');
    }

    public function destroy(Request $request, Story $story): RedirectResponse
    {
        abort_unless($this->canManageStory($request, $story), 403);

        $story->delete();

        return redirect()->route('creator.stories.index')->with('status', 'Story deleted.');
    }

    public function autosave(Request $request, Story $story): JsonResponse
    {
        abort_unless($this->canManageStory($request, $story), 403);

        $validated = $request->validate([
            'title' => ['nullable', 'string', 'max:255'],
            'excerpt' => ['nullable', 'string', 'max:500'],
            'cover_image' => ['nullable', 'string', 'max:500'],
            'content' => ['nullable'],
            'story_type' => ['nullable', Rule::in($this->storyCategories()->pluck('slug')->all())],
            'tags_csv' => ['nullable', 'string', 'max:500'],
        ]);

        $nextContent = array_key_exists('content', $validated)
            ? $this->normalizeStoryContent($validated['content'])
            : (string) $story->content;

        $story->fill([
            'title' => $validated['title'] ?? $story->title,
            'excerpt' => $validated['excerpt'] ?? $story->excerpt,
            'cover_image' => $validated['cover_image'] ?? $story->cover_image,
            'content' => $nextContent,
            'story_type' => $validated['story_type'] ?? $story->story_type,
            'reading_time' => $this->estimateReadingTimeFromSerializedContent($nextContent),
            'status' => in_array($story->status, ['pending_review', 'published', 'scheduled'], true) ? $story->status : 'draft',
        ]);
        $story->save();

        if (! empty($validated['tags_csv'])) {
            $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']]));
        }

        return response()->json([
            'ok' => true,
            'saved_at' => now()->toIso8601String(),
            'message' => 'Saved just now',
        ]);
    }

    public function submitForReview(Request $request, Story $story): RedirectResponse
    {
        abort_unless($this->canManageStory($request, $story), 403);

        $story->update([
            'status' => 'pending_review',
            'submitted_for_review_at' => now(),
            'rejected_reason' => null,
        ]);

        return back()->with('status', 'Story submitted for review.');
    }

    public function publishNow(Request $request, Story $story): RedirectResponse
    {
        abort_unless($this->canManageStory($request, $story), 403);

        app(StoryPublicationService::class)->publish($story, 'published', [
            'published_at' => now(),
        ]);

        return redirect()->route('stories.show', ['slug' => $story->slug])->with('status', 'Story published.');
    }

    public function preview(Request $request, Story $story): View
    {
        abort_unless($this->canManageStory($request, $story), 403);

        return view('web.stories.preview', [
            'story' => $story->load(['creator.profile', 'tags']),
            'safeContent' => $this->renderStoryContent((string) $story->content),
            'page_title' => 'Preview: ' . $story->title,
            'page_robots' => 'noindex,nofollow',
        ]);
    }

    public function analytics(Request $request, Story $story): View
    {
        abort_unless($this->canManageStory($request, $story), 403);

        $viewsLast7 = StoryView::query()
            ->where('story_id', $story->id)
            ->where('created_at', '>=', now()->subDays(7))
            ->count();

        $viewsLast30 = StoryView::query()
            ->where('story_id', $story->id)
            ->where('created_at', '>=', now()->subDays(30))
            ->count();

        return view('web.stories.analytics', [
            'story' => $story,
            'metrics' => [
                'views' => (int) $story->views,
                'likes' => (int) $story->likes_count,
                'comments' => (int) $story->comments_count,
                'read_time' => (int) $story->reading_time,
                'views_last_7_days' => $viewsLast7,
                'views_last_30_days' => $viewsLast30,
                'estimated_total_read_minutes' => (int) $story->views * max(1, (int) $story->reading_time),
            ],
            'page_title' => 'Story Analytics - ' . $story->title,
            'page_robots' => 'noindex,nofollow',
        ]);
    }

    public function searchArtworks(Request $request): JsonResponse
    {
        $q = trim((string) $request->query('q', ''));

        $artworks = Artwork::query()
            ->where('user_id', (int) $request->user()->id)
            ->when($q !== '', function ($query) use ($q): void {
                $query->where('title', 'like', '%' . $q . '%');
            })
            ->latest('id')
            ->limit(20)
            ->get(['id', 'title', 'slug', 'hash', 'thumb_ext'])
            ->map(function (Artwork $art): array {
                $thumbs = [
                    'xs' => $this->resolveArtworkThumbUrl($art, 'xs'),
                    'sm' => $this->resolveArtworkThumbUrl($art, 'sm'),
                    'md' => $this->resolveArtworkThumbUrl($art, 'md'),
                    'lg' => $this->resolveArtworkThumbUrl($art, 'lg'),
                    'xl' => $this->resolveArtworkThumbUrl($art, 'xl'),
                ];

                return [
                    'id' => $art->id,
                    'title' => $art->title,
                    'url' => route('art.show', ['id' => $art->id, 'slug' => $art->slug]),
                    'thumb' => $thumbs['sm'] ?? $thumbs['md'] ?? '',
                    'thumbs' => $thumbs,
                ];
            })
            ->values();

        return response()->json(['artworks' => $artworks]);
    }

    public function apiCreate(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'title' => ['nullable', 'string', 'max:255'],
            'cover_image' => ['nullable', 'string', 'max:500'],
            'excerpt' => ['nullable', 'string', 'max:500'],
            'story_type' => ['nullable', Rule::in($this->storyCategories()->pluck('slug')->all())],
            'content' => ['nullable'],
            'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
            'scheduled_for' => ['nullable', 'date'],
            'tags_csv' => ['nullable', 'string', 'max:500'],
            'meta_title' => ['nullable', 'string', 'max:255'],
            'meta_description' => ['nullable', 'string', 'max:300'],
            'canonical_url' => ['nullable', 'url', 'max:500'],
            'og_image' => ['nullable', 'string', 'max:500'],
        ]);

        $workflow = $this->resolveWorkflowState($request, array_merge([
            'status' => 'draft',
            'story_type' => 'creator_story',
            'title' => 'Untitled Story',
            'content' => ['type' => 'doc', 'content' => [['type' => 'paragraph']]],
        ], $validated), true);

        $title = trim((string) ($validated['title'] ?? 'Untitled Story'));
        if ($title === '') {
            $title = 'Untitled Story';
        }

        $slug = $this->uniqueSlug($title);
        $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []);

        $story = Story::query()->create([
            'creator_id' => (int) $request->user()->id,
            'title' => $title,
            'slug' => $slug,
            'cover_image' => $validated['cover_image'] ?? null,
            'excerpt' => $validated['excerpt'] ?? null,
            'content' => $serializedContent,
            'story_type' => $validated['story_type'] ?? 'creator_story',
            'reading_time' => $this->estimateReadingTimeFromSerializedContent($serializedContent),
            'status' => $workflow['status'],
            'published_at' => $workflow['published_at'],
            'scheduled_for' => $workflow['scheduled_for'],
            'meta_title' => $validated['meta_title'] ?? $title,
            'meta_description' => $validated['meta_description'] ?? Str::limit((string) ($validated['excerpt'] ?? ''), 160),
            'canonical_url' => $validated['canonical_url'] ?? null,
            'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
            'submitted_for_review_at' => $workflow['status'] === 'pending_review' ? now() : null,
        ]);

        if (! empty($validated['tags_csv'])) {
            $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']]));
        }

        if ($workflow['status'] === 'published') {
            app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
        }

        return response()->json([
            'ok' => true,
            'story_id' => (int) $story->id,
            'status' => $story->status,
            'message' => 'Story created.',
            'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
            'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
            'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
            'public_url' => route('stories.show', ['slug' => $story->slug]),
        ]);
    }

    public function apiUpdate(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'story_id' => ['required', 'integer', 'exists:stories,id'],
            'title' => ['nullable', 'string', 'max:255'],
            'cover_image' => ['nullable', 'string', 'max:500'],
            'excerpt' => ['nullable', 'string', 'max:500'],
            'story_type' => ['nullable', Rule::in($this->storyCategories()->pluck('slug')->all())],
            'content' => ['nullable'],
            'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
            'scheduled_for' => ['nullable', 'date'],
            'tags_csv' => ['nullable', 'string', 'max:500'],
            'meta_title' => ['nullable', 'string', 'max:255'],
            'meta_description' => ['nullable', 'string', 'max:300'],
            'canonical_url' => ['nullable', 'url', 'max:500'],
            'og_image' => ['nullable', 'string', 'max:500'],
        ]);

        $story = Story::query()->findOrFail((int) $validated['story_id']);
        abort_unless($this->canManageStory($request, $story), 403);
        $wasPublished = $story->published_at !== null || $story->status === 'published';

        $workflow = $this->resolveWorkflowState($request, array_merge([
            'status' => $story->status,
        ], $validated), false);

        $title = trim((string) ($validated['title'] ?? $story->title));
        if ($title === '') {
            $title = 'Untitled Story';
        }

        $serializedContent = array_key_exists('content', $validated)
            ? $this->normalizeStoryContent($validated['content'])
            : (string) $story->content;

        $story->update([
            'title' => $title,
            'slug' => $story->slug !== '' ? $story->slug : $this->uniqueSlug($title, (int) $story->id),
            'cover_image' => $validated['cover_image'] ?? $story->cover_image,
            'excerpt' => $validated['excerpt'] ?? $story->excerpt,
            'content' => $serializedContent,
            'story_type' => $validated['story_type'] ?? $story->story_type,
            'reading_time' => $this->estimateReadingTimeFromSerializedContent($serializedContent),
            'status' => $workflow['status'],
            'published_at' => $workflow['published_at'] ?? $story->published_at,
            'scheduled_for' => $workflow['scheduled_for'],
            'meta_title' => $validated['meta_title'] ?? $story->meta_title ?? $title,
            'meta_description' => $validated['meta_description'] ?? $story->meta_description,
            'canonical_url' => $validated['canonical_url'] ?? $story->canonical_url,
            'og_image' => $validated['og_image'] ?? $story->og_image,
            'submitted_for_review_at' => $workflow['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at,
        ]);

        if (! empty($validated['tags_csv'])) {
            $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']]));
        }

        if (! $wasPublished && $workflow['status'] === 'published') {
            app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
        }

        return response()->json([
            'ok' => true,
            'story_id' => (int) $story->id,
            'status' => $story->status,
            'message' => 'Story updated.',
            'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
            'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
            'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
            'public_url' => route('stories.show', ['slug' => $story->slug]),
        ]);
    }

    public function apiAutosave(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'story_id' => ['nullable', 'integer', 'exists:stories,id'],
            'title' => ['nullable', 'string', 'max:255'],
            'excerpt' => ['nullable', 'string', 'max:500'],
            'cover_image' => ['nullable', 'string', 'max:500'],
            'content' => ['nullable'],
            'story_type' => ['nullable', Rule::in($this->storyCategories()->pluck('slug')->all())],
            'tags_csv' => ['nullable', 'string', 'max:500'],
            'meta_title' => ['nullable', 'string', 'max:255'],
            'meta_description' => ['nullable', 'string', 'max:300'],
            'canonical_url' => ['nullable', 'url', 'max:500'],
            'og_image' => ['nullable', 'string', 'max:500'],
            'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
            'scheduled_for' => ['nullable', 'date'],
        ]);

        $story = null;
        if (! empty($validated['story_id'])) {
            $story = Story::query()->findOrFail((int) $validated['story_id']);
            abort_unless($this->canManageStory($request, $story), 403);
        }

        if (! $story) {
            $title = trim((string) ($validated['title'] ?? 'Untitled Story'));
            if ($title === '') {
                $title = 'Untitled Story';
            }

            $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []);
            $story = Story::query()->create([
                'creator_id' => (int) $request->user()->id,
                'title' => $title,
                'slug' => $this->uniqueSlug($title),
                'excerpt' => $validated['excerpt'] ?? null,
                'cover_image' => $validated['cover_image'] ?? null,
                'content' => $serializedContent,
                'story_type' => $validated['story_type'] ?? 'creator_story',
                'reading_time' => $this->estimateReadingTimeFromSerializedContent($serializedContent),
                'status' => 'draft',
                'meta_title' => $validated['meta_title'] ?? $title,
                'meta_description' => $validated['meta_description'] ?? Str::limit((string) ($validated['excerpt'] ?? ''), 160),
                'canonical_url' => $validated['canonical_url'] ?? null,
                'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
            ]);
        } else {
            $wasPublished = $story->published_at !== null || $story->status === 'published';
            $nextContent = array_key_exists('content', $validated)
                ? $this->normalizeStoryContent($validated['content'])
                : (string) $story->content;

            $nextStatus = $validated['status'] ?? $story->status;
            if (! in_array($nextStatus, ['pending_review', 'published', 'scheduled', 'archived', 'rejected'], true)) {
                $nextStatus = 'draft';
            }

            $story->fill([
                'title' => trim((string) ($validated['title'] ?? $story->title)) ?: 'Untitled Story',
                'excerpt' => $validated['excerpt'] ?? $story->excerpt,
                'cover_image' => $validated['cover_image'] ?? $story->cover_image,
                'content' => $nextContent,
                'story_type' => $validated['story_type'] ?? $story->story_type,
                'reading_time' => $this->estimateReadingTimeFromSerializedContent($nextContent),
                'status' => $nextStatus,
                'meta_title' => $validated['meta_title'] ?? $story->meta_title,
                'meta_description' => $validated['meta_description'] ?? $story->meta_description,
                'canonical_url' => $validated['canonical_url'] ?? $story->canonical_url,
                'og_image' => $validated['og_image'] ?? $story->og_image,
                'scheduled_for' => ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : $story->scheduled_for,
            ]);
            $story->save();

            if (! $wasPublished && $story->status === 'published') {
                if ($story->published_at === null) {
                    $story->forceFill(['published_at' => now()])->save();
                }

                app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
            }
        }

        if (! empty($validated['tags_csv'])) {
            $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']]));
        }

        return response()->json([
            'ok' => true,
            'story_id' => (int) $story->id,
            'saved_at' => now()->toIso8601String(),
            'message' => 'Saved just now',
            'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
            'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
            'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
            'public_url' => route('stories.show', ['slug' => $story->slug]),
        ]);
    }

    public function apiArtworks(Request $request): JsonResponse
    {
        return $this->searchArtworks($request);
    }

    public function apiUploadImage(Request $request): JsonResponse
    {
        return $this->uploadImage($request);
    }

    public function uploadImage(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'image' => ['required', 'image', 'max:10240'],
        ]);

        /** @var UploadedFile $file */
        $file = $validated['image'];
        $sourcePath = $file->getRealPath() ?: $file->getPathname();

        if ($sourcePath === '' || ! is_file($sourcePath)) {
            return response()->json([
                'message' => 'Unable to read uploaded image. Please try again.',
            ], 422);
        }

        $disk = Storage::disk('public');
        $base = 'stories/' . now()->format('Y/m') . '/' . Str::uuid();
        $extension = strtolower((string) ($file->guessExtension() ?: $file->extension() ?: 'jpg'));
        $originalPath = $base . '/original.' . $extension;
        $thumbnailPath = $base . '/thumbnail.webp';
        $mediumPath = $base . '/medium.webp';

        $stream = fopen($sourcePath, 'rb');
        if ($stream === false) {
            return response()->json([
                'message' => 'Unable to process uploaded image. Please try again.',
            ], 422);
        }

        try {
            $disk->put($originalPath, $stream);
        } finally {
            fclose($stream);
        }

        $storedThumbnails = false;
        if (class_exists(ImageManager::class)) {
            try {
                $manager = extension_loaded('gd')
                    ? new ImageManager(new GdDriver())
                    : new ImageManager(new ImagickDriver());

                $image = $manager->read($sourcePath);

                $thumb = $image->scaleDown(width: 420);
                $disk->put($thumbnailPath, (string) $thumb->encode(new WebpEncoder(82)));

                $medium = $image->scaleDown(width: 1200);
                $disk->put($mediumPath, (string) $medium->encode(new WebpEncoder(85)));
                $storedThumbnails = true;
            } catch (\Throwable) {
                $storedThumbnails = false;
            }
        }

        if (! $storedThumbnails) {
            $disk->copy($originalPath, $thumbnailPath);
            $disk->copy($originalPath, $mediumPath);
        }

        return response()->json([
            'thumbnail_url' => $disk->url($thumbnailPath),
            'medium_url' => $disk->url($mediumPath),
            'original_url' => $disk->url($originalPath),
        ]);
    }

    public function tag(string $tag): View
    {
        $storyTag = StoryTag::query()->where('slug', $tag)->firstOrFail();

        $stories = Story::published()
            ->with(['creator.profile', 'tags'])
            ->whereHas('tags', fn ($query) => $query->where('story_tags.id', $storyTag->id))
            ->latest('published_at')
            ->paginate(12)
            ->withQueryString();

        return view('web.stories.tag', [
            'storyTag' => $storyTag,
            'stories' => $stories,
            'page_title' => '#' . $storyTag->name . ' Stories - Skinbase',
            'page_meta_description' => 'Creator stories tagged with ' . $storyTag->name . '.',
            'page_canonical' => route('stories.tag', $storyTag->slug),
        ]);
    }

    public function creator(string $username): View
    {
        $creator = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)])->firstOrFail();

        $stories = Story::published()
            ->with(['creator.profile', 'tags'])
            ->where('creator_id', $creator->id)
            ->latest('published_at')
            ->paginate(12)
            ->withQueryString();

        return view('web.stories.creator', [
            'creator' => $creator,
            'stories' => $stories,
            'page_title' => 'Stories by @' . $creator->username . ' - Skinbase',
            'page_meta_description' => 'Read stories published by @' . $creator->username . '.',
            'page_canonical' => route('stories.creator', $creator->username),
        ]);
    }

    public function category(string $category): View
    {
        $normalized = strtolower($category);
        $valid = $this->storyCategories()->pluck('slug')->all();

        abort_unless(in_array($normalized, $valid, true), 404);

        $stories = Story::published()
            ->with(['creator.profile', 'tags'])
            ->where('story_type', $normalized)
            ->latest('published_at')
            ->paginate(12)
            ->withQueryString();

        return view('web.stories.category', [
            'category' => $normalized,
            'stories' => $stories,
            'categories' => $this->storyCategories(),
            'page_title' => ucfirst(str_replace('_', ' ', $normalized)) . ' Stories - Skinbase',
            'page_meta_description' => 'Browse ' . str_replace('_', ' ', $normalized) . ' stories on Skinbase.',
            'page_canonical' => route('stories.category', $normalized),
        ]);
    }

    private function storyCategories(): Collection
    {
        return collect([
            ['slug' => 'tutorial', 'name' => 'Tutorials'],
            ['slug' => 'creator_story', 'name' => 'Creator Stories'],
            ['slug' => 'interview', 'name' => 'Interviews'],
            ['slug' => 'announcement', 'name' => 'Announcements'],
            ['slug' => 'resource', 'name' => 'Resources'],
            ['slug' => 'project_breakdown', 'name' => 'Project Breakdowns'],
        ]);
    }

    private function validateStoryPayload(Request $request): array
    {
        return $request->validate([
            'title' => ['required', 'string', 'max:255'],
            'cover_image' => ['nullable', 'string', 'max:500'],
            'excerpt' => ['nullable', 'string', 'max:500'],
            'story_type' => ['required', Rule::in($this->storyCategories()->pluck('slug')->all())],
            'content' => ['required'],
            'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
            'scheduled_for' => ['nullable', 'date'],
            'tags' => ['nullable', 'array'],
            'tags.*' => ['integer', 'exists:story_tags,id'],
            'tags_csv' => ['nullable', 'string', 'max:500'],
            'meta_title' => ['nullable', 'string', 'max:255'],
            'meta_description' => ['nullable', 'string', 'max:300'],
            'canonical_url' => ['nullable', 'url', 'max:500'],
            'og_image' => ['nullable', 'string', 'max:500'],
        ]);
    }

    private function resolveWorkflowState(Request $request, array $validated, bool $isCreate): array
    {
        $action = (string) $request->input('submit_action', 'save_draft');
        $status = (string) ($validated['status'] ?? 'draft');
        $scheduledFor = ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : null;
        $publishedAt = null;

        if ($action === 'submit_review') {
            $status = 'pending_review';
        } elseif ($action === 'publish_now') {
            $status = 'published';
            $publishedAt = now();
        } elseif ($status === 'scheduled' && $scheduledFor !== null) {
            $publishedAt = $scheduledFor;
        }

        if (! $isCreate && $status === 'published' && $publishedAt === null) {
            $publishedAt = now();
        }

        return [
            'status' => $status,
            'published_at' => $publishedAt,
            'scheduled_for' => $status === 'scheduled' ? $scheduledFor : null,
        ];
    }

    private function resolveTagIds(array $validated): array
    {
        $tagIds = collect($validated['tags'] ?? [])->map(fn ($id) => (int) $id)->filter()->values();

        $csv = (string) ($validated['tags_csv'] ?? '');
        if ($csv !== '') {
            $names = collect(explode(',', $csv))
                ->map(fn ($part) => trim($part))
                ->filter()
                ->unique()
                ->take(12)
                ->values();

            $extraIds = $names->map(function (string $name): int {
                $slug = Str::slug($name);

                $tag = StoryTag::query()->firstOrCreate(
                    ['slug' => $slug],
                    ['name' => Str::title($name)]
                );

                return (int) $tag->id;
            });

            $tagIds = $tagIds->merge($extraIds)->unique()->values();
        }

        return $tagIds->all();
    }

    private function renderStoryContent(string $raw): string
    {
        $decoded = json_decode($raw, true);
        if (is_array($decoded) && ($decoded['type'] ?? null) === 'doc') {
            return $this->renderTipTapDocument($decoded);
        }

        return $this->sanitizeStoryContent($raw);
    }

    private function normalizeStoryContent(mixed $content): string
    {
        if (is_array($content)) {
            if (($content['type'] ?? null) !== 'doc') {
                $content = [
                    'type' => 'doc',
                    'content' => [
                        ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => trim((string) json_encode($content))]]],
                    ],
                ];
            }

            return (string) json_encode($content, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        }

        if (is_string($content)) {
            $decoded = json_decode($content, true);
            if (is_array($decoded) && ($decoded['type'] ?? null) === 'doc') {
                return (string) json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            }

            $text = trim(strip_tags($content));

            return (string) json_encode([
                'type' => 'doc',
                'content' => [
                    ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => $text]]],
                ],
            ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        }

        return (string) json_encode([
            'type' => 'doc',
            'content' => [['type' => 'paragraph']],
        ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }

    private function estimateReadingTimeFromSerializedContent(string $serializedContent): int
    {
        $decoded = json_decode($serializedContent, true);
        if (is_array($decoded)) {
            $text = trim($this->extractTipTapText($decoded));
            if ($text !== '') {
                return max(1, (int) ceil(str_word_count($text) / 200));
            }
        }

        return max(1, (int) ceil(str_word_count(strip_tags($serializedContent)) / 200));
    }

    private function extractTipTapText(array $node): string
    {
        $type = (string) ($node['type'] ?? '');
        if ($type === 'text') {
            return (string) ($node['text'] ?? '');
        }

        $content = $node['content'] ?? [];
        if (! is_array($content)) {
            return '';
        }

        $chunks = [];
        foreach ($content as $child) {
            if (is_array($child)) {
                $chunks[] = $this->extractTipTapText($child);
            }
        }

        $joined = implode(' ', array_filter($chunks, fn ($part) => $part !== ''));

        return in_array($type, ['paragraph', 'heading', 'blockquote', 'codeBlock', 'listItem'], true)
            ? $joined . "\n"
            : $joined;
    }

    private function renderTipTapDocument(array $doc): string
    {
        $content = $doc['content'] ?? [];
        if (! is_array($content)) {
            return '';
        }

        $html = '';
        foreach ($content as $node) {
            if (is_array($node)) {
                $html .= $this->renderTipTapNode($node);
            }
        }

        return $this->sanitizeStoryContent($html);
    }

    private function renderTipTapNode(array $node): string
    {
        $type = (string) ($node['type'] ?? '');
        $attrs = is_array($node['attrs'] ?? null) ? $node['attrs'] : [];
        $content = is_array($node['content'] ?? null) ? $node['content'] : [];

        if ($type === 'text') {
            $text = e((string) ($node['text'] ?? ''));
            $marks = is_array($node['marks'] ?? null) ? $node['marks'] : [];

            foreach ($marks as $mark) {
                $markType = (string) ($mark['type'] ?? '');
                if ($markType === 'bold') {
                    $text = '<strong>' . $text . '</strong>';
                } elseif ($markType === 'italic') {
                    $text = '<em>' . $text . '</em>';
                } elseif ($markType === 'code') {
                    $text = '<code>' . $text . '</code>';
                } elseif ($markType === 'link') {
                    $href = (string) (($mark['attrs']['href'] ?? '') ?: '');
                    if ($this->isSafeUrl($href)) {
                        $text = '<a href="' . e($href) . '" target="_blank" rel="nofollow ugc noopener">' . $text . '</a>';
                    }
                }
            }

            return $text;
        }

        $inner = '';
        foreach ($content as $child) {
            if (is_array($child)) {
                $inner .= $this->renderTipTapNode($child);
            }
        }

        return match ($type) {
            'paragraph' => '<p>' . $inner . '</p>',
            'heading' => '<h' . max(1, min(4, (int) ($attrs['level'] ?? 2))) . '>' . $inner . '</h' . max(1, min(4, (int) ($attrs['level'] ?? 2))) . '>',
            'blockquote' => '<blockquote>' . $inner . '</blockquote>',
            'bulletList' => '<ul>' . $inner . '</ul>',
            'orderedList' => '<ol>' . $inner . '</ol>',
            'listItem' => '<li>' . $inner . '</li>',
            'horizontalRule' => '<hr>',
            'codeBlock' => $this->renderCodeBlockNode($attrs, $node),
            'image' => $this->renderImageNode($attrs),
            'artworkEmbed' => $this->renderArtworkEmbedNode($attrs),
            'galleryBlock' => $this->renderGalleryBlockNode($attrs),
            'videoEmbed' => $this->renderVideoEmbedNode($attrs),
            'downloadAsset' => $this->renderDownloadAssetNode($attrs),
            default => $inner,
        };
    }

    private function renderCodeBlockNode(array $attrs, array $node): string
    {
        $language = strtolower(trim((string) ($attrs['language'] ?? '')));
        $language = preg_match('/^[a-z0-9_+-]+$/', $language) === 1 ? $language : '';
        $escapedCode = e($this->extractTipTapText($node));

        $preAttributes = $language !== ''
            ? ' data-language="' . e($language) . '"'
            : '';

        $codeAttributes = $language !== ''
            ? ' class="language-' . e($language) . '" data-language="' . e($language) . '"'
            : '';

        return '<pre' . $preAttributes . '><code' . $codeAttributes . '>' . $escapedCode . '</code></pre>';
    }

    private function renderImageNode(array $attrs): string
    {
        $src = (string) ($attrs['src'] ?? '');
        if (! $this->isSafeUrl($src)) {
            return '';
        }

        return '<img src="' . e($src) . '" alt="' . e((string) ($attrs['alt'] ?? 'Story image')) . '" loading="lazy" />';
    }

    private function renderArtworkEmbedNode(array $attrs): string
    {
        $id = (int) ($attrs['artworkId'] ?? 0);
        if ($id <= 0) {
            return '';
        }

        $art = Artwork::query()->find($id);
        if (! $art) {
            return '<div class="my-6 rounded-xl border border-gray-700 bg-gray-900/70 p-4 text-sm text-gray-300">Embedded artwork #' . $id . ' is unavailable.</div>';
        }

        return '<div class="my-6 overflow-hidden rounded-xl border border-gray-700 bg-gray-900/70">'
            . '<a class="block" href="' . e(route('art.show', ['id' => $art->id, 'slug' => $art->slug])) . '">'
            . '<img class="h-52 w-full object-cover" loading="lazy" src="' . e($this->resolveArtworkThumbUrl($art, 'md') ?? '') . '" alt="' . e($art->title) . '" />'
            . '<div class="p-4 text-sm text-gray-100"><span class="font-semibold">' . e($art->title) . '</span><span class="ml-2 text-sky-300">View artwork</span></div>'
            . '</a></div>';
    }

    private function resolveArtworkThumbUrl(Artwork $art, string $size): ?string
    {
        $sized = $art->thumbUrl($size);
        if (is_string($sized) && $sized !== '') {
            return $sized;
        }

        $fallback = $art->thumb_url;
        if (! is_string($fallback) || $fallback === '') {
            return null;
        }

        return preg_replace('#/(thumb|xs|sm|md|lg|xl)/#', '/' . $size . '/', $fallback) ?: $fallback;
    }

    private function renderGalleryBlockNode(array $attrs): string
    {
        $images = $attrs['images'] ?? [];
        if (! is_array($images) || $images === []) {
            return '';
        }

        $items = collect($images)
            ->filter(fn ($src) => is_string($src) && $this->isSafeUrl($src))
            ->take(8)
            ->map(fn (string $src) => '<img class="rounded-lg object-cover" loading="lazy" src="' . e($src) . '" alt="Gallery image" />')
            ->implode('');

        if ($items === '') {
            return '';
        }

        return '<div class="my-6 grid grid-cols-2 gap-3 rounded-xl border border-gray-700 bg-gray-900/60 p-3">' . $items . '</div>';
    }

    private function renderVideoEmbedNode(array $attrs): string
    {
        $src = (string) ($attrs['src'] ?? '');
        if (! $this->isAllowedEmbedUrl($src)) {
            return '';
        }

        $title = e((string) ($attrs['title'] ?? 'Embedded video'));

        return '<figure class="my-6 overflow-hidden rounded-xl border border-gray-700 bg-gray-900/70">'
            . '<iframe class="aspect-video w-full" src="' . e($src) . '" title="' . $title . '" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>'
            . '</figure>';
    }

    private function renderDownloadAssetNode(array $attrs): string
    {
        $url = (string) ($attrs['url'] ?? '');
        if (! $this->isSafeUrl($url)) {
            return '';
        }

        $label = e((string) ($attrs['label'] ?? 'Download asset'));

        return '<div class="my-6 rounded-xl border border-gray-700 bg-gray-900/70 p-4">'
            . '<a class="inline-flex items-center rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200" href="' . e($url) . '" target="_blank" rel="nofollow ugc noopener" download>' . $label . '</a>'
            . '</div>';
    }

    private function sanitizeStoryContent(string $raw): string
    {
        $html = trim($raw);
        if ($html === '') {
            return '';
        }

        $html = preg_replace('/<(script|style)\\b[^>]*>.*?<\\/\\1>/is', '', $html) ?? '';

        libxml_use_internal_errors(true);
        $document = new DOMDocument('1.0', 'UTF-8');
        $document->loadHTML('<?xml encoding="utf-8" ?><div id="story-sanitize-root">' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);

        $root = $document->getElementById('story-sanitize-root');
        if (! $root instanceof DOMElement) {
            libxml_clear_errors();
            return strip_tags($html);
        }

        $allowedTags = [
            'p', 'br', 'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'strong', 'em', 'b', 'i', 'u',
            'blockquote', 'pre', 'code', 'hr', 'a', 'img', 'div', 'span', 'figure', 'figcaption', 'iframe',
        ];

        $this->sanitizeDomNode($root, $allowedTags);

        $clean = '';
        foreach ($root->childNodes as $child) {
            $clean .= $document->saveHTML($child);
        }

        libxml_clear_errors();

        return $clean;
    }

    private function sanitizeDomNode(DOMNode $node, array $allowedTags): void
    {
        $children = [];
        foreach ($node->childNodes as $child) {
            $children[] = $child;
        }

        foreach ($children as $child) {
            if ($child instanceof DOMElement) {
                $tag = strtolower($child->tagName);

                if (! in_array($tag, $allowedTags, true)) {
                    $this->unwrapNode($child);
                    continue;
                }

                $this->sanitizeElementAttributes($child);
            }

            if ($child->parentNode !== null) {
                $this->sanitizeDomNode($child, $allowedTags);
            }
        }
    }

    private function sanitizeElementAttributes(DOMElement $element): void
    {
        $tag = strtolower($element->tagName);
        $allowedByTag = [
            'a' => ['href', 'title', 'target', 'rel'],
            'img' => ['src', 'alt', 'title', 'loading'],
            'iframe' => ['src', 'title', 'allow', 'allowfullscreen', 'frameborder', 'referrerpolicy'],
            'div' => ['class'],
            'span' => ['class'],
            'p' => ['class'],
            'pre' => ['class'],
            'code' => ['class'],
            'figure' => ['class'],
            'figcaption' => ['class'],
            'h1' => ['class'],
            'h2' => ['class'],
            'h3' => ['class'],
            'h4' => ['class'],
            'ul' => ['class'],
            'ol' => ['class'],
            'li' => ['class'],
            'blockquote' => ['class'],
        ];

        $allowed = $allowedByTag[$tag] ?? [];
        $toRemove = [];

        foreach ($element->attributes as $attribute) {
            $name = strtolower($attribute->name);
            $value = trim((string) $attribute->value);

            if (str_starts_with($name, 'on') || $name === 'style' || ! in_array($name, $allowed, true)) {
                $toRemove[] = $attribute->name;
                continue;
            }

            if (($name === 'href' || $name === 'src') && ! $this->isSafeUrl($value)) {
                $toRemove[] = $attribute->name;
                continue;
            }

            if ($tag === 'iframe' && $name === 'src' && ! $this->isAllowedEmbedUrl($value)) {
                $toRemove[] = $attribute->name;
            }
        }

        foreach ($toRemove as $name) {
            $element->removeAttribute($name);
        }

        if ($tag === 'a' && $element->hasAttribute('href')) {
            $element->setAttribute('rel', 'nofollow ugc noopener');
            if ($element->getAttribute('target') === '') {
                $element->setAttribute('target', '_blank');
            }
        }

        if ($tag === 'img' && ! $element->hasAttribute('loading')) {
            $element->setAttribute('loading', 'lazy');
        }

        if ($tag === 'iframe' && ! $element->hasAttribute('src')) {
            $this->unwrapNode($element);
        }
    }

    private function isSafeUrl(string $value): bool
    {
        if ($value === '') {
            return false;
        }

        $lower = strtolower($value);
        if (str_starts_with($lower, 'javascript:') || str_starts_with($lower, 'data:')) {
            return false;
        }

        return str_starts_with($lower, 'http://')
            || str_starts_with($lower, 'https://')
            || str_starts_with($lower, '/')
            || str_starts_with($lower, '#');
    }

    private function isAllowedEmbedUrl(string $value): bool
    {
        if (! $this->isSafeUrl($value)) {
            return false;
        }

        $host = strtolower((string) (parse_url($value, PHP_URL_HOST) ?? ''));

        return $host === 'www.youtube.com'
            || $host === 'youtube.com'
            || $host === 'youtu.be'
            || $host === 'player.vimeo.com'
            || $host === 'vimeo.com';
    }

    private function unwrapNode(DOMNode $node): void
    {
        $parent = $node->parentNode;
        if ($parent === null) {
            return;
        }

        while ($node->firstChild !== null) {
            $parent->insertBefore($node->firstChild, $node);
        }

        $parent->removeChild($node);
    }

    private function canManageStory(Request $request, Story $story): bool
    {
        $user = $request->user();
        if ($user === null) {
            return false;
        }

        return (int) $story->creator_id === (int) $user->id
            || $user->isAdmin()
            || $user->isModerator();
    }

    private function uniqueSlug(string $title, ?int $ignoreId = null): string
    {
        $baseSlug = Str::slug($title);
        $slug = $baseSlug;
        $suffix = 2;

        while (Story::query()
            ->when($ignoreId !== null, fn ($q) => $q->where('id', '!=', $ignoreId))
            ->where('slug', $slug)
            ->exists()) {
            $slug = $baseSlug . '-' . $suffix;
            $suffix++;
        }

        return $slug;
    }
}
