using AsbCloudApp.Data;
using AsbCloudApp.Data.User;
using AsbCloudApp.Exceptions;
using AsbCloudApp.Repositories;
using AsbCloudApp.Services;
using AsbCloudDb.Model;
using AsbCloudInfrastructure.Background;
using AsbCloudInfrastructure.Services.DrillingProgram.Convert;
using Mapster;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AsbCloudApp.Requests;
using AsbCloudApp.Services.Notifications;
using AsbCloudInfrastructure.Services.Email;

namespace AsbCloudInfrastructure.Services.DrillingProgram
{
# nullable enable
    public class DrillingProgramService : IDrillingProgramService
    {
        private static readonly Dictionary<string, DrillingProgramCreateError> drillingProgramCreateErrors = new Dictionary<string, DrillingProgramCreateError>();
        
        private readonly IAsbCloudDbContext context;
        private readonly FileService fileService;
        private readonly IUserRepository userRepository;
        private readonly IWellService wellService;
        private readonly IConfiguration configuration;
        private readonly BackgroundWorker backgroundWorker;
        private readonly NotificationService notificationService;
        
        private const int idTransportType = 1;
        
        private const int idFileCategoryDrillingProgram = 1000;
        private const int idFileCategoryDrillingProgramPartsStart = 1001;
        private const int idFileCategoryDrillingProgramPartsEnd = 1100;

        private const int idPartStateNoFile = 0;
        private const int idPartStateApproving = 1;
        private const int idPartStateApproved = 2;

        private const int idMarkTypeReject = 0;
        private const int idMarkTypeApprove = 1;

        private const int idUserRolePublisher = 1;
        private const int idUserRoleApprover = 2;

        private const int idStateNotInitialized = 0;
        private const int idStateApproving = 1;
        private const int idStateCreating = 2;
        private const int idStateReady = 3;
        private const int idStateError = 4;

        private static readonly string[] validFileExtensions = ConvertToPdf.filesExtensions;

        public DrillingProgramService(
            IAsbCloudDbContext context,
            FileService fileService,
            IUserRepository userRepository,
            IWellService wellService,
            IConfiguration configuration,
            BackgroundWorker backgroundWorker,
            NotificationService notificationService)
        {
            this.context = context;
            this.fileService = fileService;
            this.userRepository = userRepository;
            this.wellService = wellService;
            this.configuration = configuration;
            this.backgroundWorker = backgroundWorker;
            this.notificationService = notificationService;
        }

        public async Task<IEnumerable<UserDto>> GetAvailableUsers(int idWell, CancellationToken token = default)
        {
            var users = await context.RelationCompaniesWells
                .Include(r => r.Company)
                .ThenInclude(c => c.Users)
                .Where(r => r.IdWell == idWell)
                .SelectMany(r => r.Company.Users)
                .Where(u => u != null && !string.IsNullOrEmpty(u.Email))
                .ToListAsync(token);
            var usersDto = users.Adapt<IEnumerable<UserDto>>();
            return usersDto;
        }

        public async Task<IEnumerable<FileCategoryDto>> GetCategoriesAsync(CancellationToken token = default)
        {
            var result = await context.FileCategories
                .Where(c => c.Id > idFileCategoryDrillingProgramPartsStart && c.Id < idFileCategoryDrillingProgramPartsEnd)
                .ToListAsync(token);
            return result.Select(c => c.Adapt<FileCategoryDto>());
        }

