using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Pack;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
using SVSim.EmulatedEntrypoint.Services;

namespace SVSim.EmulatedEntrypoint.Controllers;

/// <summary>
/// /pack/* — card-pack shop catalog and pack opening. /tutorial/pack_info and
/// /tutorial/pack_open are aliased here.
/// </summary>
[Route("pack")]
public class PackController : SVSimController
{
    private const string WireDateFormat = "yyyy-MM-dd HH:mm:ss";

    private readonly IPackRepository _packs;
    private readonly PackOpenService _opener;
    private readonly ICardPoolProvider _pools;
    private readonly IRandom _rng;
    private readonly SVSimDbContext _db;
    private readonly ICardAcquisitionService _acquisition;
    private readonly IGachaPointService _gachaPoint;

    public PackController(
        IPackRepository packs,
        PackOpenService opener,
        ICardPoolProvider pools,
        IRandom rng,
        SVSimDbContext db,
        ICardAcquisitionService acquisition,
        IGachaPointService gachaPoint)
    {
        _packs = packs;
        _opener = opener;
        _pools = pools;
        _rng = rng;
        _db = db;
        _acquisition = acquisition;
        _gachaPoint = gachaPoint;
    }

    [HttpPost("info")]
    [HttpPost("/tutorial/pack_info")]
    public async Task<ActionResult<PackInfoResponse>> Info(BaseRequest request)
    {
        if (!TryGetViewerId(out long viewerId)) return Unauthorized();

        var packs = await _packs.GetActivePacks(DateTime.UtcNow);
        var openCounts = await _packs.GetOpenCountsForViewer(viewerId);

        // Load owned-item counts so child_gacha_info.item_number reflects the viewer's actual
        // ticket inventory (see ToDto). The client filters tutorial packs by item_number > 0
        // — without this the legendary starter pack (99047, requires 1× item 90001) and the
        // throwback pack (80047, requires 1× item 80001) are hidden even when the tutorial
        // gift just granted those tickets, blocking the END transition.
        //
        // OwnedItemEntry is [Owned] by Viewer, and EF refuses to track owned entities without
        // their owner in the result. Project to primitive pairs in the database query before
        // materialising into the dictionary — no entity tracking, single round-trip.
        //
        // Use EF.Property<int>(i, "ItemId") to read the shadow FK directly instead of going
        // through the OwnedItemEntry.Item nav. The nav route works today (EF translates
        // `i.Item.Id` to the FK column), but a future model change that renames the FK or
        // breaks the nav→column mapping would silently fall back to client eval — where
        // `i.Item.Id` returns 0 for every row (the default-initialised ItemEntry) and the
        // dictionary collapses every ticket to item_number=0. Shadow-FK access bypasses
        // that hazard entirely.
        var ownedItemsByItemId = await _db.Viewers
            .Where(v => v.Id == viewerId)
            .SelectMany(v => v.Items)
            .Select(i => new { ItemId = (long)EF.Property<int>(i, "ItemId"), i.Count })
            .ToDictionaryAsync(x => x.ItemId, x => x.Count);

        return new PackInfoResponse
        {
            PackConfigList = packs.Select(p => ToDto(p, openCounts, ownedItemsByItemId)).ToList(),
        };
    }

