using System.Globalization;
using LudosData.Api.Services;
namespace LudosData.Api.Services.Pricing;
/// One row of an external price list.
public record PriceGuideRow(
string Title,
string? System,
decimal? Loose,
decimal? Cib,
decimal? New);
public record PriceGuideParseResult(
IReadOnlyList Rows,
IReadOnlyList Problems);
///
/// Reads a price list out of a CSV.
///
/// The point is to be source-agnostic. A PriceCharting subscriber's bulk
/// download, a spreadsheet kept by hand, and a list exported from anywhere else
/// all describe the same thing — a title, a platform and some prices — so this
/// accepts the column names each of them tends to use rather than demanding one
/// fixed schema.
///
public static class PriceGuide
{
// Header aliases, lowercased and stripped of spaces, underscores and hyphens.
private static readonly string[] TitleNames =
["title", "productname", "product", "name", "game", "gamename"];
private static readonly string[] SystemNames =
["system", "console", "consolename", "platform"];
private static readonly string[] LooseNames =
["loose", "looseprice", "loosevalue", "cartonly", "value"];
private static readonly string[] CibNames =
["cib", "cibprice", "complete", "completeprice", "cibvalue", "completeinbox"];
private static readonly string[] NewNames =
["new", "newprice", "sealed", "sealedprice", "newvalue", "graded"];
private static string Normalise(string header) =>
new(header.Trim().ToLowerInvariant()
.Where(c => char.IsLetterOrDigit(c))
.ToArray());
public static PriceGuideParseResult Parse(string text)
{
var problems = new List();
var rows = Csv.Parse(text);
if (rows.Count == 0)
{
return new PriceGuideParseResult([], ["The file is empty."]);
}
var header = rows[0].Select(Normalise).ToList();
int Find(string[] names)
{
foreach (var name in names)
{
var at = header.IndexOf(name);
if (at >= 0) return at;
}
return -1;
}
var titleAt = Find(TitleNames);
if (titleAt < 0)
{
return new PriceGuideParseResult([],
["No title column found. Expected one of: title, product-name, name, game."]);
}
var systemAt = Find(SystemNames);
var looseAt = Find(LooseNames);
var cibAt = Find(CibNames);
var newAt = Find(NewNames);
if (looseAt < 0 && cibAt < 0 && newAt < 0)
{
return new PriceGuideParseResult([],
["No price column found. Expected at least one of: loose, cib, new."]);
}
var parsed = new List();
for (var i = 1; i < rows.Count; i++)
{
var row = rows[i];
string? Cell(int at) =>
at >= 0 && at < row.Count && row[at].Trim().Length > 0 ? row[at].Trim() : null;
var title = Cell(titleAt);
if (title is null)
{
problems.Add($"Row {i + 1}: no title");
continue;
}
parsed.Add(new PriceGuideRow(
title, Cell(systemAt),
ParseMoney(Cell(looseAt)), ParseMoney(Cell(cibAt)), ParseMoney(Cell(newAt))));
}
return new PriceGuideParseResult(parsed, problems);
}
///
/// Tolerant of what a spreadsheet emits: currency symbols, thousands
/// separators, and integer pennies where a guide quotes them that way.
///
public static decimal? ParseMoney(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var cleaned = value.Trim().TrimStart('$', '£', '€').Replace(",", string.Empty).Trim();
if (!decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed))
{
return null;
}
// Zero means "no price on record", not "free".
return parsed > 0 ? parsed : null;
}
}