        public async Task<DrillingProgramStateDto> GetStateAsync(int idWell, int idUser, CancellationToken token = default)
        {
            var fileCategories = await context.FileCategories
                .Where(c => c.Id >= idFileCategoryDrillingProgramPartsStart &&
                    c.Id < idFileCategoryDrillingProgramPartsEnd)
                .ToListAsync(token);

            var files = await context.Files
                .Include(f => f.FileMarks)
                .ThenInclude(m => m.User)
                .Include(f => f.Author)
                .Include(f => f.FileCategory)
                .Where(f => f.IdWell == idWell &&
                    f.IdCategory >= idFileCategoryDrillingProgram &&
                    f.IdCategory < idFileCategoryDrillingProgramPartsEnd &&
                    f.IsDeleted == false)
                .OrderBy(f => f.UploadDate)
                .ToListAsync(token);

            var partEntities = await context.DrillingProgramParts
                .Include(p => p.RelatedUsers)
                .ThenInclude(r => r.User)
                .ThenInclude(u => u.Company)
                .Where(p => p.IdWell == idWell)
                .ToListAsync(token);

            var parts = new List<DrillingProgramPartDto>(partEntities.Count);
            var timezoneOffset = wellService.GetTimezone(idWell)?.Hours ?? 5;
            foreach (var partEntity in partEntities)
            {
                var part = ConvertPart(idUser, fileCategories, files, partEntity, timezoneOffset);
                parts.Add(part);
            }

            var state = new DrillingProgramStateDto
            {
                Parts = parts,
                Program = files.FirstOrDefault(f => f.IdCategory == idFileCategoryDrillingProgram)
                    ?.Adapt<FileInfoDto>(),
                PermissionToEdit = userRepository.HasPermission(idUser, "DrillingProgram.edit"),
            };

            if (parts.Any())
            {
                if (parts.All(p => p.IdState == idPartStateApproved))
                {
                    if (state.Program is not null)
                    {
                        state.IdState = idStateReady;
                    }
                    else
                    {
                        var workId = MakeWorkId(idWell);
                        if (drillingProgramCreateErrors.ContainsKey(workId))
                        {
                            state.IdState = idStateError;
                            state.Error = drillingProgramCreateErrors[workId];
                        }
                        else
                            state.IdState = idStateCreating;
                    }
                }
                else
                    state.IdState = idStateApproving;
            }
            else
                state.IdState = idStateNotInitialized;

            await EnqueueMakeProgramWorkAsync(idWell, state, token);
            return state;
        }

        private static bool IsFileExtensionValid(string file)
        {
            var fileExt = Path.GetExtension(file).ToLower();
            return validFileExtensions.Contains(fileExt);
        }

        public async Task<int> AddFile(int idWell, int idFileCategory, int idUser, string fileFullName, Stream fileStream, CancellationToken token = default)
        {
            if (!IsFileExtensionValid(fileFullName))
                throw new FileFormatException($"Файл {fileFullName} - не поддерживаемого формата. Файл не может быть загружен.");

            var part = await context.DrillingProgramParts
                    .Include(p => p.RelatedUsers)
                    .ThenInclude(r => r.User)
                    .FirstOrDefaultAsync(p => p.IdWell == idWell && p.IdFileCategory == idFileCategory, token)
                    ?? throw new ArgumentInvalidException(nameof(idFileCategory), $"DrillingProgramPart id == {idFileCategory} does not exist");

            if (!part.RelatedUsers.Any(r => r.IdUser == idUser && r.IdUserRole == idUserRolePublisher))
                throw new ForbidException($"User {idUser} is not in the publisher list.");

            var result = await fileService.SaveAsync(
                part.IdWell,
                idUser,
                part.IdFileCategory,
                fileFullName,
                fileStream,
                token);

            await RemoveDrillingProgramAsync(part.IdWell, token);

            await NotifyApproversAsync(part, result.Id, fileFullName, token);

            return result.Id;
        }

        public async Task<int> AddPartsAsync(int idWell, IEnumerable<int> idFileCategories, CancellationToken token = default)
        {
            if (!idFileCategories.Any())
                return 0;

            var existingCategories = await context.DrillingProgramParts
                .Where(p => p.IdWell == idWell)
                .Select(p => p.IdFileCategory)
                .ToListAsync(token);

            var newParts = idFileCategories
                .Where(c => !existingCategories.Any(e => e == c))
                .Select(c => new DrillingProgramPart
                {
                    IdWell = idWell,
                    IdFileCategory = c,
                });

            context.DrillingProgramParts.AddRange(newParts);
            var affected = await context.SaveChangesAsync(token);

            await RemoveDrillingProgramAsync(idWell, token);
            return affected;
        }

