using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using System.Security.Cryptography;

namespace JobTrackerApi.Controllers
{
    [ApiController]
    [Route("api/attachments")]
    [Authorize(AuthenticationSchemes = "local")]
    public class AttachmentsController : ControllerBase
    {
        private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB per file keeps local storage use predictable.

        private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
        {
            ".pdf", ".doc", ".docx", ".txt", ".rtf", ".png", ".jpg", ".jpeg", ".webp"
        };

        private readonly JobTrackerContext _db;
        private readonly UserManager<ApplicationUser>? _users;
        private readonly IAttachmentStorage _storage;
        private readonly ILogger<AttachmentsController> _logger;

        public AttachmentsController(AppPaths paths, JobTrackerContext db, UserManager<ApplicationUser>? users = null, IAttachmentStorage? storage = null, ILogger<AttachmentsController>? logger = null)
        {
            _db = db;
            _users = users;
            _storage = storage ?? new AttachmentStorage(paths);
            _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<AttachmentsController>.Instance;
        }

        public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize, string? Purpose, bool UseForAi);

        // Child entities are accessed by raw integer ids in a few endpoints below.
        // Always resolve them through the parent JobApplication query so the global job-level
        // ownership filter is still enforced for multi-user environments.
        private Task<Attachment?> FindOwnedAttachmentAsync(int attachmentId, CancellationToken cancellationToken)
        {
            return _db.Attachments
                .Include(a => a.JobApplication)
                .FirstOrDefaultAsync(a => a.Id == attachmentId, cancellationToken);
        }

        private static string BuildStoredFileName(string originalName)
        {
            var ext = Path.GetExtension(originalName);
            var suffix = Convert.ToHexString(RandomNumberGenerator.GetBytes(6)).ToLowerInvariant();
            return $"{DateTime.UtcNow:yyyyMMddHHmmssfff}-{suffix}{ext}";
        }

        private static string GuessPurpose(string fileName)
        {
            var n = (fileName ?? string.Empty).ToLowerInvariant();
            if (n.Contains("cover")) return "cover-letter";
            if (n.Contains("resume") || n.Contains("résumé") || n.Contains(" cv") || n.EndsWith("cv.pdf")) return "resume";
            if (n.Contains("portfolio")) return "portfolio";
            if (n.Contains("case") || n.Contains("sample")) return "case-study";
            if (n.Contains("cert")) return "certificate";
            return "other";
        }

        // JobApplication.HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived
        // from actual Attachment rows, not manually settable -- this is the single place they're
        // written, called after every attachment mutation (upload/delete/purpose change) so they
        // can never drift from what's actually attached.
        private async Task RecomputeAttachmentFlagsAsync(int jobId, CancellationToken cancellationToken)
        {
            var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == jobId, cancellationToken);
            if (job is null) return;

            var purposes = await _db.Attachments
                .Where(a => a.JobApplicationId == jobId)
                .Select(a => a.Purpose)
                .ToListAsync(cancellationToken);