    private static PackConfigDto ToDto(
        PackConfigEntry p,
        IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
        IReadOnlyDictionary<long, int> ownedItemsByItemId)
    {
        int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0;
        return new PackConfigDto
        {
            ParentGachaId = p.Id,
            BasePackId = p.BasePackId,
            OverrideDrawEffectPackId = p.OverrideDrawEffectPackId,
            OverrideUiEffectPackId = p.OverrideUiEffectPackId,
            GachaType = p.GachaType,
            SleeveId = p.SleeveId,
            SpecialSleeveId = p.SpecialSleeveId,
            CommenceDate = p.CommenceDate.ToString(WireDateFormat, CultureInfo.InvariantCulture),
            CompleteDate = p.CompleteDate.ToString(WireDateFormat, CultureInfo.InvariantCulture),
            CardpackBannerList = p.Banners.Select(b => new PackBannerDto
            {
                BannerName = b.BannerName,
                DialogTitle = b.DialogTitle,
            }).ToList(),
            GachaDetail = p.GachaDetail,
            ChildGachaInfo = p.ChildGachas.Select(c => new PackChildGachaDto
            {
                GachaId = c.GachaId,
                TypeDetail = c.TypeDetail,
                Cost = c.Cost,
                Count = c.CardCount,
                ItemId = c.ItemId?.ToString(CultureInfo.InvariantCulture),
                // item_number is viewer-specific — the count of item_id this viewer currently
                // owns, NOT a per-pack-catalog value. Verified against the prod tutorial
                // capture: legendary pack 99047 reports item_number=1 right after the gift
                // granted 1× ticket id=90001; throwback 80047 reports 40 right after the gift
                // granted 40× ticket id=80001. Client filters the tutorial pack list to
                // packs with non-zero item_number (free packs like 92001 are special-cased
                // separately), so this lookup is what makes the tutorial-final pack show up.
                ItemNumber = c.ItemId is long iid && ownedItemsByItemId.TryGetValue(iid, out var ownedCount)
                    ? ownedCount
                    : 0,
                IsDailySingle = c.IsDailySingle,
                OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
            }).ToList(),
            OpenCount = openCount,
            OpenCountLimit = p.OpenCountLimit,
            IsHide = p.IsHide ? 1 : 0,
            PackCategory = (int)p.PackCategory,
            GachaPoint = p.GachaPointConfig is null ? null : new PackGachaPointDto
            {
                PackId = p.BasePackId.ToString(CultureInfo.InvariantCulture),
                GachaPoint = 0,
                IncreaseGachaPoint = p.GachaPointConfig.IncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
                ExchangeableGachaPoint = p.GachaPointConfig.ExchangeablePoint,
                IsExchangeableGachaPoint = false,
            },
            IsPreRelease = p.IsPreRelease,
            ExistsPurchaseReward = false,
            IsNew = p.IsNew,
            PosterType = p.PosterType,
            SalesPeriodInfo = new(),  // emit `{}` per the DTO docstring
        };
    }

    [HttpPost("get_gacha_point_rewards")]
    public async Task<ActionResult<GetGachaPointRewardsResponse>> GetGachaPointRewards(
        GetGachaPointRewardsRequest request)
    {
        if (!TryGetViewerId(out long viewerId)) return Unauthorized();

        var rewards = await _gachaPoint.GetRewardsAsync(request.ParentGachaId, viewerId);

        return new GetGachaPointRewardsResponse
        {
            GachaPointRewards = rewards.ToList(),
        };
    }

    [HttpPost("exchange_gacha_point")]
    public async Task<ActionResult<ExchangeGachaPointResponse>> ExchangeGachaPoint(
        ExchangeGachaPointRequest request)
    {
        if (!TryGetViewerId(out long viewerId)) return Unauthorized();

        // Load the viewer with the collections the service mutates (balances, received marker,
        // cards, cosmetics). AsSplitQuery per project_ef_split_query memory.
        var viewer = await _db.Viewers
            .Include(v => v.GachaPointBalances)
            .Include(v => v.GachaPointReceived)
            .Include(v => v.Cards)
            .Include(v => v.Sleeves)
            .Include(v => v.Emblems)
            .Include(v => v.Degrees)
            .Include(v => v.LeaderSkins)
            .Include(v => v.MyPageBackgrounds)
            .AsSplitQuery()
            .FirstAsync(v => v.Id == viewerId);

        var outcome = await _gachaPoint.TryExchangeAsync(viewer, request.ParentGachaId, request.CardId);
        if (!outcome.Success) return BadRequest(new { error = outcome.Error });

        await _db.SaveChangesAsync();

        return new ExchangeGachaPointResponse
        {
            RewardList = outcome.RewardList.ToList(),
        };
    }