        public async Task<int> RemovePartsAsync(int idWell, IEnumerable<int> idFileCategories, CancellationToken token = default)
        {
            var whereQuery = context.DrillingProgramParts
                .Where(p => p.IdWell == idWell && idFileCategories.Contains(p.IdFileCategory));

            context.DrillingProgramParts.RemoveRange(whereQuery);

            await RemoveDrillingProgramAsync(idWell, token);
            return await context.SaveChangesAsync(token);
        }

        public async Task<int> AddUserAsync(int idWell, int idFileCategory, int idUser, int idUserRole, CancellationToken token = default)
        {
            var user = await userRepository.GetOrDefaultAsync(idUser, token)
                ?? throw new ArgumentInvalidException(nameof(idUser), $"User id: {idUser} does not exist");

            var part = await context.DrillingProgramParts
                .Include(p => p.FileCategory)
                .FirstOrDefaultAsync(p => p.IdWell == idWell && p.IdFileCategory == idFileCategory, token)
                ?? throw new ArgumentInvalidException(nameof(idFileCategory), $"DrillingProgramPart idFileCategory: {idFileCategory} does not exist");

            if (idUserRole != idUserRoleApprover && idUserRole != idUserRolePublisher)
                throw new ArgumentInvalidException(nameof(idUserRole), $"idUserRole ({idUserRole}), should be approver ({idUserRoleApprover}) or publisher ({idUserRolePublisher})");

            var oldRelation = await context.RelationDrillingProgramPartUsers
                .FirstOrDefaultAsync(r => r.IdUser == idUser && r.IdDrillingProgramPart == part.Id, token);

            if (oldRelation is not null)
                context.RelationDrillingProgramPartUsers.Remove(oldRelation);

            var newRelation = new RelationUserDrillingProgramPart
            {
                IdUser = idUser,
                IdDrillingProgramPart = part.Id,
                IdUserRole = idUserRole,
            };
            context.RelationDrillingProgramPartUsers.Add(newRelation);
            if (idUserRole == idUserRoleApprover)
                await RemoveDrillingProgramAsync(part.IdWell, token);

            if (idUserRole == idUserRolePublisher)
                await NotifyNewPublisherAsync(idWell, user, part.FileCategory.Name, token);

            return await context.SaveChangesAsync(token);
        }

        public async Task<int> RemoveUserAsync(int idWell, int idFileCategory, int idUser, int idUserRole, CancellationToken token = default)
        {
            var whereQuery = context.RelationDrillingProgramPartUsers
                .Include(r => r.DrillingProgramPart)
                .Where(r => r.IdUser == idUser &&
                    r.IdUserRole == idUserRole &&
                    r.DrillingProgramPart.IdWell == idWell &&
                    r.DrillingProgramPart.IdFileCategory == idFileCategory);

            context.RelationDrillingProgramPartUsers.RemoveRange(whereQuery);

            return await context.SaveChangesAsync(token);
        }

