using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace BlueLaminate.Core.CsMoney;
/// Outcome of ingesting one skin+wear scrape job's results.
public sealed record CsMoneyIngestResult(
int Matched, int Inserted, int Updated, int Removed, int Skipped);
///
/// Persists the listings the worker scraped for one targeted skin+wear job into the
/// cs_money_listings table. Mirrors the CSFloat ListingSweepService
/// patterns — upsert by natural key, resolve each listing to a market-agnostic
/// by fingerprint, soft-track Removed, flag dupes — but
/// scoped to the one skin+condition the job targeted (so it's the per-band unit, and
/// Removed-tracking is exact). cs.money's free-text search is fuzzy, so results are
/// filtered to the intended skin (by name) and wear (by quality) before persisting.
///
public sealed class CsMoneyIngestService
{
public const string Source = SweepSource.CsMoney;
private readonly SkinTrackerDbContext _db;
private readonly ILogger _logger;
public CsMoneyIngestService(SkinTrackerDbContext db, ILogger logger)
{
_db = db;
_logger = logger;
}
///
/// True only when the worker walked the whole skin+wear (stoppedReason "completed").
/// On a partial sweep we upsert what we saw but skip Removed-marking, the price
/// point, and the swept-checkpoint — unseen listings may just be unfetched, so the
/// band stays un-stamped and gets re-queued rather than being wrongly pruned.
///
public async Task IngestAsync(
int skinId, int? conditionId, IReadOnlyList items, bool complete, CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
var skin = await _db.Skins
.Where(s => s.Id == skinId)
.Select(s => new { s.Id, s.Name, Weapon = s.Weapon.Name })
.FirstOrDefaultAsync(ct);
if (skin is null)
{
_logger.LogWarning("Ingest skipped: skin {SkinId} not found.", skinId);
return new CsMoneyIngestResult(0, 0, 0, 0, items.Count);
}
string? conditionName = null;
if (conditionId is { } cid)
{
conditionName = await _db.SkinConditions
.Where(c => c.Id == cid).Select(c => c.Condition).FirstOrDefaultAsync(ct);
}
var expectedShort = Normalize($"{skin.Weapon} | {skin.Name}");
var expectedQuality = Wear.ToCode(conditionName);
// cs.money search is fuzzy — keep only items that are actually this skin (by
// name) and, when the job targets a wear band, this wear (by quality).
var matched = items.Where(it =>
{
var a = it.Asset;
if (a?.Names?.Short is null)
{
return false;
}
if (Normalize(a.Names.Short) != expectedShort)
{
return false;
}
return expectedQuality is null
|| string.Equals(a.Quality, expectedQuality, StringComparison.OrdinalIgnoreCase);
}).ToList();
var skipped = items.Count - matched.Count;
if (matched.Count == 0)
{
// Nothing for this skin+wear. If the sweep was complete this is genuine
// (none listed, or a name mismatch) — stamp the checkpoint so it advances.
// If it was partial (e.g. challenged before any item), leave it un-stamped
// so the band is retried.
if (complete)
{
await StampCheckpointAsync(conditionId, now, ct);
await _db.SaveChangesAsync(ct);
}
return new CsMoneyIngestResult(0, 0, 0, 0, skipped);
}
var sellOrderIds = matched.Select(it => it.Id).ToList();
var existing = await _db.CsMoneyListings
.Where(l => sellOrderIds.Contains(l.SellOrderId))
.ToDictionaryAsync(l => l.SellOrderId, ct);
var inserted = 0;
var updated = 0;
var touched = new HashSet();
var touchedInstanceIds = new HashSet();
foreach (var it in matched)
{
touched.Add(it.Id);
var instance = await ResolveInstanceAsync(skinId, conditionId, it, now, ct);
if (instance is not null)
{
touchedInstanceIds.Add(instance.Id);
}
if (existing.TryGetValue(it.Id, out var row))
{
row.Price = it.Pricing?.Default ?? row.Price;
row.PriceBeforeDiscount = it.Pricing?.PriceBeforeDiscount;
row.ComputedPrice = it.Pricing?.Computed;
row.AssetId = it.Asset?.Id?.ToString();
row.LastSeenAt = now;
row.Status = ListingStatus.Active;
row.RemovedAt = null;
row.ConditionId = conditionId;
row.SkinInstance = instance;
updated++;
}
else
{
var entity = Map(it, skinId, conditionId, now);
entity.SkinInstance = instance;
_db.CsMoneyListings.Add(entity);
inserted++;
}
}
// Persist inserts/updates before the set-based Removed/dupe queries run.
await _db.SaveChangesAsync(ct);
await FlagDupesAsync(touchedInstanceIds, now, ct);
// The following only hold if we saw the FULL skin+wear set. On a partial sweep,
// listings we didn't fetch are not gone (so don't mark them Removed), the
// cheapest item may be among the unfetched (so don't record a price point), and
// the band isn't fully swept (so don't stamp the checkpoint — let it re-queue).
var removed = 0;
if (complete)
{
removed = await MarkRemovedAsync(skinId, conditionId, touched, now, ct);
// Record a price point (the cheapest live listing) for this skin+wear.
if (conditionId is { } condId)
{
var minPrice = matched.Where(m => m.Pricing is not null).Select(m => m.Pricing!.Default).Min();
await _db.PriceHistories.AddAsync(new PriceHistory
{
SkinId = skinId,
ConditionId = condId,
Price = minPrice,
Currency = "USD",
RecordedAt = now,
Source = Source,
}, ct);
}
await StampCheckpointAsync(conditionId, now, ct);
}
await _db.SaveChangesAsync(ct);
_logger.LogInformation(
"cs.money ingest {Weapon} | {Skin} ({Wear}): {Matched} matched ({Ins} new, {Upd} upd, "
+ "{Rem} removed), {Skipped} skipped by filter{Partial}.",
skin.Weapon, skin.Name, conditionName ?? "all", matched.Count, inserted, updated, removed, skipped,
complete ? "" : " [PARTIAL — not pruned/checkpointed]");
return new CsMoneyIngestResult(matched.Count, inserted, updated, removed, skipped);
}
// Find the physical item matching this listing's fingerprint, or create one.
// Shared with CSFloat listings, so a copy seen on both markets is one instance.
// Skipped for non-skin items (no float/pattern) — the fingerprint is meaningless.
private async Task ResolveInstanceAsync(
int skinId, int? conditionId, CsMoneyItem it, DateTimeOffset now, CancellationToken ct)
{
if (it.Asset?.Float is not { } floatValue || it.Asset.Pattern is not { } pattern)
{
return null;
}
var seed = pattern;
var st = it.Asset.IsStatTrak;
var sv = it.Asset.IsSouvenir;
var tracked = _db.ChangeTracker.Entries()
.Select(e => e.Entity)
.FirstOrDefault(i => i.SkinId == skinId && i.FloatValue == floatValue
&& i.PaintSeed == seed && i.StatTrak == st && i.Souvenir == sv);
if (tracked is not null)
{
tracked.LastSeenAt = now;
return tracked;
}
var instance = await _db.SkinInstances.FirstOrDefaultAsync(
i => i.SkinId == skinId && i.FloatValue == floatValue
&& i.PaintSeed == seed && i.StatTrak == st && i.Souvenir == sv, ct);
if (instance is not null)
{
instance.LastSeenAt = now;
return instance;
}
instance = new SkinInstance
{
SkinId = skinId,
ConditionId = conditionId,
FloatValue = floatValue,
PaintSeed = seed,
StatTrak = st,
Souvenir = sv,
FirstSeenAt = now,
LastSeenAt = now,
};
_db.SkinInstances.Add(instance);
return instance;
}
// Flag this skin+wear's once-Active listings we didn't see this run as Removed.
private async Task MarkRemovedAsync(
int skinId, int? conditionId, HashSet touched, DateTimeOffset now, CancellationToken ct)
{
return await _db.CsMoneyListings
.Where(l => l.SkinId == skinId
&& l.ConditionId == conditionId
&& l.Status == ListingStatus.Active
&& !touched.Contains(l.SellOrderId))
.ExecuteUpdateAsync(setters => setters
.SetProperty(l => l.Status, ListingStatus.Removed)
.SetProperty(l => l.RemovedAt, now), ct);
}
// Same dupe signal as CSFloat: a fingerprint live under 2+ distinct asset ids at
// once. Considers cs.money listings only (cross-market dupe analysis is later).
private async Task FlagDupesAsync(HashSet instanceIds, DateTimeOffset now, CancellationToken ct)
{
if (instanceIds.Count == 0)
{
return;
}
var dupeInstanceIds = await _db.CsMoneyListings
.Where(l => l.SkinInstanceId != null
&& instanceIds.Contains(l.SkinInstanceId!.Value)
&& l.Status == ListingStatus.Active
&& l.AssetId != null)
.GroupBy(l => l.SkinInstanceId!.Value)
.Where(g => g.Select(l => l.AssetId).Distinct().Count() >= 2)
.Select(g => g.Key)
.ToListAsync(ct);
if (dupeInstanceIds.Count == 0)
{
return;
}
var newlyFlagged = await _db.SkinInstances
.Where(i => dupeInstanceIds.Contains(i.Id) && !i.SuspectedDupe)
.ExecuteUpdateAsync(setters => setters
.SetProperty(i => i.SuspectedDupe, true)
.SetProperty(i => i.DupeFirstSeenAt, now), ct);
if (newlyFlagged > 0)
{
_logger.LogWarning("cs.money dupe detection: {Count} instance(s) newly flagged.", newlyFlagged);
}
}
// Stamp this band's cs.money checkpoint (upsert into skin_condition_sweeps under
// the csmoney source). Caller persists via SaveChangesAsync.
private async Task StampCheckpointAsync(int? conditionId, DateTimeOffset now, CancellationToken ct)
{
if (conditionId is { } cid)
{
await SweepCheckpoints.StampConditionAsync(_db, cid, Source, now, ct);
}
}
private static CsMoneyListing Map(CsMoneyItem it, int skinId, int? conditionId, DateTimeOffset now) => new()
{
SellOrderId = it.Id,
AssetId = it.Asset?.Id?.ToString(),
SkinId = skinId,
ConditionId = conditionId,
MarketHashName = it.Asset?.Names?.Full ?? it.Asset?.Names?.Short ?? "",
Quality = it.Asset?.Quality,
FloatValue = it.Asset?.Float,
PaintSeed = it.Asset?.Pattern,
Phase = it.Asset?.Phase,
IsStatTrak = it.Asset?.IsStatTrak ?? false,
IsSouvenir = it.Asset?.IsSouvenir ?? false,
StickerCount = it.Stickers?.Count(s => s is not null) ?? 0,
Price = it.Pricing?.Default ?? 0m,
PriceBeforeDiscount = it.Pricing?.PriceBeforeDiscount,
ComputedPrice = it.Pricing?.Computed,
Currency = "USD",
InspectLink = it.Links?.InspectLink,
FirstSeenAt = now,
LastSeenAt = now,
Status = ListingStatus.Active,
};
// Normalize a market name for matching: drop the StatTrak/Souvenir/★ adornments,
// collapse whitespace, lowercase. So "StatTrak™ M4A4 | Cyber Security" and the
// catalogue's "M4A4 | Cyber Security" compare equal.
private static string Normalize(string name)
{
var s = name
.Replace("★", " ", StringComparison.Ordinal)
.Replace("StatTrak™", " ", StringComparison.OrdinalIgnoreCase)
.Replace("Souvenir", " ", StringComparison.OrdinalIgnoreCase);
return string.Join(' ', s.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToLowerInvariant();
}
}