using System.Data;
using System.Text;
using Dapper;
using PlotLine.Models;

namespace PlotLine.Data;

public interface IImportRepository
{
    Task<bool> ProjectTitleExistsAsync(string projectTitle, int userId);
    Task<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle, int userId);
    Task<IReadOnlyDictionary<string, int>> GetIdentityMaxValuesAsync(IEnumerable<string> tableNames);
    Task<ProjectBackupRelationshipImportLookups> GetRelationshipImportLookupsAsync();
    Task<ProjectBackupRelationshipImportDiagnostics> GetRelationshipImportDiagnosticsAsync(int projectId);
    Task<int> CreateProjectShellAsync(string projectName, string? description, int userId);
    Task ApplyProjectBackupJsonAsync(int projectId, string jsonData);
}

public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository
{
    private const string ImportMarker = "Imported via JSON Import Phase 1F";

    public async Task<bool> ProjectTitleExistsAsync(string projectTitle, int userId)
    {
        using var connection = connectionFactory.CreateConnection();
        var count = await connection.ExecuteScalarAsync<int>(
            "dbo.Project_ExistsByNameForUser",
            new { ProjectName = projectTitle, UserID = userId },
            commandType: CommandType.StoredProcedure);

        return count > 0;
    }

    public async Task<IReadOnlyDictionary<string, int>> GetIdentityMaxValuesAsync(IEnumerable<string> tableNames)
    {
        using var connection = connectionFactory.CreateConnection();
        var requestedTables = tableNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
        var rows = await connection.QueryAsync<IdentityMaxRow>(
            "dbo.ProjectBackup_GetIdentityMaxValues",
            new { TableNames = string.Join(',', requestedTables) },
            commandType: CommandType.StoredProcedure);

        return rows.ToDictionary(row => row.TableName, row => row.MaxID, StringComparer.OrdinalIgnoreCase);
    }

    public async Task<ProjectBackupRelationshipImportLookups> GetRelationshipImportLookupsAsync()
    {
        using var connection = connectionFactory.CreateConnection();
        using var result = await connection.QueryMultipleAsync(
            "dbo.ProjectBackup_GetRelationshipImportLookups",
            commandType: CommandType.StoredProcedure);

        return new ProjectBackupRelationshipImportLookups
        {
            RevisionStatuses = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            TimeModes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            DurationUnits = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            TimeConfidences = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            LocationTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            LocationRelationshipTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            PlotLineTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            PlotImportance = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            ThreadTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            ThreadStatuses = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            ThreadEventTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            PlotEventTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            AssetKinds = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            AssetStates = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            AssetEventTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            AssetDependencyTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            AssetCustodyEventTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            CustodyRoles = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            CharacterRoleTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            PresenceTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            KnowledgeStates = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            SceneDependencyTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            SceneNoteTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            SceneAttachmentTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            ScenePurposeTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            SceneMetricTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
            RelationshipTypes = (await result.ReadAsync<RelationshipType>()).ToList(),
            RelationshipStates = (await result.ReadAsync<RelationshipState>()).ToList()
        };
    }

    public async Task<ProjectBackupRelationshipImportDiagnostics> GetRelationshipImportDiagnosticsAsync(int projectId)
    {
        using var connection = connectionFactory.CreateConnection();
        return await connection.QuerySingleAsync<ProjectBackupRelationshipImportDiagnostics>(
            "dbo.ProjectBackup_RelationshipImportDiagnostics",
            new { ProjectID = projectId },
            commandType: CommandType.StoredProcedure);
    }

    public async Task<int> CreateProjectShellAsync(string projectName, string? description, int userId)
    {
        using var connection = connectionFactory.CreateConnection();
        return await connection.QuerySingleAsync<int>(
            "dbo.Project_Save",
            new { ProjectID = 0, ProjectName = projectName, Description = description, UserID = userId },
            commandType: CommandType.StoredProcedure);
    }

    public async Task ApplyProjectBackupJsonAsync(int projectId, string jsonData)
    {
        using var connection = connectionFactory.CreateConnection();
        await connection.ExecuteAsync(
            "dbo.ProjectRestore_ApplyFromJson",
            new
            {
                ProjectID = projectId,
                SourceRestorePointID = (int?)null,
                SafetyRestorePointID = (int?)null,
                JsonData = jsonData
            },
            commandType: CommandType.StoredProcedure,
            commandTimeout: 180);
    }

    public async Task<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle, int userId)
    {
        using var connection = connectionFactory.CreateConnection();
        connection.Open();
        using var transaction = connection.BeginTransaction();

        try
        {
            var booksCreated = 0;
            var chaptersCreated = 0;
            var scenesCreated = 0;
            var charactersCreated = 0;
            var locationsCreated = 0;
            var appearancesCreated = 0;
            var plotLinesCreated = 0;
            var plotThreadsCreated = 0;
            var threadEventsCreated = 0;
            var storyAssetsCreated = 0;
            var assetStatesCreated = 0;
            var assetEventsCreated = 0;
            var assetCustodyEventsCreated = 0;
            var sceneDependenciesCreated = 0;
            var skippedFields = GetSkippedOptionalFields(package);
            var unresolvedWarnings = GetUnresolvedWarnings(package);
            var sceneMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
            var projectName = string.IsNullOrWhiteSpace(importedProjectTitle)
                ? Clean(package.Project.Title)
                : Clean(importedProjectTitle);
            var lookupIds = await GetLookupIdsAsync(connection, transaction);
            var projectId = await connection.QuerySingleAsync<int>(
                "dbo.Project_Save",
                new
                {
                    ProjectID = 0,
                    ProjectName = projectName,
                    Description = CombineText(
                        package.Project.Description,
                        package.Project.Notes,
                        ImportMetadata(package)),
                    UserID = userId
                },
                transaction,
                commandType: CommandType.StoredProcedure);

            var characterMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
            foreach (var character in package.Characters)
            {
                var characterName = Clean(DisplayName(character.DisplayName, character.Name));
                if (characterName.Length == 0 || ResolveId(characterMap, character.Name, package.Aliases.Characters).HasValue)
                {
                    continue;
                }

                var characterId = await connection.QuerySingleAsync<int>(
                    "dbo.Character_Save",
                    new
                    {
                        CharacterID = 0,
                        ProjectID = projectId,
                        CharacterName = characterName,
                        ShortName = Truncate(character.ShortName, 100),
                        Sex = (string?)null,
                        BirthDate = (DateTime?)null,
                        AgeAtSeriesStart = (int?)null,
                        AgeReferenceSceneID = (int?)null,
                        Height = (string?)null,
                        EyeColour = (string?)null,
                        CharacterImportance = NormalizeCharacterImportance(character.Role, character.Importance),
                        DefaultDescription = CombineText(
                            character.Description,
                            character.Notes,
                            ImportMarker)
                    },
                    transaction,
                    commandType: CommandType.StoredProcedure);

                AddLookupKeys(characterMap, character.Name, characterId);
                AddLookupKeys(characterMap, character.DisplayName, characterId);
                charactersCreated++;
            }

            var locationMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
            foreach (var location in package.Locations)
            {
                var locationName = Clean(DisplayName(location.DisplayName, location.Name));
                if (locationName.Length == 0 || ResolveId(locationMap, location.Name, package.Aliases.Locations).HasValue)
                {
                    continue;
                }

                var locationId = await connection.QuerySingleAsync<int>(
                    "dbo.Location_Save",
                    new
                    {
                        LocationID = 0,
                        ProjectID = projectId,
                        ParentLocationID = (int?)null,
                        LocationName = locationName,
                        LocationTypeID = LookupLocationTypeId(lookupIds, location.Type),
                        Description = CombineText(location.Description, location.Notes, ImportMarker)
                    },
                    transaction,
                    commandType: CommandType.StoredProcedure);

                AddLookupKeys(locationMap, location.Name, locationId);
                AddLookupKeys(locationMap, location.DisplayName, locationId);
                locationsCreated++;
            }

            foreach (var location in package.Locations.Where(x => !string.IsNullOrWhiteSpace(x.ParentLocationName)))
            {
                var locationId = ResolveId(locationMap, location.Name, package.Aliases.Locations);
                var parentLocation = ResolveId(locationMap, location.ParentLocationName, package.Aliases.Locations);
                if (!locationId.HasValue || !parentLocation.HasValue)
                {
                    continue;
                }

                await connection.QuerySingleAsync<int>(
                    "dbo.Location_Save",
                    new
                    {
                        LocationID = locationId.Value,
                        ProjectID = projectId,
                        ParentLocationID = parentLocation.Value,
                        LocationName = Clean(DisplayName(location.DisplayName, location.Name)),
                        LocationTypeID = LookupLocationTypeId(lookupIds, location.Type),
                        Description = CombineText(location.Description, location.Notes, ImportMarker)
                    },
                    transaction,
                    commandType: CommandType.StoredProcedure);
            }

            foreach (var book in package.Books.OrderBy(x => x.SeriesOrder))
            {
                var bookId = await connection.QuerySingleAsync<int>(
                    "dbo.Book_Save",
                    new
                    {
                        BookID = 0,
                        ProjectID = projectId,
                        BookTitle = Clean(FormatBookTitle(book)),
                        BookNumber = book.SeriesOrder,
                        Description = CombineText(
                            book.Description,
                            book.DependsOnPreviousBook ? "Depends on previous book." : null,
                            book.Notes,
                            ImportMarker)
                    },
                    transaction,
                    commandType: CommandType.StoredProcedure);
                booksCreated++;

                foreach (var chapter in book.Chapters.OrderBy(x => x.Order))
                {
                    var chapterId = await connection.QuerySingleAsync<int>(
                        "dbo.Chapter_Save",
                        new
                        {
                            ChapterID = 0,
                            BookID = bookId,
                            chapter.ChapterNumber,
                            ChapterTitle = Clean(chapter.Title),
                            Summary = CombineText(chapter.Summary, chapter.Notes, ImportMarker),
                            RevisionStatusID = lookupIds.RevisionStatusID
                        },
                        transaction,
                        commandType: CommandType.StoredProcedure);
                    chaptersCreated++;

                    var orderedScenes = chapter.Scenes.OrderBy(x => x.Order).ToList();
                    var defaultSceneId = 0;
                    if (orderedScenes.Count > 0)
                    {
                        defaultSceneId = (await connection.QueryAsync<Scene>(
                            "dbo.Scene_ListByChapter",
                            new { ChapterID = chapterId },
                            transaction,
                            commandType: CommandType.StoredProcedure))
                            .OrderBy(x => x.SortOrder)
                            .ThenBy(x => x.SceneID)
                            .Select(x => x.SceneID)
                            .FirstOrDefault();
                    }

                    for (var sceneIndex = 0; sceneIndex < orderedScenes.Count; sceneIndex++)
                    {
                        var scene = orderedScenes[sceneIndex];
                        var primaryLocationId = ResolveId(locationMap, scene.LocationName, package.Aliases.Locations);
                        var povCharacterId = ResolveId(characterMap, scene.PovCharacterName, package.Aliases.Characters);
                        var sceneId = await connection.QuerySingleAsync<int>(
                            "dbo.Scene_Save",
                            new
                            {
                                SceneID = sceneIndex == 0 ? defaultSceneId : 0,
                                ChapterID = chapterId,
                                scene.SceneNumber,
                                SceneTitle = Clean(scene.Title),
                                Summary = CombineText(
                                    scene.Summary,
                                    povCharacterId.HasValue ? null : MetadataLine("POV", scene.PovCharacterName),
                                    primaryLocationId.HasValue ? null : MetadataLine("Location", scene.LocationName),
                                    scene.Notes,
                                    ImportMarker),
                                TimeModeID = lookupIds.TimeModeID,
                                StartDateTime = (DateTime?)null,
                                EndDateTime = (DateTime?)null,
                                DurationAmount = (decimal?)null,
                                DurationUnitID = (int?)null,
                                RelativeTimeText = Truncate(scene.DateTimeText, 200),
                                TimeConfidenceID = lookupIds.TimeConfidenceID,
                                ScenePurposeNotes = CombineText(scene.PurposeText),
                                SceneOutcomeNotes = CombineText(scene.OutcomeText),
                                RevisionStatusID = lookupIds.RevisionStatusID,
                                PrimaryLocationID = primaryLocationId
                            },
                            transaction,
                            commandType: CommandType.StoredProcedure);
                        if (povCharacterId.HasValue)
                        {
                            await connection.ExecuteAsync(
                                "dbo.Scene_UpdatePovCharacter",
                                new { SceneID = sceneId, POVCharacterID = povCharacterId.Value },
                                transaction,
                                commandType: CommandType.StoredProcedure);
                        }

                        foreach (var appearance in BuildSceneAppearances(scene, povCharacterId, characterMap, package.Aliases.Characters))
                        {
                            await connection.QuerySingleAsync<int>(
                                "dbo.SceneCharacter_Save",
                                new
                                {
                                    SceneCharacterID = 0,
                                    SceneID = sceneId,
                                    CharacterID = appearance.CharacterId,
                                    RoleInSceneTypeID = ResolveRoleTypeId(lookupIds, appearance.RoleInScene),
                                    PresenceTypeID = ResolvePresenceTypeId(lookupIds, appearance.RoleInScene),
                                    LocationID = primaryLocationId,
                                    EntryLocationID = (int?)null,
                                    ExitLocationID = (int?)null,
                                    AppearanceNotes = CombineText(
                                        MetadataLine("Imported role", appearance.RoleInScene),
                                        MetadataLine("Importance", appearance.Importance?.ToString()),
                                        appearance.Notes),
                                    OutfitDescription = (string?)null,
                                    PhysicalCondition = (string?)null,
                                    EmotionalState = (string?)null,
                                    KnowledgeNotes = (string?)null
                                },
                                transaction,
                                commandType: CommandType.StoredProcedure);
                            appearancesCreated++;
                        }
                        scenesCreated++;
                        sceneMap[SceneKey(book.SeriesOrder, chapter.Order, scene.Order)] = sceneId;
                    }
                }
            }

            foreach (var plotLine in package.PlotLines)
            {
                if (string.IsNullOrWhiteSpace(plotLine.Name))
                {
                    continue;
                }

                var plotLineId = await connection.QuerySingleAsync<int>(
                    "dbo.PlotLine_Save",
                    new
                    {
                        PlotLineID = 0,
                        ProjectID = projectId,
                        BookID = (int?)null,
                        PlotLineName = Clean(DisplayName(plotLine.DisplayName, plotLine.Name)),
                        PlotLineTypeID = ResolvePlotLineTypeId(lookupIds, plotLine.Type),
                        PlotImportanceID = ResolvePlotImportanceId(lookupIds, plotLine),
                        Description = CombineText(
                            plotLine.Description,
                            MetadataLine("Type", plotLine.Type),
                            MetadataLine("Importance", plotLine.Importance?.ToString()),
                            plotLine.Notes,
                            ImportMarker),
                        ParentPlotLineID = (int?)null,
                        EmergesFromPlotLineID = (int?)null,
                        SortOrder = (int?)null,
                        Colour = "#2f6f63",
                        IsVisibleOnTimeline = true
                    },
                    transaction,
                    commandType: CommandType.StoredProcedure);
                plotLinesCreated++;

                foreach (var thread in plotLine.Threads)
                {
                    if (string.IsNullOrWhiteSpace(thread.Name))
                    {
                        continue;
                    }

                    var threadId = await connection.QuerySingleAsync<int>(
                        "dbo.PlotThread_Save",
                        new
                        {
                            PlotThreadID = 0,
                            PlotLineID = plotLineId,
                            ThreadTitle = Clean(DisplayName(thread.DisplayName, thread.Name)),
                            ThreadTypeID = ResolveThreadTypeId(lookupIds, thread.Type),
                            ThreadStatusID = ResolveThreadStatusId(lookupIds, thread.Status),
                            Importance = ClampImportance(thread.Importance),
                            Summary = CombineText(thread.Description, MetadataLine("Status", thread.Status), thread.Notes, ImportMarker),
                            IntroducedSceneID = (int?)null,
                            PlannedResolutionSceneID = (int?)null,
                            ActualResolutionSceneID = (int?)null
                        },
                        transaction,
                        commandType: CommandType.StoredProcedure);
                    plotThreadsCreated++;

                    var importedEventSceneIds = new HashSet<int>();
                    foreach (var threadEvent in thread.Events)
                    {
                        if (!TryResolveThreadEventScene(sceneMap, threadEvent, out var sceneId) || importedEventSceneIds.Contains(sceneId))
                        {
                            continue;
                        }

                        await connection.QuerySingleAsync<int>(
                            "dbo.ThreadEvent_Save",
                            new
                            {
                                ThreadEventID = 0,
                                PlotThreadID = threadId,
                                SceneID = sceneId,
                                EventTypeID = ResolveThreadEventTypeId(lookupIds, threadEvent.EventType),
                                EventTitle = Truncate(Clean(threadEvent.Title).Length == 0 ? Clean(threadEvent.Summary) : threadEvent.Title, 200) ?? "Imported thread event",
                                EventDescription = CombineText(
                                    threadEvent.Summary,
                                    MetadataLine("Event type", threadEvent.EventType),
                                    MetadataLine("Impact", threadEvent.Impact),
                                    MetadataLine("Importance", threadEvent.Importance?.ToString()),
                                    threadEvent.Notes,
                                    ImportMarker)
                            },
                            transaction,
                            commandType: CommandType.StoredProcedure);
                        importedEventSceneIds.Add(sceneId);
                        threadEventsCreated++;
                    }
                }
            }

            var firstSceneId = sceneMap.Values.FirstOrDefault();
            foreach (var storyAsset in package.StoryAssets)
            {
                if (string.IsNullOrWhiteSpace(storyAsset.Name))
                {
                    continue;
                }

                var initialLocationId = ResolveId(locationMap, storyAsset.InitialLocationName, package.Aliases.Locations);
                var initialOwnerCharacterId = ResolveId(characterMap, storyAsset.InitialOwnerCharacterName, package.Aliases.Characters);
                var storyAssetId = await connection.QuerySingleAsync<int>(
                    "dbo.StoryAsset_Save",
                    new
                    {
                        StoryAssetID = 0,
                        ProjectID = projectId,
                        AssetName = Clean(DisplayName(storyAsset.DisplayName, storyAsset.Name)),
                        AssetKindID = ResolveAssetKindId(lookupIds, storyAsset.Type),
                        Description = CombineText(
                            storyAsset.Description,
                            MetadataLine("Type", storyAsset.Type),
                            initialOwnerCharacterId.HasValue ? null : MetadataLine("Initial owner", storyAsset.InitialOwnerCharacterName),
                            initialLocationId.HasValue ? null : MetadataLine("Initial location", storyAsset.InitialLocationName),
                            storyAsset.Notes,
                            ImportMarker),
                        Importance = ClampImportance(storyAsset.Importance),
                        CurrentStateID = ResolveAssetStateId(lookupIds, storyAsset.States.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.StateName))?.StateName),
                        CurrentLocationID = initialLocationId,
                        IsResolved = false
                    },
                    transaction,
                    commandType: CommandType.StoredProcedure);
                storyAssetsCreated++;

                if (initialOwnerCharacterId.HasValue && firstSceneId > 0)
                {
                    await connection.QuerySingleAsync<int>(
                        "dbo.AssetCustodyEvent_Save",
                        new
                        {
                            AssetCustodyEventID = 0,
                            StoryAssetID = storyAssetId,
                            SceneID = firstSceneId,
                            AssetCustodyEventTypeID = ResolveAssetCustodyEventTypeId(lookupIds, "Has"),
                            Description = CombineText("Initial owner imported from JSON.", ImportMarker),
                            CustodianNames = storyAsset.InitialOwnerCharacterName,
                            CustodyRoleID = ResolveCustodyRoleId(lookupIds, "Has Custody")
                        },
                        transaction,
                        commandType: CommandType.StoredProcedure);
                    assetCustodyEventsCreated++;
                }

                var importedAssetStateSceneIds = new HashSet<int>();
                foreach (var state in storyAsset.States)
                {
                    if (!TryResolveAssetStateScene(sceneMap, state, out var sceneId) || importedAssetStateSceneIds.Contains(sceneId))
                    {
                        continue;
                    }

                    await connection.QuerySingleAsync<int>(
                        "dbo.AssetEvent_Save",
                        new
                        {
                            AssetEventID = 0,
                            StoryAssetID = storyAssetId,
                            SceneID = sceneId,
                            AssetEventTypeID = ResolveAssetEventTypeId(lookupIds, "State Changed"),
                            FromStateID = (int?)null,
                            ToStateID = ResolveAssetStateId(lookupIds, state.StateName),
                            EventTitle = Truncate(Clean(state.StateName).Length == 0 ? "Imported asset state" : state.StateName, 200),
                            EventDescription = CombineText(state.Description, state.Notes, ImportMarker)
                        },
                        transaction,
                        commandType: CommandType.StoredProcedure);
                    importedAssetStateSceneIds.Add(sceneId);
                    assetStatesCreated++;
                }

                var importedAssetEventSceneIds = new HashSet<int>();
                foreach (var assetEvent in storyAsset.Events)
                {
                    if (!TryResolveAssetEventScene(sceneMap, assetEvent, out var sceneId) || importedAssetEventSceneIds.Contains(sceneId))
                    {
                        continue;
                    }

                    var eventLocationId = ResolveId(locationMap, assetEvent.LocationName, package.Aliases.Locations);
                    if (eventLocationId.HasValue)
                    {
                        await connection.QuerySingleAsync<int>(
                            "dbo.SceneAssetLocation_Save",
                            new
                            {
                                SceneAssetLocationID = 0,
                                SceneID = sceneId,
                                StoryAssetID = storyAssetId,
                                LocationID = eventLocationId.Value,
                                Description = CombineText(assetEvent.Summary, assetEvent.Notes, ImportMarker),
                                UpdateCurrentLocation = true
                            },
                            transaction,
                            commandType: CommandType.StoredProcedure);
                    }

                    await connection.QuerySingleAsync<int>(
                        "dbo.AssetEvent_Save",
                        new
                        {
                            AssetEventID = 0,
                            StoryAssetID = storyAssetId,
                            SceneID = sceneId,
                            AssetEventTypeID = ResolveAssetEventTypeId(lookupIds, assetEvent.EventType),
                            FromStateID = (int?)null,
                            ToStateID = (int?)null,
                            EventTitle = Truncate(Clean(assetEvent.Title).Length == 0 ? Clean(assetEvent.Summary) : assetEvent.Title, 200) ?? "Imported asset event",
                            EventDescription = CombineText(
                                assetEvent.Summary,
                                MetadataLine("Event type", assetEvent.EventType),
                                MetadataLine("Character", assetEvent.CharacterName),
                                eventLocationId.HasValue ? null : MetadataLine("Location", assetEvent.LocationName),
                                assetEvent.Notes,
                                ImportMarker)
                        },
                        transaction,
                        commandType: CommandType.StoredProcedure);
                    importedAssetEventSceneIds.Add(sceneId);
                    assetEventsCreated++;
                }
            }

            var importedSceneDependencyKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            foreach (var dependency in package.SceneDependencies)
            {
                if (!TryResolveSceneReference(sceneMap, dependency.FromScene, out var sourceSceneId)
                    || !TryResolveSceneReference(sceneMap, dependency.ToScene, out var targetSceneId)
                    || sourceSceneId == targetSceneId)
                {
                    continue;
                }

                var dependencyTypeId = ResolveSceneDependencyTypeId(lookupIds, dependency.DependencyType);
                var dependencyKey = $"{sourceSceneId}/{targetSceneId}/{dependencyTypeId}";
                if (!importedSceneDependencyKeys.Add(dependencyKey))
                {
                    continue;
                }

                await connection.QuerySingleAsync<int>(
                    "dbo.SceneDependency_Save",
                    new
                    {
                        SceneDependencyID = 0,
                        ProjectID = projectId,
                        SourceSceneID = sourceSceneId,
                        TargetSceneID = targetSceneId,
                        SceneDependencyTypeID = dependencyTypeId,
                        Description = CombineText(
                            dependency.Description,
                            MetadataLine("Dependency type", dependency.DependencyType),
                            MetadataLine("Strength", dependency.Strength),
                            dependency.Notes,
                            ImportMarker),
                        IsHardDependency = ResolveSceneDependencyStrength(dependency.Strength)
                    },
                    transaction,
                    commandType: CommandType.StoredProcedure);
                sceneDependenciesCreated++;
            }

            transaction.Commit();
            return new ImportResult
            {
                Succeeded = true,
                ProjectID = projectId,
                ProjectName = projectName,
                BooksCreated = booksCreated,
                ChaptersCreated = chaptersCreated,
                ScenesCreated = scenesCreated,
                CharactersCreated = charactersCreated,
                LocationsCreated = locationsCreated,
                SceneCharacterAppearancesCreated = appearancesCreated,
                PlotLinesCreated = plotLinesCreated,
                PlotThreadsCreated = plotThreadsCreated,
                ThreadEventsCreated = threadEventsCreated,
                StoryAssetsCreated = storyAssetsCreated,
                AssetStatesCreated = assetStatesCreated,
                AssetEventsCreated = assetEventsCreated,
                AssetCustodyEventsCreated = assetCustodyEventsCreated,
                SceneDependenciesCreated = sceneDependenciesCreated,
                SkippedItemCount = skippedFields.Count,
                UnresolvedReferenceCount = unresolvedWarnings.Count,
                SkippedOptionalFields = skippedFields,
                UnresolvedWarnings = unresolvedWarnings,
                Message = "Import complete."
            };
        }
        catch
        {
            transaction.Rollback();
            throw;
        }
    }

    private static async Task<ImportLookupIds> GetLookupIdsAsync(IDbConnection connection, IDbTransaction transaction)
    {
        using var result = await connection.QueryMultipleAsync(
            "dbo.Import_LookupIds",
            transaction: transaction,
            commandType: CommandType.StoredProcedure);
        var requiredLookups = await result.ReadSingleAsync<ImportRequiredLookupRow>();
        var locationTypes = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var roleTypes = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var presenceTypes = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var plotLineTypes = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var plotImportance = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var threadTypes = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var threadStatuses = (await result.ReadAsync<ImportStatusLookupRow>()).ToDictionary(x => x.StatusName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var threadEventTypes = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var sceneDependencyTypes = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var assetKinds = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var assetStates = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var assetEventTypes = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var assetCustodyEventTypes = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
        var custodyRoles = (await result.ReadAsync<ImportLookupRow>()).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);

        if (!requiredLookups.RevisionStatusID.HasValue || !requiredLookups.TimeModeID.HasValue || !requiredLookups.TimeConfidenceID.HasValue)
        {
            throw new InvalidOperationException("Import lookup values are missing. Run the core database script before importing.");
        }

        return new ImportLookupIds(
            requiredLookups.RevisionStatusID.Value,
            requiredLookups.TimeModeID.Value,
            requiredLookups.TimeConfidenceID.Value,
            locationTypes,
            roleTypes,
            presenceTypes,
            plotLineTypes,
            plotImportance,
            threadTypes,
            threadStatuses,
            threadEventTypes,
            sceneDependencyTypes,
            assetKinds,
            assetStates,
            assetEventTypes,
            assetCustodyEventTypes,
            custodyRoles);
    }

    private static string FormatBookTitle(ImportBookDto book)
        => string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}";

    private static string ImportMetadata(PlotLineImportPackage package)
    {
        var source = string.IsNullOrWhiteSpace(package.Source) ? null : $"Source: {package.Source.Trim()}";
        return CombineText(
            ImportMarker,
            $"Imported at {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC",
            source,
            MetadataLine("Package created by", package.CreatedBy),
            MetadataLine("Package created date", package.CreatedDate),
            MetadataLine("Package description", package.Description)) ?? ImportMarker;
    }

    private static IReadOnlyList<string> GetSkippedOptionalFields(PlotLineImportPackage package)
    {
        var skipped = new List<string>();
        var characterNames = BuildNameKeySet(package.Characters.SelectMany(x => new[] { x.Name, x.DisplayName }));
        var locationNames = BuildNameKeySet(package.Locations.SelectMany(x => new[] { x.Name, x.DisplayName }));

        if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName) && !ContainsNameKey(characterNames, ResolveAlias(scene.PovCharacterName, package.Aliases.Characters))))))
        {
            skipped.Add("POV character names were stored as plain text in scene summaries; Character records were not created.");
        }

        if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName) && !ContainsNameKey(locationNames, ResolveAlias(scene.LocationName, package.Aliases.Locations))))))
        {
            skipped.Add("Location names were stored as plain text in scene summaries; Location records were not created.");
        }

        if (package.PlotLines.Any(plotLine => plotLine.Importance.HasValue))
        {
            skipped.Add("Plot line importance was stored as text in plot line descriptions; PlotLine records do not have an importance column.");
        }

        if (package.PlotLines.Any(plotLine => plotLine.Threads.Any(thread => thread.Events.Any(threadEvent => threadEvent.Importance.HasValue || !string.IsNullOrWhiteSpace(threadEvent.Impact)))))
        {
            skipped.Add("Thread event impact and importance were stored in event descriptions.");
        }

        if (package.StoryAssets.Any(asset => asset.Events.Any(assetEvent => !string.IsNullOrWhiteSpace(assetEvent.CharacterName))))
        {
            skipped.Add("Asset event character names were stored in event descriptions.");
        }

        if (package.SceneDependencies.Any(dependency => !string.IsNullOrWhiteSpace(dependency.DependencyType) || !string.IsNullOrWhiteSpace(dependency.Strength)))
        {
            skipped.Add("Scene dependency type and strength labels were mapped to existing dependency fields and preserved in descriptions.");
        }

        return skipped;
    }

    private static IReadOnlyList<string> GetUnresolvedWarnings(PlotLineImportPackage package)
    {
        var warnings = new List<string>();
        var characterNames = BuildNameKeySet(package.Characters.SelectMany(x => new[] { x.Name, x.DisplayName }));
        var locationNames = BuildNameKeySet(package.Locations.SelectMany(x => new[] { x.Name, x.DisplayName }));

        foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.PovCharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(characterNames, ResolveAlias(name, package.Aliases.Characters))).Distinct(StringComparer.OrdinalIgnoreCase))
        {
            warnings.Add($"Unmatched POV character: {name}");
        }

        foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.LocationName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(locationNames, ResolveAlias(name, package.Aliases.Locations))).Distinct(StringComparer.OrdinalIgnoreCase))
        {
            warnings.Add($"Unmatched scene location: {name}");
        }

        foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).SelectMany(scene => scene.SceneCharacters).Select(sceneCharacter => sceneCharacter.CharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(characterNames, ResolveAlias(name, package.Aliases.Characters))).Distinct(StringComparer.OrdinalIgnoreCase))
        {
            warnings.Add($"Unmatched scene character: {name}");
        }

        var sceneKeys = package.Books
            .SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => SceneKey(book.SeriesOrder, chapter.Order, scene.Order))))
            .ToHashSet(StringComparer.OrdinalIgnoreCase);
        foreach (var reference in package.PlotLines.SelectMany(plotLine => plotLine.Threads.SelectMany(thread => thread.Events.Select((threadEvent, index) => new
                 {
                     PlotLineName = DisplayName(plotLine.DisplayName, plotLine.Name),
                     ThreadName = DisplayName(thread.DisplayName, thread.Name),
                     EventTitle = string.IsNullOrWhiteSpace(threadEvent.Title) ? $"Event {index + 1}" : threadEvent.Title.Trim(),
                     Event = threadEvent
                 })))
                 .Where(x => !x.Event.BookOrder.HasValue || !x.Event.ChapterOrder.HasValue || !x.Event.SceneOrder.HasValue || !sceneKeys.Contains(SceneKey(x.Event.BookOrder.GetValueOrDefault(), x.Event.ChapterOrder.GetValueOrDefault(), x.Event.SceneOrder.GetValueOrDefault())))
                 .Select(x => $"{x.PlotLineName} / {x.ThreadName} / {x.EventTitle}")
                 .Distinct(StringComparer.OrdinalIgnoreCase))
        {
            warnings.Add($"Unresolved thread event scene reference: {reference}");
        }

        foreach (var name in package.StoryAssets.Select(asset => asset.InitialOwnerCharacterName?.Trim())
                     .Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.CharacterName?.Trim())))
                     .Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(characterNames, ResolveAlias(name, package.Aliases.Characters)))
                     .Distinct(StringComparer.OrdinalIgnoreCase))
        {
            warnings.Add($"Unmatched asset character: {name}");
        }

        foreach (var name in package.StoryAssets.Select(asset => asset.InitialLocationName?.Trim())
                     .Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.LocationName?.Trim())))
                     .Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(locationNames, ResolveAlias(name, package.Aliases.Locations)))
                     .Distinct(StringComparer.OrdinalIgnoreCase))
        {
            warnings.Add($"Unmatched asset location: {name}");
        }

        foreach (var reference in package.StoryAssets.SelectMany(asset => asset.States.Select((state, index) => new
                 {
                     AssetName = DisplayName(asset.DisplayName, asset.Name),
                     Label = string.IsNullOrWhiteSpace(state.StateName) ? $"State {index + 1}" : state.StateName.Trim(),
                     State = state
                 }))
                 .Where(x => !x.State.BookOrder.HasValue || !x.State.ChapterOrder.HasValue || !x.State.SceneOrder.HasValue || !sceneKeys.Contains(SceneKey(x.State.BookOrder.GetValueOrDefault(), x.State.ChapterOrder.GetValueOrDefault(), x.State.SceneOrder.GetValueOrDefault())))
                 .Select(x => $"{x.AssetName} / state / {x.Label}")
                 .Distinct(StringComparer.OrdinalIgnoreCase))
        {
            warnings.Add($"Unresolved asset state scene reference: {reference}");
        }

        foreach (var reference in package.StoryAssets.SelectMany(asset => asset.Events.Select((assetEvent, index) => new
                 {
                     AssetName = DisplayName(asset.DisplayName, asset.Name),
                     Label = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {index + 1}" : assetEvent.Title.Trim(),
                     Event = assetEvent
                 }))
                 .Where(x => !x.Event.BookOrder.HasValue || !x.Event.ChapterOrder.HasValue || !x.Event.SceneOrder.HasValue || !sceneKeys.Contains(SceneKey(x.Event.BookOrder.GetValueOrDefault(), x.Event.ChapterOrder.GetValueOrDefault(), x.Event.SceneOrder.GetValueOrDefault())))
                 .Select(x => $"{x.AssetName} / event / {x.Label}")
                 .Distinct(StringComparer.OrdinalIgnoreCase))
        {
            warnings.Add($"Unresolved asset event scene reference: {reference}");
        }

        foreach (var reference in package.SceneDependencies.SelectMany((dependency, index) => new[]
                 {
                     new { Label = $"Dependency {index + 1} fromScene", Reference = dependency.FromScene },
                     new { Label = $"Dependency {index + 1} toScene", Reference = dependency.ToScene }
                 })
                 .Where(x => x.Reference?.BookOrder.HasValue != true
                             || !x.Reference.ChapterOrder.HasValue
                             || !x.Reference.SceneOrder.HasValue
                             || !sceneKeys.Contains(SceneKey(x.Reference.BookOrder.GetValueOrDefault(), x.Reference.ChapterOrder.GetValueOrDefault(), x.Reference.SceneOrder.GetValueOrDefault())))
                 .Select(x => $"{x.Label} -> {SceneReferenceKey(x.Reference)}")
                 .Distinct(StringComparer.OrdinalIgnoreCase))
        {
            warnings.Add($"Unresolved scene dependency reference: {reference}");
        }

        return warnings;
    }

    private static IEnumerable<ImportAppearance> BuildSceneAppearances(ImportSceneDto scene, int? povCharacterId, IReadOnlyDictionary<string, int> characterMap, Dictionary<string, string>? aliases)
    {
        var appearances = new Dictionary<int, ImportAppearance>();

        foreach (var sceneCharacter in scene.SceneCharacters)
        {
            if (string.IsNullOrWhiteSpace(sceneCharacter.CharacterName)
                || !ResolveId(characterMap, sceneCharacter.CharacterName, aliases).HasValue)
            {
                continue;
            }

            var characterId = ResolveId(characterMap, sceneCharacter.CharacterName, aliases)!.Value;
            appearances[characterId] = new ImportAppearance(characterId, sceneCharacter.RoleInScene, sceneCharacter.Importance, sceneCharacter.Notes);
        }

        if (povCharacterId.HasValue && !appearances.ContainsKey(povCharacterId.Value))
        {
            appearances[povCharacterId.Value] = new ImportAppearance(povCharacterId.Value, "POV", null, "Auto-added from povCharacterName.");
        }

        return appearances.Values;
    }

    private static int? ResolveId(IReadOnlyDictionary<string, int> map, string? name, Dictionary<string, string>? aliases)
    {
        var resolvedName = ResolveAlias(name, aliases);
        foreach (var key in CandidateNameKeys(resolvedName))
        {
            if (map.TryGetValue(key, out var id) && id > 0)
            {
                return id;
            }
        }

        return null;
    }

    private static void AddLookupKeys(Dictionary<string, int> map, string? name, int id)
    {
        foreach (var key in CandidateNameKeys(name))
        {
            if (map.TryGetValue(key, out var existingId) && existingId != id)
            {
                map[key] = 0;
            }
            else
            {
                map[key] = id;
            }
        }
    }

    private static HashSet<string> BuildNameKeySet(IEnumerable<string?> names)
    {
        var keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var name in names)
        {
            foreach (var key in CandidateNameKeys(name))
            {
                keys.Add(key);
            }
        }

        return keys;
    }

    private static bool ContainsNameKey(HashSet<string> keys, string? name)
        => CandidateNameKeys(name).Any(keys.Contains);

    private static string ResolveAlias(string? name, Dictionary<string, string>? aliases)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            return string.Empty;
        }

        var clean = CollapseSpaces(name);
        if (aliases is null || aliases.Count == 0)
        {
            return clean;
        }

        foreach (var key in CandidateNameKeys(clean))
        {
            foreach (var alias in aliases)
            {
                if (CandidateNameKeys(alias.Key).Contains(key, StringComparer.OrdinalIgnoreCase))
                {
                    return CollapseSpaces(alias.Value);
                }
            }
        }

        return clean;
    }

    private static IEnumerable<string> CandidateNameKeys(string? name)
    {
        var normalized = NormalizeNameKey(name);
        if (normalized.Length == 0)
        {
            yield break;
        }

        yield return normalized;

        var punctuationInsensitive = new string(normalized.Where(ch => !char.IsPunctuation(ch)).ToArray());
        punctuationInsensitive = CollapseSpaces(punctuationInsensitive);
        if (punctuationInsensitive.Length > 0 && punctuationInsensitive != normalized)
        {
            yield return punctuationInsensitive;
        }
    }

    private static string NormalizeNameKey(string? value)
        => CollapseSpaces(value).ToUpperInvariant();

    private static string CollapseSpaces(string? value)
    {
        var clean = Clean(value);
        if (clean.Length == 0)
        {
            return string.Empty;
        }

        var builder = new StringBuilder(clean.Length);
        var lastWasSpace = false;
        foreach (var ch in clean)
        {
            if (char.IsWhiteSpace(ch))
            {
                if (!lastWasSpace)
                {
                    builder.Append(' ');
                    lastWasSpace = true;
                }
            }
            else
            {
                builder.Append(ch);
                lastWasSpace = false;
            }
        }

        return builder.ToString();
    }

    private static int? LookupLocationTypeId(ImportLookupIds lookupIds, string? type)
    {
        if (!string.IsNullOrWhiteSpace(type) && lookupIds.LocationTypeIds.TryGetValue(type.Trim(), out var id))
        {
            return id;
        }

        return lookupIds.LocationTypeIds.TryGetValue("Other", out var otherId) ? otherId : null;
    }

    private static int? ResolveRoleTypeId(ImportLookupIds lookupIds, string? role)
    {
        var normalized = Clean(role);
        if (normalized.Length == 0)
        {
            normalized = "Present";
        }

        var roleName = normalized.Equals("POV", StringComparison.OrdinalIgnoreCase)
            ? "POV Character"
            : normalized.Contains("protagonist", StringComparison.OrdinalIgnoreCase)
                || normalized.Contains("main", StringComparison.OrdinalIgnoreCase)
                || normalized.Contains("major", StringComparison.OrdinalIgnoreCase)
                || normalized.Contains("lead", StringComparison.OrdinalIgnoreCase)
                || normalized.Equals("Present", StringComparison.OrdinalIgnoreCase)
                ? "Main Participant"
                : normalized.Equals("Supporting", StringComparison.OrdinalIgnoreCase)
                    || normalized.Contains("secondary", StringComparison.OrdinalIgnoreCase)
                    ? "Supporting Participant"
                    : normalized.Equals("Mentioned", StringComparison.OrdinalIgnoreCase)
                        ? "Mentioned Only"
                        : normalized.Equals("Antagonist", StringComparison.OrdinalIgnoreCase)
                            ? "Main Participant"
                            : normalized;

        if (lookupIds.RoleTypeIds.TryGetValue(roleName, out var id))
        {
            return id;
        }

        return lookupIds.RoleTypeIds.TryGetValue("Supporting Participant", out var supportingId) ? supportingId : null;
    }

    private static int? ResolvePresenceTypeId(ImportLookupIds lookupIds, string? role)
    {
        var normalized = Clean(role);
        var presenceName = normalized.Equals("Mentioned", StringComparison.OrdinalIgnoreCase)
            ? "Mentioned Only"
            : normalized.Equals("Background", StringComparison.OrdinalIgnoreCase)
                ? "Present"
                : normalized.Equals("POV", StringComparison.OrdinalIgnoreCase) || normalized.Equals("Antagonist", StringComparison.OrdinalIgnoreCase) || normalized.Equals("Supporting", StringComparison.OrdinalIgnoreCase)
                    ? "Present"
                    : normalized.Length == 0 ? "Present" : normalized;

        if (lookupIds.PresenceTypeIds.TryGetValue(presenceName, out var id))
        {
            return id;
        }

        return lookupIds.PresenceTypeIds.TryGetValue("Present", out var presentId) ? presentId : null;
    }

    private static int ResolvePlotLineTypeId(ImportLookupIds lookupIds, string? type)
    {
        if (!string.IsNullOrWhiteSpace(type) && lookupIds.PlotLineTypeIds.TryGetValue(type.Trim(), out var id))
        {
            return id;
        }

        return lookupIds.PlotLineTypeIds.TryGetValue("Side Plot", out var sidePlotId)
            ? sidePlotId
            : lookupIds.PlotLineTypeIds.Values.First();
    }

    private static int ResolvePlotImportanceId(ImportLookupIds lookupIds, ImportPlotLineDto plotLine)
    {
        if (string.Equals(plotLine.Type?.Trim(), "Main Plot", StringComparison.OrdinalIgnoreCase)
            && lookupIds.PlotImportanceIds.TryGetValue("Primary", out var primaryId))
        {
            return primaryId;
        }

        return lookupIds.PlotImportanceIds.TryGetValue("Secondary", out var secondaryId)
            ? secondaryId
            : lookupIds.PlotImportanceIds.Values.First();
    }

    private static int ResolveThreadTypeId(ImportLookupIds lookupIds, string? type)
    {
        if (!string.IsNullOrWhiteSpace(type) && lookupIds.ThreadTypeIds.TryGetValue(type.Trim(), out var id))
        {
            return id;
        }

        return lookupIds.ThreadTypeIds.TryGetValue("Question", out var questionId)
            ? questionId
            : lookupIds.ThreadTypeIds.Values.First();
    }

    private static int ResolveThreadStatusId(ImportLookupIds lookupIds, string? status)
    {
        if (!string.IsNullOrWhiteSpace(status) && lookupIds.ThreadStatusIds.TryGetValue(status.Trim(), out var id))
        {
            return id;
        }

        return lookupIds.ThreadStatusIds.TryGetValue("Planned", out var plannedId)
            ? plannedId
            : lookupIds.ThreadStatusIds.Values.First();
    }

    private static int ResolveThreadEventTypeId(ImportLookupIds lookupIds, string? eventType)
    {
        if (!string.IsNullOrWhiteSpace(eventType) && lookupIds.ThreadEventTypeIds.TryGetValue(eventType.Trim(), out var id))
        {
            return id;
        }

        return lookupIds.ThreadEventTypeIds.TryGetValue("Mentioned", out var mentionedId)
            ? mentionedId
            : lookupIds.ThreadEventTypeIds.Values.First();
    }

    private static int? NormalizeCharacterImportance(string? role, int? importance)
    {
        var normalized = Clean(role).ToLowerInvariant();
        if (normalized.Contains("protagonist", StringComparison.OrdinalIgnoreCase)
            || normalized.Contains("main", StringComparison.OrdinalIgnoreCase)
            || normalized.Contains("major", StringComparison.OrdinalIgnoreCase)
            || normalized.Contains("lead", StringComparison.OrdinalIgnoreCase)
            || normalized.Contains("antagonist", StringComparison.OrdinalIgnoreCase))
        {
            return 10;
        }

        if (normalized.Contains("minor", StringComparison.OrdinalIgnoreCase)
            || normalized.Contains("background", StringComparison.OrdinalIgnoreCase)
            || normalized.Contains("cameo", StringComparison.OrdinalIgnoreCase))
        {
            return 2;
        }

        if (normalized.Contains("support", StringComparison.OrdinalIgnoreCase)
            || normalized.Contains("secondary", StringComparison.OrdinalIgnoreCase))
        {
            return 6;
        }

        if (importance >= 8)
        {
            return 10;
        }

        if (importance >= 5)
        {
            return 6;
        }

        return importance.HasValue ? 2 : null;
    }

    private static int ResolveSceneDependencyTypeId(ImportLookupIds lookupIds, string? dependencyType)
    {
        var normalized = Clean(dependencyType);
        if (normalized.Length > 0 && lookupIds.SceneDependencyTypeIds.TryGetValue(normalized, out var id))
        {
            return id;
        }

        var mappedType = normalized.ToLowerInvariant() switch
        {
            "causes" or "requires" or "blocks" or "follows" => "Must Occur Before",
            "reveals" or "foreshadows" or "paysoff" or "pays off" => "Reveal Setup",
            "contradicts" => "Knowledge Dependency",
            "mirrors" => "Emotional Setup",
            _ => "Must Occur Before"
        };

        return lookupIds.SceneDependencyTypeIds.TryGetValue(mappedType, out var mappedId)
            ? mappedId
            : lookupIds.SceneDependencyTypeIds.Values.First();
    }

    private static bool ResolveSceneDependencyStrength(string? strength)
    {
        var normalized = Clean(strength);
        if (normalized.Length == 0)
        {
            return false;
        }

        if (int.TryParse(normalized, out var numeric))
        {
            return numeric >= 7;
        }

        return normalized.Equals("Hard", StringComparison.OrdinalIgnoreCase)
            || normalized.Equals("Strong", StringComparison.OrdinalIgnoreCase)
            || normalized.Equals("Required", StringComparison.OrdinalIgnoreCase)
            || normalized.Equals("High", StringComparison.OrdinalIgnoreCase)
            || normalized.Equals("Critical", StringComparison.OrdinalIgnoreCase);
    }

    private static int ResolveAssetKindId(ImportLookupIds lookupIds, string? type)
    {
        if (!string.IsNullOrWhiteSpace(type) && lookupIds.AssetKindIds.TryGetValue(type.Trim(), out var id))
        {
            return id;
        }

        return lookupIds.AssetKindIds.TryGetValue("Object", out var objectId)
            ? objectId
            : lookupIds.AssetKindIds.Values.First();
    }

    private static int? ResolveAssetStateId(ImportLookupIds lookupIds, string? state)
    {
        if (!string.IsNullOrWhiteSpace(state) && lookupIds.AssetStateIds.TryGetValue(state.Trim(), out var id))
        {
            return id;
        }

        return null;
    }

    private static int ResolveAssetEventTypeId(ImportLookupIds lookupIds, string? eventType)
    {
        if (!string.IsNullOrWhiteSpace(eventType) && lookupIds.AssetEventTypeIds.TryGetValue(eventType.Trim(), out var id))
        {
            return id;
        }

        return lookupIds.AssetEventTypeIds.TryGetValue("Mentioned", out var mentionedId)
            ? mentionedId
            : lookupIds.AssetEventTypeIds.Values.First();
    }

    private static int ResolveAssetCustodyEventTypeId(ImportLookupIds lookupIds, string? eventType)
    {
        if (!string.IsNullOrWhiteSpace(eventType) && lookupIds.AssetCustodyEventTypeIds.TryGetValue(eventType.Trim(), out var id))
        {
            return id;
        }

        return lookupIds.AssetCustodyEventTypesFallback();
    }

    private static int ResolveCustodyRoleId(ImportLookupIds lookupIds, string? role)
    {
        if (!string.IsNullOrWhiteSpace(role) && lookupIds.CustodyRoleIds.TryGetValue(role.Trim(), out var id))
        {
            return id;
        }

        return lookupIds.CustodyRoleIds.TryGetValue("Holder", out var holderId)
            ? holderId
            : lookupIds.CustodyRoleIds.Values.First();
    }

    private static int ClampImportance(int? importance)
        => Math.Clamp(importance ?? 5, 1, 10);

    private static bool TryResolveThreadEventScene(IReadOnlyDictionary<string, int> sceneMap, ImportThreadEventDto threadEvent, out int sceneId)
    {
        if (threadEvent.BookOrder.HasValue && threadEvent.ChapterOrder.HasValue && threadEvent.SceneOrder.HasValue)
        {
            return sceneMap.TryGetValue(SceneKey(threadEvent.BookOrder.Value, threadEvent.ChapterOrder.Value, threadEvent.SceneOrder.Value), out sceneId);
        }

        sceneId = 0;
        return false;
    }

    private static bool TryResolveAssetStateScene(IReadOnlyDictionary<string, int> sceneMap, ImportAssetStateDto state, out int sceneId)
    {
        if (state.BookOrder.HasValue && state.ChapterOrder.HasValue && state.SceneOrder.HasValue)
        {
            return sceneMap.TryGetValue(SceneKey(state.BookOrder.Value, state.ChapterOrder.Value, state.SceneOrder.Value), out sceneId);
        }

        sceneId = 0;
        return false;
    }

    private static bool TryResolveAssetEventScene(IReadOnlyDictionary<string, int> sceneMap, ImportAssetEventDto assetEvent, out int sceneId)
    {
        if (assetEvent.BookOrder.HasValue && assetEvent.ChapterOrder.HasValue && assetEvent.SceneOrder.HasValue)
        {
            return sceneMap.TryGetValue(SceneKey(assetEvent.BookOrder.Value, assetEvent.ChapterOrder.Value, assetEvent.SceneOrder.Value), out sceneId);
        }

        sceneId = 0;
        return false;
    }

    private static bool TryResolveSceneReference(IReadOnlyDictionary<string, int> sceneMap, ImportSceneReferenceDto? reference, out int sceneId)
    {
        if (reference?.BookOrder.HasValue == true && reference.ChapterOrder.HasValue && reference.SceneOrder.HasValue)
        {
            return sceneMap.TryGetValue(SceneKey(reference.BookOrder.Value, reference.ChapterOrder.Value, reference.SceneOrder.Value), out sceneId);
        }

        sceneId = 0;
        return false;
    }

    private static string SceneKey(int bookOrder, int chapterOrder, int sceneOrder)
        => $"{bookOrder}/{chapterOrder}/{sceneOrder}";

    private static string SceneReferenceKey(ImportSceneReferenceDto? reference)
        => reference is null
            ? "(missing)"
            : $"{reference.BookOrder?.ToString() ?? "?"}/{reference.ChapterOrder?.ToString() ?? "?"}/{reference.SceneOrder?.ToString() ?? "?"}";

    private static string Clean(string? value) => (value ?? string.Empty).Trim();

    private static string? MetadataLine(string label, string? value)
        => string.IsNullOrWhiteSpace(value) ? null : $"{label}: {value.Trim()}";

    private static string? Truncate(string? value, int maxLength)
    {
        var clean = Clean(value);
        return clean.Length == 0 ? null : clean[..Math.Min(clean.Length, maxLength)];
    }

    private static string? CombineText(params string?[] values)
    {
        var parts = values
            .Select(Clean)
            .Where(x => !string.IsNullOrWhiteSpace(x))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToList();

        if (parts.Count == 0)
        {
            return null;
        }

        var builder = new StringBuilder();
        foreach (var part in parts)
        {
            if (builder.Length > 0)
            {
                builder.AppendLine().AppendLine();
            }

            builder.Append(part);
        }

        return builder.ToString();
    }

    private static string DisplayName(string? displayName, string fallback)
        => string.IsNullOrWhiteSpace(displayName) ? fallback : displayName;

    private sealed record ImportLookupIds(
        int RevisionStatusID,
        int TimeModeID,
        int TimeConfidenceID,
        IReadOnlyDictionary<string, int> LocationTypeIds,
        IReadOnlyDictionary<string, int> RoleTypeIds,
        IReadOnlyDictionary<string, int> PresenceTypeIds,
        IReadOnlyDictionary<string, int> PlotLineTypeIds,
        IReadOnlyDictionary<string, int> PlotImportanceIds,
        IReadOnlyDictionary<string, int> ThreadTypeIds,
        IReadOnlyDictionary<string, int> ThreadStatusIds,
        IReadOnlyDictionary<string, int> ThreadEventTypeIds,
        IReadOnlyDictionary<string, int> SceneDependencyTypeIds,
        IReadOnlyDictionary<string, int> AssetKindIds,
        IReadOnlyDictionary<string, int> AssetStateIds,
        IReadOnlyDictionary<string, int> AssetEventTypeIds,
        IReadOnlyDictionary<string, int> AssetCustodyEventTypeIds,
        IReadOnlyDictionary<string, int> CustodyRoleIds)
    {
        public int AssetCustodyEventTypesFallback()
            => AssetCustodyEventTypeIds.TryGetValue("Has", out var hasId)
                ? hasId
                : AssetCustodyEventTypeIds.Values.First();
    }

    private sealed record ImportAppearance(int CharacterId, string? RoleInScene, int? Importance, string? Notes);

    private sealed class ImportLookupRow
    {
        public int LookupID { get; set; }
        public string TypeName { get; set; } = string.Empty;
    }

    private sealed class ImportRequiredLookupRow
    {
        public int? RevisionStatusID { get; set; }
        public int? TimeModeID { get; set; }
        public int? TimeConfidenceID { get; set; }
    }

    private sealed class ImportStatusLookupRow
    {
        public int LookupID { get; set; }
        public string StatusName { get; set; } = string.Empty;
    }

    private sealed class IdentityMaxRow
    {
        public string TableName { get; set; } = string.Empty;
        public int MaxID { get; set; }
    }
}