        public async Task<int> AddOrReplaceFileMarkAsync(FileMarkDto fileMarkDto, int idUser, CancellationToken token)
        {
            if (fileMarkDto.IdMarkType != idMarkTypeApprove &&
                fileMarkDto.IdMarkType != idMarkTypeReject)
                throw new ArgumentInvalidException(nameof(fileMarkDto), $"В этом методе допустимы только отметки о принятии или отклонении.");

            var fileInfo = await fileService.GetOrDefaultAsync(fileMarkDto.IdFile, token)
                ?? throw new ArgumentInvalidException(nameof(fileMarkDto), $"Файла для такой отметки не существует.");

            if (fileInfo.IdCategory < idFileCategoryDrillingProgramPartsStart ||
                fileInfo.IdCategory > idFileCategoryDrillingProgramPartsEnd)
                throw new ArgumentInvalidException(nameof(fileMarkDto), $"Этот метод допустим только для файлов-частей программы бурения.");

            var part = await context.DrillingProgramParts
                .Include(p => p.RelatedUsers)
                .ThenInclude(r => r.User)
                .AsNoTracking()
                .FirstOrDefaultAsync(p => p.IdWell == fileInfo.IdWell && p.IdFileCategory == fileInfo.IdCategory, token);

            var user = part?.RelatedUsers.FirstOrDefault(r => r.IdUser == idUser && r.IdUserRole == idUserRoleApprover)?.User
                ?? throw new ForbidException($"User {idUser} is not in the approvers list.");

            fileMarkDto.User = user.Adapt<UserDto>();

            var oldMarksIds = fileInfo.FileMarks
                .Where(m => m.User?.Id == idUser)
                .Select(m => m.Id);

            if (oldMarksIds?.Any() == true)
                await fileService.MarkFileMarkAsDeletedAsync(oldMarksIds, token);

            var result = await fileService.CreateFileMarkAsync(fileMarkDto, idUser, token)
                .ConfigureAwait(false);

            if (fileMarkDto.IdMarkType == idMarkTypeReject)
            {
                await RemoveDrillingProgramAsync(fileInfo.IdWell, token);
                await NotifyPublisherOnRejectAsync(fileMarkDto, token);
            }
            else
            {
                // если все согласованты согласовали - оповещаем публикатора
                var approvers = part!.RelatedUsers
                    .Where(u => u.IdUserRole == idUserRoleApprover);
                if (approvers
                    .All(user => fileInfo.FileMarks
                        ?.Any(mark => (mark.IdMarkType == idMarkTypeApprove && mark.User?.Id == user.IdUser && !mark.IsDeleted)) == true ||
                        (fileMarkDto.IdMarkType == idMarkTypeApprove && user.IdUser == idUser)))
                {
                    await NotifyPublisherOnFullAccepAsync(fileMarkDto, token);
                }
            }
            return result;
        }

        public async Task<int> MarkAsDeletedFileMarkAsync(int idMark,
            CancellationToken token)
        {
            var fileInfo = await fileService.GetByMarkId(idMark, token)
                .ConfigureAwait(false);

            if (fileInfo.IdCategory < idFileCategoryDrillingProgramPartsStart ||
                fileInfo.IdCategory > idFileCategoryDrillingProgramPartsEnd)
                throw new ArgumentInvalidException(nameof(idMark), $"Этот метод допустим только для файлов-частей программы бурения.");

            var result = await fileService.MarkFileMarkAsDeletedAsync(idMark, token)
                .ConfigureAwait(false);

            await RemoveDrillingProgramAsync(fileInfo.IdWell, token);
            return result;
        }

        private async Task NotifyPublisherOnFullAccepAsync(FileMarkDto fileMark, CancellationToken token)
        {
            var file = await fileService.GetOrDefaultAsync(fileMark.IdFile, token);
            var well = await wellService.GetOrDefaultAsync(file!.IdWell, token)
                ?? throw new ArgumentInvalidException(nameof(file.IdWell), "idWell doesn`t exist");

            var user = file.Author!;
            var factory = new DrillingMailBodyFactory(configuration);
            var subject = factory.MakeSubject(well, "Загруженный вами документ полностью согласован");
            var body = factory.MakeMailBodyForPublisherOnFullAccept(well, user.Name ?? string.Empty, file.Id, file.Name);

            await notificationService.NotifyAsync(new NotifyRequest
            {
                IdUser = user.Id,
                IdNotificationCategory = NotificationCategory.IdSystemNotificationCategory,
                Title = subject,
                Message = body,
                IdTransportType = idTransportType
            }, token);
        }

