using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;

namespace Tiku.Infrastructure.Content;

public sealed class ContentManagementService(
    TikuDbContext dbContext,
    IQuestionReferenceService questionReferenceService,
    ICurrentAccessContext currentAccessContext) : IContentManagementService
{
    private const int DefaultLimit = 100;
    private const int MaxLimit = 1000;

    public async Task<CatalogList<ContentEntryManagementItem>> GetEntriesAsync(
        ContentManagementActor actor,
        ContentManagementFilter filter,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var query = dbContext.ContentEntries
            .AsNoTracking()
            .Where(entry => entry.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                entry => entry.CreatedBy == actor.UserId,
                entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value));

        if (!filter.IncludeInactive)
        {
            query = query.Where(entry => entry.IsActive);
        }

        if (filter.RegionId.HasValue)
        {
            query = query.Where(entry => entry.RegionId == filter.RegionId.Value);
        }

        if (TryParse(filter.EntryType, out ContentEntryType entryType))
        {
            query = query.Where(entry => entry.EntryType == entryType);
        }

        if (!string.IsNullOrWhiteSpace(filter.Keyword))
        {
            var keyword = filter.Keyword.Trim();
            query = query.Where(entry =>
                entry.Name.Contains(keyword) ||
                entry.EntryKey.Contains(keyword) ||
                (entry.Description != null && entry.Description.Contains(keyword)));
        }

        var items = await query
            .OrderBy(entry => entry.SortOrder)
            .ThenBy(entry => entry.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(entry => ToEntryItem(entry))
            .ToArrayAsync(cancellationToken);

        return new CatalogList<ContentEntryManagementItem>(items);
    }

    public async Task<ContentManagementResult<ContentEntryManagementItem>> UpsertEntryAsync(
        ContentManagementActor actor,
        UpsertContentEntryCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
        await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);

        var entryKey = Normalize(command.EntryKey) ??
                       Normalize(command.Id?.ToString("N")) ??
                       Guid.NewGuid().ToString("N");
        var entry = await ResolveEntityAsync(
            dbContext.ContentEntries,
            actor.TenantId,
            command.Id,
            item => item.EntryKey == entryKey,
            cancellationToken);

        var isNew = entry is null;
        if (command.Id.HasValue && (entry is null || entry.Id != command.Id.Value))
        {
            throw new ContentManagementException("Content entry was not found.", "entry_not_found");
        }

        if (entry is not null && !scope.AllowsResource(actor.UserId, entry.CreatedBy, entry.RegionId))
        {
            throw new ContentManagementException("Content entry was not found.", "entry_not_found");
        }

        if (entry is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
        {
            throw new ContentManagementException("Content entry was not found.", "entry_not_found");
        }

        entry ??= new ContentEntry
        {
            Id = command.Id ?? Guid.NewGuid(),
            TenantId = actor.TenantId,
            EntryKey = entryKey,
            CreatedBy = actor.UserId
        };

        entry.RegionId = command.RegionId;
        entry.LegacyId = Normalize(command.LegacyId);
        entry.Name = command.Name.Trim();
        entry.EntryType = Parse(command.EntryType, ContentEntryType.QuestionPractice, "entry_type_invalid");
        entry.Icon = Normalize(command.Icon);
        entry.Route = Normalize(command.Route);
        entry.Description = Normalize(command.Description);
        entry.Visibility = Parse(command.Visibility, ContentVisibility.Public, "visibility_invalid");
        entry.AccessRules = JsonObjectOrDefault(command.AccessRules);
        entry.LayoutConfig = JsonObjectOrDefault(command.LayoutConfig);
        entry.SortOrder = command.Order ?? 0;
        entry.IsActive = command.IsActive ?? true;
        entry.UpdatedBy = actor.UserId;

        if (isNew)
        {
            dbContext.ContentEntries.Add(entry);
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<ContentEntryManagementItem>(ToEntryItem(entry));
    }

    public async Task<CatalogList<ContentNodeManagementItem>> GetNodesAsync(
        ContentManagementActor actor,
        ContentManagementFilter filter,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        if (!filter.EntryId.HasValue)
        {
            throw new ContentManagementException("entryId is required.", "entry_id_required");
        }

        await AssertEntryAsync(actor, scope, filter.EntryId, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var query = dbContext.ContentNodes
            .AsNoTracking()
            .Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value)
            .ApplyDataScope(
                scope,
                node => node.CreatedBy == actor.UserId,
                node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value));

        if (!filter.IncludeInactive)
        {
            query = query.Where(node => node.IsActive);
        }

        if (filter.RegionId.HasValue)
        {
            query = query.Where(node => node.RegionId == filter.RegionId.Value);
        }

        if (filter.ParentId is not null)
        {
            if (string.Equals(filter.ParentId, "root", StringComparison.OrdinalIgnoreCase))
            {
                query = query.Where(node => node.ParentId == null);
            }
            else if (Guid.TryParse(filter.ParentId, out var parentId))
            {
                query = query.Where(node => node.ParentId == parentId);
            }
        }

        if (TryParse(filter.MarkerType, out ContentMarkerType markerType))
        {
            query = query.Where(node => node.MarkerType == markerType);
        }

        if (!string.IsNullOrWhiteSpace(filter.Keyword))
        {
            var keyword = filter.Keyword.Trim();
            query = query.Where(node =>
                node.Name.Contains(keyword) ||
                (node.NodeKey != null && node.NodeKey.Contains(keyword)));
        }

        query = string.Equals(filter.Mode, "flat", StringComparison.OrdinalIgnoreCase)
            ? query.OrderBy(node => node.Path).ThenBy(node => node.SortOrder)
            : query.OrderBy(node => node.SortOrder).ThenBy(node => node.CreatedAt);

        var items = await query
            .Take(ResolveLimit(filter.Limit))
            .Select(node => ToNodeItem(node))
            .ToArrayAsync(cancellationToken);

        return new CatalogList<ContentNodeManagementItem>(items);
    }

    public async Task<ContentManagementResult<ContentNodeManagementItem>> UpsertNodeAsync(
        ContentManagementActor actor,
        UpsertContentNodeCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
        await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
        await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
        await AssertNodeAsync(actor, scope, command.ParentId, cancellationToken);

        var nodeKey = Normalize(command.NodeKey) ??
                      Normalize(command.Id?.ToString("N")) ??
                      Guid.NewGuid().ToString("N");
        var node = await ResolveEntityAsync(
            dbContext.ContentNodes,
            actor.TenantId,
            command.Id,
            item => item.EntryId == command.EntryId && item.NodeKey == nodeKey,
            cancellationToken);

        var isNew = node is null;
        if (command.Id.HasValue && (node is null || node.Id != command.Id.Value))
        {
            throw new ContentManagementException("Content node was not found.", "node_not_found");
        }

        if (node is not null && !scope.AllowsResource(actor.UserId, node.CreatedBy, node.RegionId))
        {
            throw new ContentManagementException("Content node was not found.", "node_not_found");
        }

        if (node is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
        {
            throw new ContentManagementException("Content node was not found.", "node_not_found");
        }

        node ??= new ContentNode
        {
            Id = command.Id ?? Guid.NewGuid(),
            TenantId = actor.TenantId,
            EntryId = command.EntryId,
            NodeKey = nodeKey,
            CreatedBy = actor.UserId
        };

        var path = await BuildNodePathAsync(actor.TenantId, command.EntryId, node.Id, command.ParentId, cancellationToken);
        node.EntryId = command.EntryId;
        node.RegionId = command.RegionId;
        node.ParentId = command.ParentId;
        node.LegacyId = Normalize(command.LegacyId);
        node.Name = command.Name.Trim();
        node.NodeType = Parse(command.NodeType, ContentNodeType.Category, "node_type_invalid");
        node.MarkerType = ParseNullable<ContentMarkerType>(command.MarkerType, "marker_type_invalid");
        node.MarkerConfig = JsonObjectOrDefault(command.MarkerConfig);
        node.Path = path.Path;
        node.Depth = path.Depth;
        node.SortOrder = command.Order ?? 0;
        node.IsActive = command.IsActive ?? true;
        node.IsSelectable = command.IsSelectable ?? true;
        node.IsLeaf = command.IsLeaf ?? false;
        node.AccessRules = JsonObjectOrDefault(command.AccessRules);
        node.Metadata = JsonObjectOrDefault(command.Metadata);
        node.UpdatedBy = actor.UserId;

        if (isNew)
        {
            dbContext.ContentNodes.Add(node);
        }

        if (command.ParentId.HasValue)
        {
            var parent = await dbContext.ContentNodes.SingleOrDefaultAsync(
                item => item.TenantId == actor.TenantId && item.Id == command.ParentId.Value,
                cancellationToken);
            if (parent is not null)
            {
                parent.IsLeaf = false;
            }
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<ContentNodeManagementItem>(ToNodeItem(node));
    }

    public async Task<CatalogList<QuestionCollectionManagementItem>> GetCollectionsAsync(
        ContentManagementActor actor,
        ContentManagementFilter filter,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var query = dbContext.QuestionCollections
            .AsNoTracking()
            .Where(collection => collection.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                collection => collection.CreatedBy == actor.UserId,
                collection => collection.RegionId.HasValue && regionIds.Contains(collection.RegionId.Value));

        if (!filter.IncludeInactive)
        {
            query = query.Where(collection => collection.Status == ContentStatus.Active);
        }

        if (filter.RegionId.HasValue)
        {
            query = query.Where(collection => collection.RegionId == filter.RegionId.Value);
        }

        if (filter.EntryId.HasValue)
        {
            query = query.Where(collection => collection.EntryId == filter.EntryId.Value);
        }

        if (filter.NodeId.HasValue)
        {
            query = query.Where(collection => collection.NodeId == filter.NodeId.Value);
        }

        if (TryParse(filter.CollectionType, out QuestionCollectionType collectionType))
        {
            query = query.Where(collection => collection.CollectionType == collectionType);
        }

        if (!string.IsNullOrWhiteSpace(filter.Keyword))
        {
            var keyword = filter.Keyword.Trim();
            query = query.Where(collection => collection.Name.Contains(keyword));
        }

        var items = await query
            .OrderBy(collection => collection.SortOrder)
            .ThenBy(collection => collection.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(collection => ToCollectionItem(collection))
            .ToArrayAsync(cancellationToken);

        return new CatalogList<QuestionCollectionManagementItem>(items);
    }

    public async Task<ContentManagementResult<QuestionCollectionManagementItem>> UpsertCollectionAsync(
        ContentManagementActor actor,
        UpsertQuestionCollectionCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
        await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
        await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
        await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
        await AssertReferenceAsync<Subject>(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken);
        await AssertReferenceAsync<Category>(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken);
        await AssertReferenceAsync<QuestionBank>(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken);

        var collection = await ResolveEntityByIdOrLegacyAsync(
            dbContext.QuestionCollections,
            actor.TenantId,
            command.Id,
            command.LegacyId,
            cancellationToken);

        var isNew = collection is null;
        if (command.Id.HasValue && (collection is null || collection.Id != command.Id.Value))
        {
            throw new ContentManagementException("Collection was not found.", "collection_not_found");
        }

        if (collection is not null && !scope.AllowsResource(actor.UserId, collection.CreatedBy, collection.RegionId))
        {
            throw new ContentManagementException("Collection was not found.", "collection_not_found");
        }

        if (collection is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
        {
            throw new ContentManagementException("Collection was not found.", "collection_not_found");
        }

        collection ??= new QuestionCollection
        {
            Id = command.Id ?? Guid.NewGuid(),
            TenantId = actor.TenantId,
            CreatedBy = actor.UserId
        };

        collection.RegionId = command.RegionId;
        collection.EntryId = command.EntryId;
        collection.NodeId = command.NodeId;
        collection.SubjectId = command.SubjectId;
        collection.CategoryId = command.CategoryId;
        collection.QuestionBankId = command.QuestionBankId;
        collection.LegacyId = Normalize(command.LegacyId);
        collection.Name = command.Name.Trim();
        collection.CollectionType = Parse(command.CollectionType, QuestionCollectionType.Dynamic, "collection_type_invalid");
        collection.SourceType = Parse(command.SourceType, QuestionCollectionSourceType.Filters, "collection_source_type_invalid");
        collection.Filters = JsonObjectOrDefault(command.Filters);
        collection.TotalScore = command.TotalScore;
        collection.DurationMinutes = command.DurationMinutes;
        collection.Status = Parse(command.Status, ContentStatus.Active, "content_status_invalid");
        collection.SortOrder = command.Order ?? 0;
        collection.AccessRules = JsonObjectOrDefault(command.AccessRules);
        collection.Metadata = JsonObjectOrDefault(command.Metadata);
        collection.UpdatedBy = actor.UserId;

        if (isNew)
        {
            dbContext.QuestionCollections.Add(collection);
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<QuestionCollectionManagementItem>(ToCollectionItem(collection));
    }

    public async Task<CollectionItemsReplaceResult> ReplaceCollectionItemsAsync(
        ContentManagementActor actor,
        ReplaceCollectionItemsCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var collection = await dbContext.QuestionCollections
            .Where(item => item.TenantId == actor.TenantId && item.Id == command.CollectionId)
            .ApplyDataScope(
                scope,
                item => item.CreatedBy == actor.UserId,
                item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))
            .SingleOrDefaultAsync(cancellationToken);

        if (collection is null)
        {
            throw new ContentManagementException("Collection was not found.", "collection_not_found");
        }

        var resolvedQuestions = new List<(CollectionQuestionCommand Command, TenantQuestionReference Reference)>();
        foreach (var question in command.Questions)
        {
            var reference = await questionReferenceService.ResolveAsync(
                actor.TenantId,
                actor.UserId,
                question.Locator,
                cancellationToken);
            resolvedQuestions.Add((question, reference));
        }

        var oldItems = await dbContext.QuestionCollectionItems
            .Where(item => item.TenantId == actor.TenantId && item.CollectionId == command.CollectionId)
            .ToArrayAsync(cancellationToken);
        dbContext.QuestionCollectionItems.RemoveRange(oldItems);

        var items = resolvedQuestions
            .Select((resolved, index) => new QuestionCollectionItem
            {
                TenantId = actor.TenantId,
                CollectionId = command.CollectionId,
                QuestionReferenceId = resolved.Reference.Id,
                QuestionOwnerTenantId = resolved.Reference.QuestionOwnerTenantId,
                QuestionId = resolved.Reference.QuestionId,
                SectionKey = Normalize(resolved.Command.SectionKey),
                SortOrder = resolved.Command.Order ?? index,
                Score = resolved.Command.Score,
                Required = resolved.Command.Required ?? true,
                Metadata = JsonObjectOrDefault(resolved.Command.Metadata)
            })
            .ToArray();

        dbContext.QuestionCollectionItems.AddRange(items);
        collection.QuestionCount = items.Length;
        collection.UpdatedBy = actor.UserId;
        await dbContext.SaveChangesAsync(cancellationToken);

        return new CollectionItemsReplaceResult(
            command.CollectionId,
            collection.QuestionCount,
            items.Select(ToCollectionItemItem).ToArray());
    }

    public async Task<CatalogList<PracticeBlueprintManagementItem>> GetPracticeBlueprintsAsync(
        ContentManagementActor actor,
        ContentManagementFilter filter,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var query = dbContext.PracticeBlueprints
            .AsNoTracking()
            .Where(blueprint => blueprint.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                blueprint => blueprint.CreatedBy == actor.UserId,
                blueprint => blueprint.RegionId.HasValue && regionIds.Contains(blueprint.RegionId.Value));

        if (!filter.IncludeInactive)
        {
            query = query.Where(blueprint => blueprint.Status == ContentStatus.Active);
        }

        if (filter.RegionId.HasValue)
        {
            query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value);
        }

        if (filter.EntryId.HasValue)
        {
            query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value);
        }

        if (filter.NodeId.HasValue)
        {
            query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value);
        }

        if (filter.CollectionId.HasValue)
        {
            query = query.Where(blueprint => blueprint.CollectionId == filter.CollectionId.Value);
        }

        if (TryParse(filter.Mode, out PracticeMode mode))
        {
            query = query.Where(blueprint => blueprint.Mode == mode);
        }

        if (!string.IsNullOrWhiteSpace(filter.Keyword))
        {
            var keyword = filter.Keyword.Trim();
            query = query.Where(blueprint => blueprint.Name.Contains(keyword));
        }

        var items = await query
            .OrderBy(blueprint => blueprint.SortOrder)
            .ThenBy(blueprint => blueprint.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(blueprint => ToBlueprintItem(blueprint))
            .ToArrayAsync(cancellationToken);

        return new CatalogList<PracticeBlueprintManagementItem>(items);
    }

    public async Task<ContentManagementResult<PracticeBlueprintManagementItem>> UpsertPracticeBlueprintAsync(
        ContentManagementActor actor,
        UpsertPracticeBlueprintCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
        await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
        await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
        await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
        await AssertReferenceAsync<QuestionCollection>(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken);

        var blueprint = await ResolveEntityByIdOrLegacyAsync(
            dbContext.PracticeBlueprints,
            actor.TenantId,
            command.Id,
            command.LegacyId,
            cancellationToken);

        var isNew = blueprint is null;
        if (command.Id.HasValue && (blueprint is null || blueprint.Id != command.Id.Value))
        {
            throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
        }

        if (blueprint is not null && !scope.AllowsResource(actor.UserId, blueprint.CreatedBy, blueprint.RegionId))
        {
            throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
        }

        if (blueprint is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
        {
            throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
        }

        blueprint ??= new PracticeBlueprint
        {
            Id = command.Id ?? Guid.NewGuid(),
            TenantId = actor.TenantId,
            CreatedBy = actor.UserId
        };

        blueprint.RegionId = command.RegionId;
        blueprint.EntryId = command.EntryId;
        blueprint.NodeId = command.NodeId;
        blueprint.CollectionId = command.CollectionId;
        blueprint.LegacyId = Normalize(command.LegacyId);
        blueprint.Name = command.Name.Trim();
        blueprint.Mode = Parse(command.Mode, PracticeMode.Sequential, "practice_mode_invalid");
        blueprint.AssemblyType = Parse(command.AssemblyType, PracticeAssemblyType.Collection, "practice_assembly_type_invalid");
        blueprint.QuestionLimit = command.QuestionLimit;
        blueprint.DurationMinutes = command.DurationMinutes;
        blueprint.TotalScore = command.TotalScore;
        blueprint.PassScore = command.PassScore;
        blueprint.Sections = JsonArrayOrDefault(command.Sections);
        blueprint.Rules = JsonObjectOrDefault(command.Rules);
        blueprint.AccessRules = JsonObjectOrDefault(command.AccessRules);
        blueprint.Status = Parse(command.Status, ContentStatus.Active, "content_status_invalid");
        blueprint.SortOrder = command.Order ?? 0;
        blueprint.UpdatedBy = actor.UserId;

        if (isNew)
        {
            dbContext.PracticeBlueprints.Add(blueprint);
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<PracticeBlueprintManagementItem>(ToBlueprintItem(blueprint));
    }

    public ImportFieldMappingItem GetImportFieldMapping(string importType)
    {
        var spec = ResolveImportSpec(importType);
        return new ImportFieldMappingItem(
            spec.ImportType,
            spec.Title,
            spec.Description,
            spec.Fields,
            spec.Fields.Where(field => field.Required).Select(field => field.Field).ToArray());
    }

    public ImportTemplateItem GetImportTemplate(string importType, string? format)
    {
        var spec = ResolveImportSpec(importType);
        var normalizedFormat = string.IsNullOrWhiteSpace(format) ? "json" : format.Trim().ToLowerInvariant();
        var content = normalizedFormat switch
        {
            "json" => JsonSerializer.Serialize(spec.JsonExample, new JsonSerializerOptions { WriteIndented = true }),
            "csv" => string.Join(
                "\n",
                spec.CsvRows.Select(row => string.Join(",", row.Select(EscapeCsv)))),
            _ => throw new ContentManagementException("Import template format is not supported.", "import_template_format_invalid")
        };

        return new ImportTemplateItem(
            spec.ImportType,
            normalizedFormat,
            $"{spec.ImportType}-import-template.{normalizedFormat}",
            normalizedFormat == "csv" ? "text/csv" : "application/json",
            Convert.ToBase64String(Encoding.UTF8.GetBytes(content)),
            content,
            spec.Fields);
    }

    private async Task<(string Path, int Depth)> BuildNodePathAsync(
        Guid tenantId,
        Guid entryId,
        Guid nodeId,
        Guid? parentId,
        CancellationToken cancellationToken)
    {
        var label = $"n_{nodeId:N}";
        if (!parentId.HasValue)
        {
            return (label, 0);
        }

        var parent = await dbContext.ContentNodes
            .AsNoTracking()
            .Where(node => node.TenantId == tenantId && node.EntryId == entryId && node.Id == parentId.Value)
            .Select(node => new { node.Path, node.Depth })
            .SingleOrDefaultAsync(cancellationToken);

        if (parent is null)
        {
            throw new ContentManagementException("Parent node was not found in this entry.", "parent_node_not_found");
        }

        return ($"{parent.Path}.{label}", parent.Depth + 1);
    }

    private async Task AssertRegionAsync(Guid tenantId, Guid? regionId, CancellationToken cancellationToken)
    {
        await AssertReferenceAsync<Region>(tenantId, regionId, "region_not_found", cancellationToken);
    }

    private async Task AssertEntryAsync(Guid tenantId, Guid? entryId, CancellationToken cancellationToken)
    {
        await AssertReferenceAsync<ContentEntry>(tenantId, entryId, "entry_not_found", cancellationToken);
    }

    private async Task AssertEntryAsync(
        ContentManagementActor actor,
        CurrentDataScope scope,
        Guid? entryId,
        CancellationToken cancellationToken)
    {
        if (!entryId.HasValue)
        {
            return;
        }

        var regionIds = scope.RegionIds.ToArray();
        var exists = await dbContext.ContentEntries
            .Where(entry => entry.TenantId == actor.TenantId && entry.Id == entryId.Value)
            .ApplyDataScope(
                scope,
                entry => entry.CreatedBy == actor.UserId,
                entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value))
            .AnyAsync(cancellationToken);
        if (!exists)
        {
            throw new ContentManagementException("Content entry was not found.", "entry_not_found");
        }
    }

    private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken)
    {
        await AssertReferenceAsync<ContentNode>(tenantId, nodeId, "node_not_found", cancellationToken);
    }

    private async Task AssertNodeAsync(
        ContentManagementActor actor,
        CurrentDataScope scope,
        Guid? nodeId,
        CancellationToken cancellationToken)
    {
        if (!nodeId.HasValue)
        {
            return;
        }

        var regionIds = scope.RegionIds.ToArray();
        var exists = await dbContext.ContentNodes
            .Where(node => node.TenantId == actor.TenantId && node.Id == nodeId.Value)
            .ApplyDataScope(
                scope,
                node => node.CreatedBy == actor.UserId,
                node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value))
            .AnyAsync(cancellationToken);
        if (!exists)
        {
            throw new ContentManagementException("Content node was not found.", "node_not_found");
        }
    }

    private async Task<CurrentDataScope> RequireDataScopeAsync(
        ContentManagementActor actor,
        CancellationToken cancellationToken)
    {
        var access = await currentAccessContext.GetAsync(cancellationToken);
        if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId)
        {
            throw new ContentManagementException("Content resource was not found.", "content_not_found");
        }

        return access.DataScope;
    }

    private async Task AssertReferenceAsync<TEntity>(
        Guid tenantId,
        Guid? id,
        string code,
        CancellationToken cancellationToken)
        where TEntity : class
    {
        if (!id.HasValue)
        {
            return;
        }

        var exists = await dbContext.Set<TEntity>()
            .AnyAsync(entity =>
                EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
                EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
                cancellationToken);

        if (!exists)
        {
            throw new ContentManagementException("Referenced entity was not found in this tenant.", code);
        }
    }

    private static async Task<TEntity?> ResolveEntityAsync<TEntity>(
        DbSet<TEntity> set,
        Guid tenantId,
        Guid? id,
        System.Linq.Expressions.Expression<Func<TEntity, bool>> alternatePredicate,
        CancellationToken cancellationToken)
        where TEntity : class
    {
        if (id.HasValue)
        {
            var byId = await set.SingleOrDefaultAsync(entity =>
                EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
                EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
                cancellationToken);
            if (byId is not null)
            {
                return byId;
            }
        }

        return await set
            .Where(entity => EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId)
            .SingleOrDefaultAsync(alternatePredicate, cancellationToken);
    }

    private static async Task<TEntity?> ResolveEntityByIdOrLegacyAsync<TEntity>(
        DbSet<TEntity> set,
        Guid tenantId,
        Guid? id,
        string? legacyId,
        CancellationToken cancellationToken)
        where TEntity : class
    {
        if (id.HasValue)
        {
            var byId = await set.SingleOrDefaultAsync(entity =>
                EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
                EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
                cancellationToken);
            if (byId is not null)
            {
                return byId;
            }
        }

        var normalizedLegacyId = Normalize(legacyId);
        if (normalizedLegacyId is null)
        {
            return null;
        }

        return await set.SingleOrDefaultAsync(entity =>
            EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
            EF.Property<string?>(entity, nameof(ContentEntry.LegacyId)) == normalizedLegacyId,
            cancellationToken);
    }

    private static ContentEntryManagementItem ToEntryItem(ContentEntry entry)
    {
        return new ContentEntryManagementItem(
            entry.Id,
            entry.RegionId,
            entry.LegacyId,
            entry.EntryKey,
            entry.Name,
            entry.EntryType,
            entry.Icon,
            entry.Route,
            entry.Description,
            entry.Visibility,
            entry.AccessRules,
            entry.LayoutConfig,
            entry.SortOrder,
            entry.IsActive,
            entry.CreatedAt,
            entry.UpdatedAt);
    }

    private static ContentNodeManagementItem ToNodeItem(ContentNode node)
    {
        return new ContentNodeManagementItem(
            node.Id,
            node.EntryId,
            node.RegionId,
            node.ParentId,
            node.LegacyId,
            node.NodeKey,
            node.Name,
            node.NodeType,
            node.MarkerType,
            node.MarkerConfig,
            node.Path,
            node.Depth,
            node.SortOrder,
            node.IsActive,
            node.IsSelectable,
            node.IsLeaf,
            node.AccessRules,
            node.Metadata,
            node.CreatedAt,
            node.UpdatedAt);
    }

    private static QuestionCollectionManagementItem ToCollectionItem(QuestionCollection collection)
    {
        return new QuestionCollectionManagementItem(
            collection.Id,
            collection.RegionId,
            collection.EntryId,
            collection.NodeId,
            collection.SubjectId,
            collection.CategoryId,
            collection.QuestionBankId,
            collection.LegacyId,
            collection.Name,
            collection.CollectionType,
            collection.SourceType,
            collection.Filters,
            collection.QuestionCount,
            collection.TotalScore,
            collection.DurationMinutes,
            collection.Status,
            collection.SortOrder,
            collection.AccessRules,
            collection.Metadata,
            collection.CreatedAt,
            collection.UpdatedAt);
    }

    private static QuestionCollectionItemManagementItem ToCollectionItemItem(QuestionCollectionItem item)
    {
        return new QuestionCollectionItemManagementItem(
            item.Id,
            item.CollectionId,
            item.QuestionId,
            new QuestionLocator(
                item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
                item.QuestionId),
            item.SectionKey,
            item.SortOrder,
            item.Score,
            item.Required,
            item.Metadata);
    }

    private static PracticeBlueprintManagementItem ToBlueprintItem(PracticeBlueprint blueprint)
    {
        return new PracticeBlueprintManagementItem(
            blueprint.Id,
            blueprint.RegionId,
            blueprint.EntryId,
            blueprint.NodeId,
            blueprint.CollectionId,
            blueprint.LegacyId,
            blueprint.Name,
            blueprint.Mode,
            blueprint.AssemblyType,
            blueprint.QuestionLimit,
            blueprint.DurationMinutes,
            blueprint.TotalScore,
            blueprint.PassScore,
            blueprint.Sections,
            blueprint.Rules,
            blueprint.AccessRules,
            blueprint.Status,
            blueprint.SortOrder,
            blueprint.CreatedAt,
            blueprint.UpdatedAt);
    }

    private static int ResolveLimit(int? limit)
    {
        return limit is > 0 ? Math.Min(limit.Value, MaxLimit) : DefaultLimit;
    }

    private static string? Normalize(string? value)
    {
        return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
    }

    private static JsonElement JsonObjectOrDefault(JsonElement value)
    {
        return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null
            ? JsonDefaults.Object()
            : value;
    }

    private static JsonElement JsonArrayOrDefault(JsonElement value)
    {
        return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null
            ? JsonDefaults.Array()
            : value;
    }

    private static TEnum Parse<TEnum>(string? value, TEnum fallback, string code)
        where TEnum : struct
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return fallback;
        }

        if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
        {
            return parsed;
        }

        throw new ContentManagementException("Invalid enum value.", code);
    }

    private static TEnum? ParseNullable<TEnum>(string? value, string code)
        where TEnum : struct
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return null;
        }

        if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
        {
            return parsed;
        }

        throw new ContentManagementException("Invalid enum value.", code);
    }

    private static bool TryParse<TEnum>(string? value, out TEnum parsed)
        where TEnum : struct
    {
        return Enum.TryParse(value, ignoreCase: true, out parsed);
    }

    private static string EscapeCsv(string value)
    {
        return value.Contains(',') || value.Contains('"') || value.Contains('\n')
            ? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""
            : value;
    }

    private static ImportSpec ResolveImportSpec(string importType)
    {
        var normalized = importType.Trim().ToLowerInvariant();
        return Specs.TryGetValue(normalized, out var spec)
            ? spec
            : throw new ContentManagementException("Import type is not supported.", "import_type_invalid");
    }

    private sealed record ImportSpec(
        string ImportType,
        string Title,
        string Description,
        IReadOnlyCollection<ImportFieldSpec> Fields,
        string[][] CsvRows,
        object JsonExample);

    private static ImportFieldSpec Field(
        string field,
        string label,
        bool required,
        string[] aliases,
        string description,
        object example)
    {
        return new ImportFieldSpec(
            field,
            label,
            required,
            aliases,
            description,
            JsonSerializer.SerializeToElement(example));
    }

    private static readonly IReadOnlyDictionary<string, ImportSpec> Specs =
        new Dictionary<string, ImportSpec>(StringComparer.Ordinal)
        {
            ["questions"] = new(
                "questions",
                "题目导入模板",
                "用于导入刷题题库，后端会校验题型、答案、目标科目、分类、题集和租户隔离。",
                [
                    Field("legacyId", "旧系统 ID", false, ["legacy_id", "externalId", "id"], "用于幂等更新。", "tj-english-2026-001"),
                    Field("type", "题型", true, ["题型", "questionType"], "choice、multi、judge、reading、short_answer 等。", "choice"),
                    Field("content", "题干", true, ["题干", "stem", "question"], "支持 Markdown、图片 URL 和公式。", "多租户 SaaS 最重要的安全边界是什么？"),
                    Field("options", "选项", false, ["选项", "choices"], "客观题选项。", new[] { "前端隐藏", "后端权限" }),
                    Field("correctOptionIndices", "正确选项索引", false, ["答案", "answer"], "从 0 开始；CSV 可用 A/B/C/D。", new[] { 1 }),
                    Field("answerText", "文字答案", false, ["主观题答案"], "主观题答案。", "以后端权限和数据库约束为准。"),
                    Field("explanation", "解析", false, ["解析", "analysis"], "题目解析内容。", "最终权限以后端强制为准。"),
                    Field("difficulty", "难度", false, ["难度"], "建议 1-5。", 2),
                    Field("tags", "标签", false, ["标签", "tag"], "JSON 数组或 CSV 中用 | 分隔。", new[] { "安全", "多租户" })
                ],
                [
                    ["legacyId", "type", "content", "选项A", "选项B", "答案", "explanation", "difficulty", "tags"],
                    ["tj-english-2026-001", "choice", "多租户 SaaS 最重要的安全边界是什么？", "前端隐藏", "后端权限", "B", "最终权限以后端强制为准。", "2", "安全|多租户"]
                ],
                new
                {
                    items = new[]
                    {
                        new
                        {
                            legacyId = "tj-english-2026-001",
                            type = "choice",
                            content = "多租户 SaaS 最重要的安全边界是什么？",
                            options = new[] { "前端隐藏", "后端权限" },
                            correctOptionIndices = new[] { 1 },
                            explanation = "最终权限以后端强制为准。",
                            difficulty = 2,
                            tags = new[] { "安全", "多租户" }
                        }
                    }
                }),
            ["vocabulary"] = new(
                "vocabulary",
                "单词导入模板",
                "用于导入词汇单元和单词，后端会按单元归组并幂等写入。",
                [
                    Field("unitName", "单元名称", true, ["unit", "单元"], "单词所属单元。", "核心词汇 Unit 1"),
                    Field("word", "单词", true, ["单词"], "英文单词或词组。", "scale"),
                    Field("meaning", "释义", true, ["释义", "中文"], "中文释义。", "n. 规模；等级"),
                    Field("phonetic", "音标", false, ["音标"], "音标展示文本。", "/skeil/"),
                    Field("example", "例句", false, ["例句"], "英文例句。", "The platform must scale safely.")
                ],
                [
                    ["unitName", "word", "phonetic", "meaning", "example", "difficulty", "tags"],
                    ["核心词汇 Unit 1", "scale", "/skeil/", "n. 规模；等级", "The platform must scale safely.", "2", "高频|SaaS"]
                ],
                new { units = new[] { new { name = "核心词汇 Unit 1", words = new[] { new { word = "scale", meaning = "n. 规模；等级" } } } } }),
            ["handbook"] = new(
                "handbook",
                "知识手册导入模板",
                "用于导入手册科目、章节、小节和知识点。",
                [
                    Field("subjectName", "手册科目", true, ["subject", "手册"], "知识手册顶层名称。", "专升本英语知识手册"),
                    Field("chapterName", "章节", true, ["chapter", "章节"], "章节名称。", "第一章 语法基础"),
                    Field("title", "知识点标题", true, ["entryTitle", "标题"], "知识点条目标题。", "that 引导的主语从句"),
                    Field("content", "正文", true, ["正文", "markdown"], "Markdown 正文。", "主语从句可放在句首。")
                ],
                [
                    ["subjectName", "chapterName", "title", "content", "tags"],
                    ["专升本英语知识手册", "第一章 语法基础", "that 引导的主语从句", "主语从句可放在句首。", "语法"]
                ],
                new { subjects = new[] { new { name = "专升本英语知识手册", chapters = new[] { new { name = "第一章 语法基础" } } } } }),
            ["scoreline"] = new(
                "scoreline",
                "分数线导入模板",
                "用于导入动态字段、院校、专业和年份分数线记录。",
                [
                    Field("kind", "数据类型", true, ["type", "类型"], "field、school、major、record。", "record"),
                    Field("schoolName", "院校名称", false, ["school", "院校"], "院校名称。", "天津职业技术师范大学"),
                    Field("majorName", "专业名称", false, ["major", "专业"], "专业名称。", "软件工程"),
                    Field("year", "年份", false, ["年份"], "record 常用。", 2026),
                    Field("fieldValues", "字段值", false, ["values", "分数字段"], "record 的动态字段 JSON。", new { minScore = 188 })
                ],
                [
                    ["kind", "schoolName", "majorName", "year", "minScore"],
                    ["record", "天津职业技术师范大学", "软件工程", "2026", "188"]
                ],
                new { records = new[] { new { schoolName = "天津职业技术师范大学", majorName = "软件工程", year = 2026, fieldValues = new { minScore = 188 } } } }),
            ["videos"] = new(
                "videos",
                "视频解析导入模板",
                "用于导入视频解析元数据并绑定到题目。",
                [
                    Field("title", "标题", true, ["视频标题", "name"], "视频标题。", "多租户隔离题解析"),
                    Field("videoUrl", "视频 URL", false, ["video_url", "url"], "外部视频 URL。", "https://cdn.example.test/video.mp4"),
                    Field("assetId", "资源 ID", false, ["asset_id"], "对象存储资源台账 ID。", "00000000-0000-0000-0000-000000000000"),
                    Field("legacyQuestionId", "题目外部 ID", false, ["legacy_question_id"], "按旧题目 ID 绑定。", "tj-english-2026-001")
                ],
                [
                    ["title", "videoUrl", "legacyQuestionId", "videoType"],
                    ["多租户隔离题解析", "https://cdn.example.test/video.mp4", "tj-english-2026-001", "specific"]
                ],
                new { videos = new[] { new { title = "多租户隔离题解析", videoUrl = "https://cdn.example.test/video.mp4" } } })
        };
}
