using System.Globalization;
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server;
///
/// Measured library package sizes. The server never parses packages, so the worker lists the
/// packages of current drawings without a summary at its version, measures each and stores it
/// here keyed by package hash: identical geometry is measured once, and a metadata-only
/// revision keeps its summary. Queries join it to filter and sort by size and holes.
///
public sealed partial class NestDatabase
{
// v19: one summary per package; error is nonblank (and the sizes 0) when it could not be measured.
private const string DrawingGeometrySchema = """
CREATE TABLE IF NOT EXISTS drawing_package_geometry (
sha256 TEXT NOT NULL PRIMARY KEY REFERENCES drawing_packages(sha256),
version INTEGER NOT NULL,
length REAL NOT NULL,
width REAL NOT NULL,
area REAL NOT NULL,
holes INTEGER NOT NULL,
error TEXT NOT NULL DEFAULT '',
measuredAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_drawing_package_geometry_length ON drawing_package_geometry(length);
PRAGMA user_version=19;
""";
// Lengths and thickness compare in inches; the slack absorbs rounding and millimeter conversion.
private const double RangeSlack = 1e-6;
private const string GeometryJoin = "LEFT JOIN drawing_package_geometry g ON g.sha256 = drawings.packageSha256";
private const string GeometryColumns = "g.version, g.length, g.width, g.area, g.holes, g.error";
private const string ThicknessInches = "(CASE WHEN units = 'Millimeters' THEN thickness / 25.4 ELSE thickness END)";
///
/// Up to distinct package hashes of current drawings (archived ones
/// included) with no summary at or later, in hash order.
///
public IReadOnlyList GetUnmeasuredPackages(int version, int limit)
{
ArgumentOutOfRangeException.ThrowIfLessThan(version, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1);
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = """
SELECT DISTINCT d.packageSha256 FROM drawings d
LEFT JOIN drawing_package_geometry g ON g.sha256 = d.packageSha256
WHERE g.sha256 IS NULL OR g.version < $version
ORDER BY d.packageSha256 LIMIT $limit
""";
command.Parameters.AddWithValue("$version", version);
command.Parameters.AddWithValue("$limit", limit);
var hashes = new List();
using var reader = command.ExecuteReader();
while (reader.Read())
hashes.Add(reader.GetString(0));
return hashes;
}
}
/// The package stored under a hash, or null.
public byte[]? GetPackageBySha(string sha256)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = "SELECT package FROM drawing_packages WHERE sha256=$sha";
command.Parameters.AddWithValue("$sha", sha256);
return command.ExecuteScalar() as byte[];
}
}
///
/// Stores a package's summary; false when the package is unknown. A summary from an older
/// version than the stored one is ignored, so an outdated worker cannot undo a newer one.
///
public bool SetPackageGeometry(string sha256, DrawingGeometrySummary summary)
{
ArgumentNullException.ThrowIfNull(summary);
var error = summary.GetValidationError();
if (error is not null)
throw new ArgumentException(error, nameof(summary));
lock (_sync)
{
using var exists = _connection.CreateCommand();
exists.CommandText = "SELECT COUNT(*) FROM drawing_packages WHERE sha256=$sha";
exists.Parameters.AddWithValue("$sha", sha256);
if ((long)exists.ExecuteScalar()! == 0)
return false;
using var command = _connection.CreateCommand();
command.CommandText = """
INSERT INTO drawing_package_geometry (sha256, version, length, width, area, holes, error, measuredAt)
VALUES ($sha, $version, $length, $width, $area, $holes, $error, $measuredAt)
ON CONFLICT(sha256) DO UPDATE SET
version = excluded.version, length = excluded.length, width = excluded.width,
area = excluded.area, holes = excluded.holes, error = excluded.error, measuredAt = excluded.measuredAt
WHERE excluded.version >= drawing_package_geometry.version
""";
var measured = summary.IsMeasured;
command.Parameters.AddWithValue("$sha", sha256);
command.Parameters.AddWithValue("$version", summary.Version);
command.Parameters.AddWithValue("$length", measured ? summary.Length : 0);
command.Parameters.AddWithValue("$width", measured ? summary.Width : 0);
command.Parameters.AddWithValue("$area", measured ? summary.Area : 0);
command.Parameters.AddWithValue("$holes", measured ? summary.Holes : 0);
command.Parameters.AddWithValue("$error", summary.Error);
command.Parameters.AddWithValue("$measuredAt", DateTime.Now.ToString("o", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
return true;
}
}
// The filter's ranges as WHERE conditions over drawings joined to their geometry (GeometryJoin).
private static void AddRangeConditions(List conditions, DrawingLibraryFilter filter)
{
if (filter.MinThickness is not null || filter.MaxThickness is not null)
{
var range = new List { "thickness > 0" };
if (filter.MinThickness is not null) range.Add($"{ThicknessInches} >= $minThickness");
if (filter.MaxThickness is not null) range.Add($"{ThicknessInches} <= $maxThickness");
var inRange = "(" + string.Join(" AND ", range) + ")";
// A drawing saved without a thickness suits any, as with the exact thickness filter.
conditions.Add(filter.IncludeBlankThickness ? $"({inRange} OR thickness = 0)" : inRange);
}
else if (!filter.IncludeBlankThickness)
{
conditions.Add("thickness > 0");
}
if (filter.NeedsGeometry)
conditions.Add("g.error = ''");
if (filter.MinLength is not null) conditions.Add("g.length >= $minLength");
if (filter.MaxLength is not null) conditions.Add("g.length <= $maxLength");
if (filter.MinWidth is not null) conditions.Add("g.width >= $minWidth");
if (filter.MaxWidth is not null) conditions.Add("g.width <= $maxWidth");
if (filter.MinHoles is not null) conditions.Add("g.holes >= $minHoles");
if (filter.MaxHoles is not null) conditions.Add("g.holes <= $maxHoles");
// savedAt is the server's local ISO time, so its first ten characters are its local date.
if (filter.SavedFrom is not null) conditions.Add("substr(savedAt, 1, 10) >= $savedFrom");
if (filter.SavedTo is not null) conditions.Add("substr(savedAt, 1, 10) <= $savedTo");
}
private static void AddRangeParameters(SqliteCommand command, DrawingLibraryFilter filter)
{
AddLower(command, "$minThickness", filter.MinThickness);
AddUpper(command, "$maxThickness", filter.MaxThickness);
AddLower(command, "$minLength", filter.MinLength);
AddUpper(command, "$maxLength", filter.MaxLength);
AddLower(command, "$minWidth", filter.MinWidth);
AddUpper(command, "$maxWidth", filter.MaxWidth);
if (filter.MinHoles is { } minHoles) command.Parameters.AddWithValue("$minHoles", minHoles);
if (filter.MaxHoles is { } maxHoles) command.Parameters.AddWithValue("$maxHoles", maxHoles);
if (filter.SavedFrom is { } from) command.Parameters.AddWithValue("$savedFrom", from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
if (filter.SavedTo is { } to) command.Parameters.AddWithValue("$savedTo", to.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
}
private static void AddLower(SqliteCommand command, string name, double? value)
{
if (value is { } number)
command.Parameters.AddWithValue(name, number - RangeSlack);
}
private static void AddUpper(SqliteCommand command, string name, double? value)
{
if (value is { } number)
command.Parameters.AddWithValue(name, number + RangeSlack);
}
// Reads GeometryColumns from column `first` on; null when the package has no summary yet.
private static DrawingGeometrySummary? ReadGeometry(SqliteDataReader reader, int first) =>
reader.IsDBNull(first) ? null : new DrawingGeometrySummary
{
Version = reader.GetInt32(first),
Length = reader.GetDouble(first + 1),
Width = reader.GetDouble(first + 2),
Area = reader.GetDouble(first + 3),
Holes = reader.GetInt32(first + 4),
Error = reader.GetString(first + 5),
};
}