        private async Task NotifyPublisherOnRejectAsync(FileMarkDto fileMark, CancellationToken token)
        {
            var file = await fileService.GetOrDefaultAsync(fileMark.IdFile, token);
            var well = await wellService.GetOrDefaultAsync(file!.IdWell, token)
                ?? throw new ArgumentInvalidException(nameof(file.IdWell), "idWell doesn`t exist");

            var user = file.Author!;
            var factory = new DrillingMailBodyFactory(configuration);
            var subject = factory.MakeSubject(well, "Загруженный вами документ отклонен");
            var body = factory.MakeMailBodyForPublisherOnReject(well, user.Name ?? string.Empty, file.Id, file.Name, fileMark);

            await notificationService.NotifyAsync(new NotifyRequest
            {
                IdUser = user.Id,
                IdNotificationCategory = NotificationCategory.IdSystemNotificationCategory,
                Title = subject,
                Message = body,
                IdTransportType = idTransportType
            }, token);
        }

        private async Task NotifyApproversAsync(DrillingProgramPart part, int idFile, string fileName, CancellationToken token)
        {
            var well = await wellService.GetOrDefaultAsync(part.IdWell, token)
                ?? throw new ArgumentInvalidException(nameof(part.IdWell), "idWell doesn`t exist");

            var factory = new DrillingMailBodyFactory(configuration);
            var subject = factory.MakeSubject(well, "Загружен новый документ для согласования.");
            var users = part.RelatedUsers
                .Where(r => r.IdUserRole == idUserRoleApprover)
                .Select(r => r.User);

            foreach (var user in users)
            {
                var body = factory.MakeMailBodyForApproverNewFile(well, user.Name ?? string.Empty, idFile, fileName);
                await notificationService.NotifyAsync(new NotifyRequest
                {
                    IdUser = user.Id,
                    IdNotificationCategory = NotificationCategory.IdSystemNotificationCategory,
                    Title = subject,
                    Message = body,
                    IdTransportType = idTransportType
                }, token);
            }
        }

        private async Task NotifyNewPublisherAsync(int idWell, UserDto user, string documentCategory, CancellationToken token)
        {
            var well = await wellService.GetOrDefaultAsync(idWell, token)
                ?? throw new ArgumentInvalidException(nameof(idWell), "idWell doesn`t exist");

            var factory = new DrillingMailBodyFactory(configuration);
            var subject = factory.MakeSubject(well, $"От вас ожидается загрузка на портал документа «{documentCategory}»");
            var body = factory.MakeMailBodyForNewPublisher(well, user.Name ?? string.Empty, documentCategory);
            
            await notificationService.NotifyAsync(new NotifyRequest
            {
                IdUser = user.Id,
                IdNotificationCategory = NotificationCategory.IdSystemNotificationCategory,
                Title = subject,
                Message = body,
                IdTransportType = idTransportType
            }, token);
        }

        private static DrillingProgramPartDto ConvertPart(int idUser, List<FileCategory> fileCategories, List<AsbCloudDb.Model.FileInfo> files, DrillingProgramPart partEntity, double timezoneOffset)
        {
            var part = new DrillingProgramPartDto
            {
                IdFileCategory = partEntity.IdFileCategory,
                Name = fileCategories.FirstOrDefault(c => c.Id == partEntity.IdFileCategory)!.Name,
                Approvers = partEntity.RelatedUsers
                                .Where(r => r.IdUserRole == idUserRoleApprover)
                                .Select(r => r.User.Adapt<UserDto>()),
                Publishers = partEntity.RelatedUsers
                                .Where(r => r.IdUserRole == idUserRolePublisher)
                                .Select(r => r.User.Adapt<UserDto>()),
                PermissionToApprove = partEntity.RelatedUsers
                                .Any(r => r.IdUserRole == idUserRoleApprover && r.IdUser == idUser),
                PermissionToUpload = partEntity.RelatedUsers
                                .Any(r => r.IdUserRole == idUserRolePublisher && r.IdUser == idUser),
            };

            var fileEntity = files.LastOrDefault(f => f.IdCategory == partEntity.IdFileCategory);

            if (fileEntity is not null)
            {
                part.IdState = idPartStateApproving;
                part.File = new FileInfoDto
                {
                    Id = fileEntity.Id,
                    Author = fileEntity.Author?.Adapt<UserDto>(),
                    IdAuthor = fileEntity.Author?.Id,
                    IdCategory = fileEntity.IdCategory,
                    IdWell = fileEntity.IdWell,
                    Name = fileEntity.Name,
                    Size = fileEntity.Size,
                    UploadDate = fileEntity.UploadDate.ToOffset(TimeSpan.FromHours(timezoneOffset)),
                };

                var marks = fileEntity.FileMarks?.Where(m => !m.IsDeleted);
                if (marks?.Any() == true)
                {
                    part.File.FileMarks = marks.Select(m =>
                    {
                        var mark = m.Adapt<FileMarkDto>();
                        mark.DateCreated = m.DateCreated.ToOffset(TimeSpan.FromHours(timezoneOffset));
                        return mark;
                    });
                    var hasReject = marks.Any(m => m.IdMarkType == idMarkTypeReject);
                    if (!hasReject)
                    {
                        var allAproved = part.Approvers.All(a => marks.Any(m => m.IdUser == a.Id && m.IdMarkType == idMarkTypeApprove));
                        if (allAproved)
                            part.IdState = idPartStateApproved;
                    }
                }
            }
            else
                part.IdState = idPartStateNoFile;

            return part;
        }

