using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Execution;
/// <summary>Recognizes journal-confirmed station services only when they exactly explain the original wallet gap.</summary>
public static class ExternalServiceCosts
{
public sealed record Proof(TradeCashEntry Entry,string File,int Line,string Original);
public static async Task<bool> ReconcileAsync(string journals,string evidenceDirectory,TradeJourneyCheckpoint checkpoint,
TradeLeg leg,TradeProfitLedger ledger,long opening,long actual,CancellationToken token)
{
var gap=opening+ledger.Snapshot.CashChange-actual;
if(gap<=0||checkpoint.Bought is not {} bought||!Directory.Exists(journals))return false;
var candidates=(await ReadAsync(journals,checkpoint.Commander,checkpoint.ShipId,bought.ConfirmedAt,[leg.From.MarketId,leg.To.MarketId],token))
.Where(p=>!ledger.ContainsOperation(p.Entry.OperationKey)).ToArray();
if(candidates.Length==0||candidates.Sum(v=>v.Entry.Amount)!=gap)return false;
var proofs=Path.Combine(evidenceDirectory,"external-service-proofs");Directory.CreateDirectory(proofs);
foreach(var value in candidates)
{
var path=Path.Combine(proofs,value.Entry.OperationKey+".json");
await File.WriteAllTextAsync(path+".tmp",JsonSerializer.Serialize(new{checkpoint.Commander,checkpoint.ShipId,value.File,value.Line,value.Original,
entry=value.Entry,walletGap=gap,actualWallet=actual,at=DateTimeOffset.UtcNow},ToolboxSettings.JsonOptions),token);
File.Move(path+".tmp",path,true);ledger.Record(value.Entry);
}
return opening+ledger.Snapshot.CashChange==actual;
}
public static async Task<IReadOnlyList<Proof>> ReadAsync(string journals,string commander,long expectedShip,DateTimeOffset since,IReadOnlyList<long> markets,CancellationToken token)
{
if(!Directory.Exists(journals))return [];
var candidates=new Dictionary<string,Proof>();
foreach(var file in Directory.EnumerateFiles(journals,"Journal*.log").Where(p=>File.GetLastWriteTimeUtc(p)>=since.UtcDateTime).Order(StringComparer.Ordinal))
{
await using var stream=new FileStream(file,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete);
using var reader=new StreamReader(stream);var fid="";long ship=0,market=0;var lineNumber=0;
while(await reader.ReadLineAsync(token) is {} line)
{
lineNumber++;JsonDocument document;
try{document=JsonDocument.Parse(line);}catch(JsonException){continue;}
using(document)
{
var row=document.RootElement;
string Text(string key)=>row.TryGetProperty(key,out var v)&&v.ValueKind==JsonValueKind.String?v.GetString()!:"";
long Number(string key)=>row.TryGetProperty(key,out var v)&&v.TryGetInt64(out var n)?n:0;
var kind=Text("event");
if(kind=="LoadGame"){fid=Text("FID");ship=Number("ShipID");market=0;}
if(kind=="Loadout")ship=Number("ShipID");
if(kind=="ShipyardSwap"){ship=0;market=0;}
if(kind is "Docked" or "Location")market=kind=="Docked"||row.TryGetProperty("Docked",out var docked)&&docked.ValueKind==JsonValueKind.True?Number("MarketID"):0;
if(kind=="Undocked")market=0;
var cashKind=kind switch{"RefuelAll" or "RefuelPartial"=>(TradeCashKind?)TradeCashKind.Fuel,"RepairAll" or "Repair"=>TradeCashKind.Repair,"PayFines" or "PayBounties"=>TradeCashKind.OtherExpense,_=>null};
var cost=kind is "PayFines" or "PayBounties"?Number("Amount"):Number("Cost");
if(cashKind is null||fid!=commander||ship!=expectedShip||!markets.Contains(market)
||!DateTimeOffset.TryParse(Text("timestamp"),out var at)||at<since||at>DateTimeOffset.UtcNow||cost<=0)continue;
var key=Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Path.GetFileName(file)+"\n"+lineNumber+"\n"+line)));
candidates.TryAdd(key,new(new(key,cashKind.Value,cost,market,at),Path.GetFileName(file),lineNumber,line));
}
}
}
return candidates.Values.ToArray();
}
}
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Execution;
/// <summary>Recognizes journal-confirmed station services only when they exactly explain the original wallet gap.</summary>
public static class ExternalServiceCosts
{
public sealed record Proof(TradeCashEntry Entry,string File,int Line,string Original);
public static async Task<bool> ReconcileAsync(string journals,string evidenceDirectory,TradeJourneyCheckpoint checkpoint,
TradeLeg leg,TradeProfitLedger ledger,long opening,long actual,CancellationToken token)
{
var gap=opening+ledger.Snapshot.CashChange-actual;
if(gap<=0||checkpoint.Bought is not {} bought||!Directory.Exists(journals))return false;
var candidates=(await ReadAsync(journals,checkpoint.Commander,checkpoint.ShipId,bought.ConfirmedAt,[leg.From.MarketId,leg.To.MarketId],token))
.Where(p=>!ledger.ContainsOperation(p.Entry.OperationKey)).ToArray();
if(candidates.Length==0||candidates.Sum(v=>v.Entry.Amount)!=gap)return false;
var proofs=Path.Combine(evidenceDirectory,"external-service-proofs");Directory.CreateDirectory(proofs);
foreach(var value in candidates)
{
var path=Path.Combine(proofs,value.Entry.OperationKey+".json");
await File.WriteAllTextAsync(path+".tmp",JsonSerializer.Serialize(new{checkpoint.Commander,checkpoint.ShipId,value.File,value.Line,value.Original,
entry=value.Entry,walletGap=gap,actualWallet=actual,at=DateTimeOffset.UtcNow},ToolboxSettings.JsonOptions),token);
File.Move(path+".tmp",path,true);ledger.Record(value.Entry);
}
return opening+ledger.Snapshot.CashChange==actual;
}
public static async Task<IReadOnlyList<Proof>> ReadAsync(string journals,string commander,long expectedShip,DateTimeOffset since,IReadOnlyList<long> markets,CancellationToken token)
{
if(!Directory.Exists(journals))return [];
var candidates=new Dictionary<string,Proof>();
foreach(var file in Directory.EnumerateFiles(journals,"Journal*.log").Where(p=>File.GetLastWriteTimeUtc(p)>=since.UtcDateTime).Order(StringComparer.Ordinal))
{
await using var stream=new FileStream(file,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete);
using var reader=new StreamReader(stream);var fid="";long ship=0,market=0;var lineNumber=0;
while(await reader.ReadLineAsync(token) is {} line)
{
lineNumber++;JsonDocument document;
try{document=JsonDocument.Parse(line);}catch(JsonException){continue;}
using(document)
{
var row=document.RootElement;
string Text(string key)=>row.TryGetProperty(key,out var v)&&v.ValueKind==JsonValueKind.String?v.GetString()!:"";
long Number(string key)=>row.TryGetProperty(key,out var v)&&v.TryGetInt64(out var n)?n:0;
var kind=Text("event");
if(kind=="LoadGame"){fid=Text("FID");ship=Number("ShipID");market=0;}
if(kind=="Loadout")ship=Number("ShipID");
if(kind=="ShipyardSwap"){ship=0;market=0;}
if(kind is "Docked" or "Location")market=kind=="Docked"||row.TryGetProperty("Docked",out var docked)&&docked.ValueKind==JsonValueKind.True?Number("MarketID"):0;
if(kind=="Undocked")market=0;
var cashKind=kind switch{"RefuelAll" or "RefuelPartial"=>(TradeCashKind?)TradeCashKind.Fuel,"RepairAll" or "Repair"=>TradeCashKind.Repair,"PayFines" or "PayBounties"=>TradeCashKind.OtherExpense,_=>null};
var cost=kind is "PayFines" or "PayBounties"?Number("Amount"):Number("Cost");
if(cashKind is null||fid!=commander||ship!=expectedShip||!markets.Contains(market)
||!DateTimeOffset.TryParse(Text("timestamp"),out var at)||at<since||at>DateTimeOffset.UtcNow||cost<=0)continue;
var key=Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Path.GetFileName(file)+"\n"+lineNumber+"\n"+line)));
candidates.TryAdd(key,new(new(key,cashKind.Value,cost,market,at),Path.GetFileName(file),lineNumber,line));
}
}
}
return candidates.Values.ToArray();
}
}