            job.HasResume = purposes.Any(p => p == "resume");
            job.HasCoverLetter = purposes.Any(p => p == "cover-letter");
            job.HasPortfolio = purposes.Any(p => p == "portfolio");
            job.HasOtherAttachment = purposes.Any(p => p is not ("resume" or "cover-letter" or "portfolio"));
        }

        [HttpGet("{jobId:int}")]
        public async Task<ActionResult<List<AttachmentDto>>> ListForJob([FromRoute] int jobId, CancellationToken cancellationToken)
        {
            var jobOk = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken);
            if (!jobOk) return NotFound();

            var items = await _db.Attachments
                .AsNoTracking()
                .Where(a => a.JobApplicationId == jobId)
                .OrderByDescending(a => a.UploadDate)
                .Select(a => new AttachmentDto(a.Id, a.FileName, a.UploadDate, a.FileType, a.FileSize, a.Purpose, a.UseForAi))
                .ToListAsync(cancellationToken);

            return Ok(items);
        }

        [HttpGet("download/{id:int}")]
        public async Task<IActionResult> Download([FromRoute] int id, CancellationToken cancellationToken)
        {
            var att = await FindOwnedAttachmentAsync(id, cancellationToken);
            if (att is null) return NotFound();

            if (string.IsNullOrWhiteSpace(att.FilePath) || !_storage.IsManagedPath(att.FilePath))
                return Conflict("The attachment storage path is invalid.");
            if (!System.IO.File.Exists(att.FilePath))
            {
                if (System.IO.File.Exists(_storage.StagePath(att.FilePath)))
                    return Conflict("The attachment is still being finalized. Try again after the service restarts.");
                return NotFound();
            }

            var contentType = string.IsNullOrWhiteSpace(att.FileType) ? "application/octet-stream" : att.FileType;
            var fileName = Path.GetFileName(att.FileName);
            return PhysicalFile(att.FilePath, contentType, fileName);
        }

        public sealed record UpdateAttachmentRequest(string? FileName, string? Purpose, bool? UseForAi);

        [HttpPatch("{id:int}")]
        public async Task<IActionResult> Rename([FromRoute] int id, [FromBody] UpdateAttachmentRequest request, CancellationToken cancellationToken)
        {
            var att = await FindOwnedAttachmentAsync(id, cancellationToken);
            if (att is null) return NotFound();

            if (request.UseForAi is not null)
            {
                att.UseForAi = request.UseForAi.Value;
            }

            var purposeChanged = !string.IsNullOrWhiteSpace(request.Purpose);
            if (purposeChanged)
            {
                att.Purpose = request.Purpose!.Trim().ToLowerInvariant();
            }

            var rawName = (request.FileName ?? string.Empty).Trim();
            if (rawName.Length > 0)
            {
                var name = Path.GetFileName(rawName);
                var ext = Path.GetExtension(name);
                if (!AllowedExtensions.Contains(ext))
                    return BadRequest("That file type is not allowed.");

                // The generated storage name is intentionally stable. A user-visible rename is metadata,
                // so no filesystem/DB split can leave the row pointing at a moved file.
                att.FileName = name;
            }

            await using var transaction = _db.Database.IsRelational()
                ? await _db.Database.BeginTransactionAsync(cancellationToken)
                : null;
            await _db.SaveChangesAsync(cancellationToken);
            if (purposeChanged)
            {
                // This query must see the new persisted purpose; the transaction keeps both saves atomic.
                await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
                await _db.SaveChangesAsync(cancellationToken);
            }
            if (transaction is not null) await transaction.CommitAsync(cancellationToken);

            return NoContent();
        }

        [HttpDelete("{id:int}")]
        public async Task<IActionResult> Delete([FromRoute] int id, CancellationToken cancellationToken)
        {
            var att = await FindOwnedAttachmentAsync(id, cancellationToken);
            if (att is null) return NotFound();

            var path = att.FilePath;
            var jobId = att.JobApplicationId;
            if (!string.IsNullOrWhiteSpace(path) && !_storage.IsManagedPath(path))
                return Conflict("The attachment storage path is invalid.");

            var deletePath = string.IsNullOrWhiteSpace(path) ? null : _storage.DeletePath(path);
            var quarantined = false;
            if (!string.IsNullOrWhiteSpace(path) && System.IO.File.Exists(path))
            {
                _storage.Quarantine(path, deletePath!);
                quarantined = true;
            }
            else if (deletePath is not null && System.IO.File.Exists(deletePath))
            {
                return Accepted(new { recoveryPending = true });
            }
            else if (!string.IsNullOrWhiteSpace(path))
            {
                _logger.LogWarning("Attachment {AttachmentId} metadata referenced missing bytes; deleting the stale row.", id);
            }

            Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? transaction = null;
            var rolledBack = false;
            try
            {
                if (_db.Database.IsRelational())
                    transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
                _db.Attachments.Remove(att);
                await _db.SaveChangesAsync(cancellationToken);
                await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
                await _db.SaveChangesAsync(cancellationToken);
                if (transaction is not null) await transaction.CommitAsync(cancellationToken);
            }
            catch
            {
                if (transaction is not null)
                {
                    try
                    {
                        await transaction.RollbackAsync(CancellationToken.None);
                        rolledBack = true;
                    }
                    catch (Exception rollbackError)
                    {
                        _logger.LogWarning(rollbackError, "Attachment {AttachmentId} transaction outcome is uncertain; quarantined bytes await startup reconciliation.", id);
                    }
                }
                if (rolledBack && quarantined && deletePath is not null && System.IO.File.Exists(deletePath) && !System.IO.File.Exists(path))
                {
                    try { _storage.Restore(deletePath, path); }
                    catch (Exception restoreError)
                    {
                        _logger.LogWarning(restoreError, "Attachment {AttachmentId} bytes could not be restored after database rollback; startup reconciliation will retry.", id);
                    }
                }
                throw;
            }
            finally
            {
                if (transaction is not null) await transaction.DisposeAsync();
            }

            if (deletePath is not null)
            {
                try
                {
                    _storage.Purge(deletePath);
                }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "Attachment {AttachmentId} metadata was deleted; quarantined bytes await startup reconciliation.", id);
                    return Accepted(new { recoveryPending = true });
                }
            }

            return NoContent();
        }

        [HttpPost]
        public async Task<IActionResult> Upload([FromForm] IFormFileCollection files, [FromForm] int jobId, CancellationToken cancellationToken)
        {
            if (jobId <= 0) return BadRequest("Valid jobId is required.");
            if (files is null || files.Count == 0) return BadRequest("At least one file is required.");

            var jobExists = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken);
            if (!jobExists) return BadRequest("jobId does not exist.");


            if (_users is not null)
            {
                var user = await _users.GetUserAsync(User);
                if (user is null) return Unauthorized();
                var roles = await _users.GetRolesAsync(user);
                var limit = AccountPlans.ForRoles(roles).StorageBytes;
                var used = await _db.Attachments.Where(a => a.JobApplication.OwnerUserId == user.Id).SumAsync(a => (long?)a.FileSize, cancellationToken) ?? 0;
                var incoming = files.Sum(file => file.Length);
                if (incoming > limit - used)
                    return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Storage limit reached ({limit / 1_000_000} MB). Remove files or upgrade your plan.");
            }

            var validFiles = new List<(IFormFile File, string DisplayName, string ContentType, string Purpose, string FinalPath, string StagePath)>();
            foreach (var file in files)
            {
                if (file.Length == 0) continue;
                if (file.Length > MaxFileSizeBytes)
                    return BadRequest($"{file.FileName} exceeds the 10 MB upload limit.");

                var displayName = Path.GetFileName(file.FileName);
                var ext = Path.GetExtension(displayName);
                if (!AllowedExtensions.Contains(ext))
                    return BadRequest($"{displayName} is not an allowed file type.");

                var storedName = BuildStoredFileName(displayName);
                var finalPath = _storage.CreateFinalPath(jobId, storedName);
                validFiles.Add((
                    file,
                    displayName,
                    string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
                    GuessPurpose(displayName),
                    finalPath,
                    _storage.StagePath(finalPath)));
            }

            if (validFiles.Count == 0) return BadRequest("At least one non-empty file is required.");

            var stagedPaths = new List<string>();
            try
            {
                foreach (var item in validFiles)
                {
                    await _storage.StageAsync(item.File, item.StagePath, cancellationToken);
                    stagedPaths.Add(item.StagePath);
                }
            }
            catch
            {
                foreach (var stagedPath in stagedPaths)
                {
                    try { _storage.Purge(stagedPath); } catch { }
                }
                throw;
            }

            Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? transaction = null;
            var rolledBack = false;
            try
            {
                if (_db.Database.IsRelational())
                    transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
                foreach (var item in validFiles)
                {
                    _db.Attachments.Add(new Attachment
                    {
                        JobApplicationId = jobId,
                        FileName = item.DisplayName,
                        FilePath = item.FinalPath,
                        UploadDate = DateTime.Now,
                        FileType = item.ContentType,
                        FileSize = item.File.Length,
                        Purpose = item.Purpose,
                        UseForAi = true,
                    });
                }
                await _db.SaveChangesAsync(cancellationToken);
                await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
                await _db.SaveChangesAsync(cancellationToken);
                if (transaction is not null) await transaction.CommitAsync(cancellationToken);
            }
            catch
            {
                if (transaction is not null)
                {
                    try
                    {
                        await transaction.RollbackAsync(CancellationToken.None);
                        rolledBack = true;
                    }
                    catch (Exception rollbackError)
                    {
                        _logger.LogWarning(rollbackError, "Attachment upload transaction outcome is uncertain; staged bytes await startup reconciliation.");
                    }
                }
                if (rolledBack)
                {
                    foreach (var item in validFiles)
                    {
                        try { _storage.Purge(item.StagePath); }
                        catch (Exception purgeError)
                        {
                            _logger.LogWarning(purgeError, "Attachment upload rolled back but staged bytes could not be removed; startup reconciliation will retry.");
                        }
                    }
                }
                throw;
            }
            finally
            {
                if (transaction is not null) await transaction.DisposeAsync();
            }

            var pending = false;
            foreach (var item in validFiles)
            {
                try
                {
                    _storage.Promote(item.StagePath, item.FinalPath);
                }
                catch (Exception ex)
                {
                    pending = true;
                    _logger.LogWarning(ex, "Attachment upload committed for job {JobId}; staged bytes await startup reconciliation.", jobId);
                }
            }

            return pending ? Accepted(new { recoveryPending = true }) : Ok();
        }
    }
}