        private async Task EnqueueMakeProgramWorkAsync(int idWell, DrillingProgramStateDto state, CancellationToken token)
        {
            if (state.IdState == idStateCreating)
            {
                var workId = MakeWorkId(idWell);
                if (!backgroundWorker.Works.Any(w => w.Id == workId))
                {
                    var well = (await wellService.GetOrDefaultAsync(idWell, token))!;
                    var resultFileName = $"Программа бурения {well.Cluster} {well.Caption}.pdf";                     
                    var convertedFilesDir = Path.Combine(Path.GetTempPath(), "drillingProgram", $"{well.Cluster}_{well.Caption}");                                        
                    var tempResultFilePath = Path.Combine(convertedFilesDir, resultFileName);
                    var workAction = async (string workId, IServiceProvider serviceProvider, Action<string, double?> onProgress, CancellationToken token) =>
                    {
                        var context = serviceProvider.GetRequiredService<IAsbCloudDbContext>();
                        var fileService = serviceProvider.GetRequiredService<FileService>();
                        var files = state.Parts.Select(p => fileService.GetUrl(p.File!));
                        onProgress($"Start converting {files.Count()} files to PDF.", null);
                        await ConvertToPdf.GetConverteAndMergedFileAsync(files, tempResultFilePath, convertedFilesDir, token);
                        await fileService.MoveAsync(idWell, null, idFileCategoryDrillingProgram, resultFileName, tempResultFilePath, token);
                    };

                    var onErrorAction = (string workId, Exception exception, CancellationToken token) =>
                    {
                        var message = $"Не удалось сформировать программу бурения по скважине {well?.Caption}";
                        drillingProgramCreateErrors[workId] = new()
                        {
                            Message = message,
                            Exception = exception.Message,
                        };
                        return Task.CompletedTask;
                    };

                    var work = Work.CreateByDelegate(workId, workAction);
                    work.OnErrorAsync = onErrorAction;
                    backgroundWorker.Enqueue(work);
                }
            }
        }

        public void ClearError(int idWell)
        {
            var workId = MakeWorkId(idWell);
            drillingProgramCreateErrors.Remove(workId);
        }

        private async Task<int> RemoveDrillingProgramAsync(int idWell, CancellationToken token)
        {
            var workId = MakeWorkId(idWell);
            backgroundWorker.TryRemoveFromQueue(workId);

            var filesIds = await context.Files
                .Where(f => f.IdWell == idWell &&
                    f.IdCategory == idFileCategoryDrillingProgram)
                .Select(f => f.Id)
                .ToListAsync(token);
            if (filesIds.Any())
                return await fileService.DeleteAsync(filesIds, token);
            else
                return 0;
        }

        private static string MakeWorkId(int idWell)
            => $"Make drilling program for wellId {idWell}";
    }

}