    [HttpPost("open")]
    [HttpPost("/tutorial/pack_open")]
    public async Task<ActionResult<PackOpenResponse>> Open(PackOpenRequest request)
    {
        if (!TryGetViewerId(out long viewerId)) return Unauthorized();

        bool isTutorialPath = HttpContext.Request.Path.StartsWithSegments("/tutorial/pack_open");

        // The tutorial alias bypasses the currency / type_detail / open-count guards because
        // the legendary starter pack (99047) is a free server-grant during the 41→100 tutorial
        // transition. Constrain the alias to that one pack so the bypass isn't a free draw on
        // ANY pack the client supplies a parent_gacha_id for.
        const int StarterParentGachaId = 99047;
        if (isTutorialPath && request.ParentGachaId != StarterParentGachaId)
            return BadRequest(new { error = "tutorial_path_only_for_starter_pack" });

        // Reject paths up front — class_id/target_card_id overloads aren't implemented.
        if (request.ClassId.HasValue)
            return StatusCode(StatusCodes.Status501NotImplemented, new { error = "starter_overload_not_implemented" });
        if (request.TargetCardId.HasValue)
            return StatusCode(StatusCodes.Status501NotImplemented, new { error = "skin_overload_not_implemented" });

        var pack = await _packs.GetPack(request.ParentGachaId);
        if (pack is null) return NotFound(new { error = "unknown_pack" });

        // Skin / starter / leader-skin packs aren't drawn in v1 regardless of child type.
        if (pack.PackCategory is PackCategory.LeaderSkinPack
                              or PackCategory.FreePackLeaderSkin
                              or PackCategory.RotationStarterCardPack
                              or PackCategory.LegendAndLeaderSkinSinglePack)
        {
            return StatusCode(StatusCodes.Status501NotImplemented, new { error = "skin_starter_category_not_implemented" });
        }

        var child = pack.ChildGachas.FirstOrDefault(c => c.GachaId == request.GachaId);
        if (child is null)
            return BadRequest(new { error = "unknown_child_gacha" });

        // Note: request.GachaType is the PARENT pack's gacha_type (a routing/disambiguation field),
        // NOT the child's type_detail. Prod traffic confirms the client sends gacha_type=1 even
        // when buying a RUPY_MULTI (type_detail=7) child. The gacha_id alone disambiguates the
        // child; gacha_type validation against child.TypeDetail would falsely reject every buy.

        // Supported currency types in v1: CRYSTAL_MULTI=2, DAILY=3, RUPY_MULTI=7. Ticket flows
        // (TICKET=4, TICKET_MULTI=5) and the rest are explicitly out of scope for the normal path.
        // The tutorial path (type_detail=5, TICKET_MULTI) bypasses this guard — the starter pack
        // is a free server-granted bonus, not a purchasable pack.
        if (!isTutorialPath && child.TypeDetail is not (2 or 3 or 7))
            return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });

        var viewer = await _db.Viewers
            .Include(v => v.PackOpenCounts)
            .Include(v => v.GachaPointBalances)
            .Include(v => v.MissionData)
            .Include(v => v.Items).ThenInclude(i => i.Item)
            .AsSplitQuery()
            .FirstAsync(v => v.Id == viewerId);

        // Tutorial alias is only valid pre-END. After state>=100 the viewer has already
        // completed the tutorial — re-running the path would re-consume the ticket they
        // chose to keep, and (without the max-preserve write below) could regress a higher
        // state value. Mirrors the 31<41 guard in GiftController.TutorialGiftReceive.
        const int TutorialEndStep = 100;
        if (isTutorialPath && viewer.MissionData.TutorialState >= TutorialEndStep)
            return BadRequest(new { error = "tutorial_already_complete" });

        int packNumber = Math.Max(1, request.PackNumber);

        // Currency check + deduction (skipped for tutorial path — starter pack is free)
        if (!isTutorialPath)
        {
            switch (child.TypeDetail)
            {
                case 2:   // CRYSTAL_MULTI
                {
                    ulong cost = (ulong)child.Cost * (ulong)packNumber;
                    if (viewer.Currency.Crystals < cost)
                        return BadRequest(new { error = "insufficient_crystals" });
                    viewer.Currency.Crystals -= cost;
                    break;
                }
                case 7:   // RUPY_MULTI
                {
                    ulong cost = (ulong)child.Cost * (ulong)packNumber;
                    if (viewer.Currency.Rupees < cost)
                        return BadRequest(new { error = "insufficient_rupees" });
                    viewer.Currency.Rupees -= cost;
                    break;
                }
                case 3:   // DAILY single — once per UTC day
                {
                    // TODO(daily-reset): no project-wide daily-reset convention exists yet. Using UTC
                    // midnight; revisit when the global reset boundary is settled.
                    var now = DateTime.UtcNow;
                    var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id);
                    if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date)
                        return BadRequest(new { error = "daily_free_already_claimed" });

                    ulong cost = (ulong)child.Cost * (ulong)packNumber;
                    if (cost > 0 && viewer.Currency.Rupees < cost)
                        return BadRequest(new { error = "insufficient_rupees" });
                    if (cost > 0) viewer.Currency.Rupees -= cost;
                    break;
                }
            }
            await _db.SaveChangesAsync();
        }

        // Increment open count + mark daily-free timestamp where relevant.
        // Tutorial path skips these — the starter pack is a one-time free grant, not a
        // purchasable/trackable open.
        if (!isTutorialPath)
        {
            await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
            if (child.TypeDetail == 3)
            {
                await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
            }
        }

        // Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
        int drawCount = child.IsDailySingle ? 1 : packNumber;
        var draw = _opener.Draw(pack, _pools, drawCount, request.ExcludeCardIds, _rng);
        var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId));

        // Accrue gacha points (skip tutorial path — the starter pack isn't a real open).
        if (!isTutorialPath)
        {
            _gachaPoint.Accrue(viewer, pack, child, drawCount);
            await _db.SaveChangesAsync();
        }

        // Build reward_list. The service produces the type=5 (Card) entries with post-state counts
        // plus any cosmetic grants. Currency entry (type=2 Crystals or type=9 Rupy) stays in the
        // controller — it's a pack-purchase concern, not a card-grant concern. The client's
        // PlayerStaticData.UpdateHaveUserGoodsNum does direct assignment, so currency/card counts
        // must be the new TOTAL — emitting deltas would leave the on-screen balances stale.
        var rewardList = new List<RewardListEntry>();

        // Currency reward entries only apply to purchasable packs; tutorial path omits them.
        if (!isTutorialPath)
        {
            var postViewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId);
            if (child.TypeDetail == 2)
            {
                rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)postViewer.Currency.Crystals });
            }
            else if (child.TypeDetail == 7 || child.TypeDetail == 3)
            {
                rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees });
            }
        }
        rewardList.AddRange(grant.RewardList);

        // Tutorial path consumes the granted ticket (same item_id used to gate display) so the
        // pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still
        // shows item_number=1 after the tutorial pack-open, the client lets the user re-click
        // it, and the second click hits /pack/open (not /tutorial/pack_open) — which 501s on
        // type_detail=5 (TICKET_MULTI is out of scope for the normal path). Emitting the
        // post-state count in reward_list direct-assigns the client's _userItemDict so the
        // UI also goes stale-safe immediately (client does direct assignment per
        // project_wire_reward_list_post_state memory).
        int? responseTutorialStep = null;
        if (isTutorialPath)
        {
            if (child.ItemId is long ticketItemId)
            {
                var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
                if (owned is not null)
                {
                    owned.Count = Math.Max(0, owned.Count - packNumber);
                    rewardList.Add(new RewardListEntry
                    {
                        RewardType = 4,                     // Item
                        RewardId = ticketItemId,
                        RewardNum = owned.Count,            // POST-STATE total
                    });
                }
            }

            // Max-preserve: never regress the persisted state, even though Gate B already
            // rejected state>=100 above. Belt-and-braces against a future caller that
            // bypasses Gate B (refactor, new alias, etc.). Wire still emits 100 — that's
            // the tutorial-END signal the client expects.
            if (viewer.MissionData.TutorialState < TutorialEndStep)
                viewer.MissionData.TutorialState = TutorialEndStep;
            await _db.SaveChangesAsync();
            responseTutorialStep = TutorialEndStep;
        }

        return new PackOpenResponse
        {
            PackList = draw.Cards.Select(c => new CardPackEntryDto
            {
                CardId = c.CardId,
                Rarity = (int)c.Rarity,
                Number = 1,
            }).ToList(),
            RewardList = rewardList,
            TutorialStep = responseTutorialStep,
        };
    }
}
