unit test_Catalog;

{$mode objfpc}{$H+}

/// <summary>
/// test_Catalog — FPCUnit tests for uHCLCatalog.
///
/// Covers JSON parsing (full document, missing fields, null fields), the
/// IHCLCatalog query methods (FindFile, SearchFiles, GetFilesByArtifactType,
/// GetTotalFileCount, GetCategories), multi-source management (UpsertSource
/// deduplication), cache round-trip (SaveSourceToCache / LoadSourceFromCache),
/// ParseISO8601 date handling, and downward artifact-type inheritance from
/// categories to subcategories and files.
///
/// Network access is not required — all tests use in-memory JSON strings or
/// the local filesystem temp directory.
/// </summary>

interface

uses
  Classes, SysUtils, DateUtils,
  fpcunit, testregistry,
  uHCLCatalog;

type
  /// <summary>
  /// FPCUnit test suite for <see cref="IHCLCatalog"/> / <see cref="THCLCatalog"/>.
  /// </summary>
  TCatalogTests = class(TTestCase)
  private
    FCatalog: IHCLCatalog;

    // -----------------------------------------------------------------------
    // Helpers
    // -----------------------------------------------------------------------

    /// <summary>
    /// Returns a minimal but structurally complete catalog JSON string for
    /// source ID <c>harpgamer</c> with one category, one sub-category, and
    /// two files.
    /// </summary>
    function MinimalCatalogJSON: String;

    /// <summary>
    /// Returns a second catalog JSON string for source ID <c>harpgamer2</c>
    /// with one category and one file (BattleSet type).
    /// </summary>
    function SecondSourceJSON: String;

    /// <summary>
    /// Saves <paramref name="AJson"/> to a temp file and returns the path.
    /// Caller is responsible for deleting the file.
    /// </summary>
    function WriteTempJSON(const AJson: String): String;

    /// <summary>
    /// Loads <paramref name="AJson"/> into FCatalog via a temp-file cache
    /// round-trip and returns True on success.
    /// </summary>
    function LoadJSON(const AJson: String): Boolean;

    /// <summary>
    /// Wraps a comma-separated list of category JSON objects in a minimal
    /// catalog document for source ID <c>inherit</c>.
    /// </summary>
    /// <param name="ACategories">Category JSON objects, without the enclosing
    /// brackets.</param>
    /// <returns>A complete catalog JSON string.</returns>
    function WrapCategories(const ACategories: String): String;

  protected
    procedure SetUp; override;
    procedure TearDown; override;

  published
    // -----------------------------------------------------------------------
    // Initial state
    // -----------------------------------------------------------------------
    /// <summary>A freshly created catalog has zero sources.</summary>
    procedure TestInitialSourceCount;
    /// <summary>A freshly created catalog reports zero files.</summary>
    procedure TestInitialFileCount;

    // -----------------------------------------------------------------------
    // LoadSourceFromCache
    // -----------------------------------------------------------------------
    /// <summary>Loading a non-existent file returns False.</summary>
    procedure TestLoadNonExistentFile;
    /// <summary>Loading a valid JSON file returns True.</summary>
    procedure TestLoadValidJSON;
    /// <summary>After loading, GetSources returns one source.</summary>
    procedure TestLoadAddsSource;
    /// <summary>Source fields are parsed correctly.</summary>
    procedure TestSourceFields;
    /// <summary>Top-level category count is correct.</summary>
    procedure TestCategoryCount;
    /// <summary>Category fields are parsed correctly.</summary>
    procedure TestCategoryFields;
    /// <summary>Subcategory is parsed inside parent category.</summary>
    procedure TestSubcategoryParsed;
    /// <summary>File fields are parsed correctly.</summary>
    procedure TestFileFields;
    /// <summary>File dependencies sub-object is parsed.</summary>
    procedure TestFileDependencies;
    /// <summary>Missing optional JSON fields default to empty / 0.</summary>
    procedure TestMissingFieldDefaults;

    // -----------------------------------------------------------------------
    // GetTotalFileCount
    // -----------------------------------------------------------------------
    /// <summary>Total file count spans files in categories and subcategories.</summary>
    procedure TestTotalFileCount;

    // -----------------------------------------------------------------------
    // FindFile
    // -----------------------------------------------------------------------
    /// <summary>FindFile returns the correct file by ID.</summary>
    procedure TestFindFileByID;
    /// <summary>FindFile on an unknown ID returns an empty record.</summary>
    procedure TestFindFileUnknownID;
    /// <summary>FindFile descends into subcategories.</summary>
    procedure TestFindFileInSubcategory;

    // -----------------------------------------------------------------------
    // SearchFiles
    // -----------------------------------------------------------------------
    /// <summary>SearchFiles returns matching files (case-insensitive).</summary>
    procedure TestSearchFilesMatch;
    /// <summary>SearchFiles returns empty array when no match.</summary>
    procedure TestSearchFilesNoMatch;
    /// <summary>SearchFiles with empty query matches no files (FPC Pos semantics).</summary>
    procedure TestSearchFilesEmptyQuery;

    // -----------------------------------------------------------------------
    // GetFilesByArtifactType
    // -----------------------------------------------------------------------
    /// <summary>GetFilesByArtifactType returns only files of the given type.</summary>
    procedure TestGetFilesByArtifactType;
    /// <summary>GetFilesByArtifactType returns empty array for unknown type.</summary>
    procedure TestGetFilesByArtifactTypeUnknown;

    // -----------------------------------------------------------------------
    // GetCategories
    // -----------------------------------------------------------------------
    /// <summary>GetCategories returns top-level categories for all sources.</summary>
    procedure TestGetCategoriesAll;
    /// <summary>GetCategories filters by source ID.</summary>
    procedure TestGetCategoriesFiltered;
    /// <summary>GetCategories with unknown source filter returns empty array.</summary>
    procedure TestGetCategoriesUnknownFilter;

    // -----------------------------------------------------------------------
    // Multi-source / UpsertSource deduplication
    // -----------------------------------------------------------------------
    /// <summary>Loading the same source twice does not duplicate it.</summary>
    procedure TestUpsertDeduplicates;
    /// <summary>Loading two different sources accumulates both.</summary>
    procedure TestTwoSourcesAccumulate;

    // -----------------------------------------------------------------------
    // SaveSourceToCache / round-trip
    // -----------------------------------------------------------------------
    /// <summary>Saving a loaded source produces a file with non-zero size.</summary>
    procedure TestSaveCreatesFile;
    /// <summary>Cache round-trip preserves the source ID.</summary>
    procedure TestCacheRoundTripSourceID;
    /// <summary>Cache round-trip preserves file count.</summary>
    procedure TestCacheRoundTripFileCount;
    /// <summary>SaveSourceToCache on unknown source ID does nothing.</summary>
    procedure TestSaveUnknownSourceIDNoOp;

    // -----------------------------------------------------------------------
    // Date parsing (ParseISO8601 via Generated field)
    // -----------------------------------------------------------------------
    /// <summary>ISO 8601 date-time with Z suffix is parsed.</summary>
    procedure TestGeneratedDateTimeZ;
    /// <summary>ISO 8601 date-only string is parsed.</summary>
    procedure TestGeneratedDateOnly;
    /// <summary>Empty generated field gives 0 (zero TDateTime).</summary>
    procedure TestGeneratedEmptyIsZero;

    // -----------------------------------------------------------------------
    // Artifact-type inheritance (ParseCategory)
    // -----------------------------------------------------------------------
    /// <summary>An untyped file inherits its category's artifact type.</summary>
    procedure TestInheritFileFromCategory;
    /// <summary>A file's explicit artifact type wins over its category's.</summary>
    procedure TestInheritFileExplicitWins;
    /// <summary>An untyped subcategory and its untyped files inherit the parent's type.</summary>
    procedure TestInheritSubcategoryFromParent;
    /// <summary>A typed subcategory keeps its type and passes it (not the grandparent's) to its files.</summary>
    procedure TestInheritSubcategoryExplicitWins;
    /// <summary>Inheritance carries through two untyped levels down to a file.</summary>
    procedure TestInheritTwoLevelsDeep;
    /// <summary>An untyped top-level category and its untyped file both stay empty.</summary>
    procedure TestInheritNothingToInherit;
    /// <summary>GetFilesByArtifactType finds files typed only by inheritance.</summary>
    procedure TestInheritGetFilesByArtifactType;
    /// <summary>Live-catalog shape (types on categories only, no file artifactType key) yields typed files.</summary>
    procedure TestInheritLiveCatalogShape;

    /// <summary>
    /// externalLink:true marks an entry whose download is a link to another
    /// site; absent, false, or a non-boolean value leaves it unset.
    /// </summary>
    procedure TestExternalLinkFlag;

    /// <summary>
    /// downloadUrl naming a specific record (?do=download&amp;r=N) is carried
    /// through to the file record unchanged.
    /// </summary>
    procedure TestDownloadUrlCarriedThrough;

    /// <summary>
    /// versionLabel and the versions array are parsed, newest first; entries
    /// without a downloadUrl or that are not objects are dropped.
    /// </summary>
    procedure TestVersionsParsed;

    /// <summary>A file without versions has an empty array and no label.</summary>
    procedure TestVersionsAbsent;

    /// <summary>
    /// HCLSelectVersion swaps in the chosen version's download fields and
    /// keeps title, type and dependencies; -1 or out of range changes nothing.
    /// </summary>
    procedure TestSelectVersion;

    /// <summary>HCLVersionCaption names the current and earlier versions.</summary>
    procedure TestVersionCaption;

    /// <summary>
    /// companions are parsed; entries without an id or file, or whose file
    /// name carries a path, are dropped.
    /// </summary>
    procedure TestCompanionsParsed;
  end;

implementation

// ---------------------------------------------------------------------------
// JSON fixture strings
// ---------------------------------------------------------------------------

function TCatalogTests.MinimalCatalogJSON: String;
begin
  Result :=
    '{'#10 +
    '  "generated": "2026-03-15T12:00:00Z",'#10 +
    '  "source": {'#10 +
    '    "id": "harpgamer",'#10 +
    '    "name": "HarpGamer.com",'#10 +
    '    "url": "https://harpgamer.com",'#10 +
    '    "contact": "admin@harpgamer.com",'#10 +
    '    "downloadMethod": "ips-csrf"'#10 +
    '  },'#10 +
    '  "categories": ['#10 +
    '    {'#10 +
    '      "id": "harpgamer:5",'#10 +
    '      "name": "Scenarios",'#10 +
    '      "parentId": "",'#10 +
    '      "artifactType": "Scenario",'#10 +
    '      "files": ['#10 +
    '        {'#10 +
    '          "id": "harpgamer:101",'#10 +
    '          "title": "GIUK Gap 1987",'#10 +
    '          "version": "1.2",'#10 +
    '          "author": "TonyE",'#10 +
    '          "size": 204800,'#10 +
    '          "downloads": 350,'#10 +
    '          "updated": "2026-01-10",'#10 +
    '          "description": "Classic GIUK scenario",'#10 +
    '          "pageUrl": "https://harpgamer.com/files/101",'#10 +
    '          "downloadUrl": "https://harpgamer.com/dl/101",'#10 +
    '          "fileName": "giuk1987.zip",'#10 +
    '          "sha256": "abc123",'#10 +
    '          "screenshotUrl": "https://harpgamer.com/ss/101.png",'#10 +
    '          "rating": 4.5,'#10 +
    '          "artifactType": "Scenario",'#10 +
    '          "dependencies": {'#10 +
    '            "platformDB": "HCDB2-170909",'#10 +
    '            "gameVersion": ">=2007.000",'#10 +
    '            "battleSet": "GIUK",'#10 +
    '            "battleSetVariant": ".scm - EC2003"'#10 +
    '          }'#10 +
    '        }'#10 +
    '      ],'#10 +
    '      "subcategories": ['#10 +
    '        {'#10 +
    '          "id": "harpgamer:6",'#10 +
    '          "name": "Atlantic",'#10 +
    '          "parentId": "harpgamer:5",'#10 +
    '          "artifactType": "Scenario",'#10 +
    '          "files": ['#10 +
    '            {'#10 +
    '              "id": "harpgamer:102",'#10 +
    '              "title": "Norwegian Sea Sweep",'#10 +
    '              "version": "1.0",'#10 +
    '              "author": "Bob",'#10 +
    '              "size": 102400,'#10 +
    '              "downloads": 120,'#10 +
    '              "updated": "2025-11-01",'#10 +
    '              "description": "Northern scenario",'#10 +
    '              "pageUrl": "",'#10 +
    '              "downloadUrl": "",'#10 +
    '              "fileName": "norsea.zip",'#10 +
    '              "sha256": "",'#10 +
    '              "screenshotUrl": "",'#10 +
    '              "rating": 3.0,'#10 +
    '              "artifactType": "Scenario",'#10 +
    '              "dependencies": {}'#10 +
    '            }'#10 +
    '          ],'#10 +
    '          "subcategories": []'#10 +
    '        }'#10 +
    '      ]'#10 +
    '    }'#10 +
    '  ]'#10 +
    '}';
end;

function TCatalogTests.SecondSourceJSON: String;
begin
  Result :=
    '{'#10 +
    '  "generated": "2026-03-01T08:00:00Z",'#10 +
    '  "source": {'#10 +
    '    "id": "harpgamer2",'#10 +
    '    "name": "HarpGamer Mirror",'#10 +
    '    "url": "https://mirror.harpgamer.com",'#10 +
    '    "contact": "",'#10 +
    '    "downloadMethod": "direct"'#10 +
    '  },'#10 +
    '  "categories": ['#10 +
    '    {'#10 +
    '      "id": "harpgamer2:1",'#10 +
    '      "name": "BattleSets",'#10 +
    '      "parentId": "",'#10 +
    '      "artifactType": "BattleSet",'#10 +
    '      "files": ['#10 +
    '        {'#10 +
    '          "id": "harpgamer2:201",'#10 +
    '          "title": "Malacca Strait BS",'#10 +
    '          "version": "1.0",'#10 +
    '          "author": "TonyE",'#10 +
    '          "size": 512000,'#10 +
    '          "downloads": 80,'#10 +
    '          "updated": "2025-06-15",'#10 +
    '          "description": "Malacca BattleSet",'#10 +
    '          "pageUrl": "",'#10 +
    '          "downloadUrl": "",'#10 +
    '          "fileName": "malacca.zip",'#10 +
    '          "sha256": "",'#10 +
    '          "screenshotUrl": "",'#10 +
    '          "rating": 4.0,'#10 +
    '          "artifactType": "BattleSet",'#10 +
    '          "dependencies": {}'#10 +
    '        }'#10 +
    '      ],'#10 +
    '      "subcategories": []'#10 +
    '    }'#10 +
    '  ]'#10 +
    '}';
end;

function TCatalogTests.WriteTempJSON(const AJson: String): String;
var
  SL: TStringList;
begin
  Result := GetTempDir + 'hcl_test_' + IntToStr(GetTickCount64) + '.json';
  SL := TStringList.Create;
  try
    SL.Text := AJson;
    SL.SaveToFile(Result);
  finally
    SL.Free;
  end;
end;

function TCatalogTests.LoadJSON(const AJson: String): Boolean;
var
  TmpPath: String;
begin
  TmpPath := WriteTempJSON(AJson);
  try
    Result := FCatalog.LoadSourceFromCache(TmpPath);
  finally
    if FileExists(TmpPath) then
      DeleteFile(TmpPath);
  end;
end;

function TCatalogTests.WrapCategories(const ACategories: String): String;
begin
  Result :=
    '{"generated":"2026-09-01T00:00:00Z","source":{"id":"inherit",' +
    '"name":"Inherit","url":"","contact":"","downloadMethod":"direct"},' +
    '"categories":[' + ACategories + ']}';
end;

// ---------------------------------------------------------------------------
// SetUp / TearDown
// ---------------------------------------------------------------------------

procedure TCatalogTests.SetUp;
begin
  FCatalog := CreateCatalog;
end;

procedure TCatalogTests.TearDown;
begin
  FCatalog := nil;
end;

// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------

procedure TCatalogTests.TestInitialSourceCount;
begin
  AssertEquals('Fresh catalog has zero sources',
    0, Length(FCatalog.GetSources));
end;

procedure TCatalogTests.TestInitialFileCount;
begin
  AssertEquals('Fresh catalog has zero files', 0, FCatalog.GetTotalFileCount);
end;

// ---------------------------------------------------------------------------
// LoadSourceFromCache
// ---------------------------------------------------------------------------

procedure TCatalogTests.TestLoadNonExistentFile;
begin
  AssertFalse('Non-existent path returns False',
    FCatalog.LoadSourceFromCache('/no/such/path/catalog.json'));
end;

procedure TCatalogTests.TestLoadValidJSON;
begin
  AssertTrue('Valid JSON returns True', LoadJSON(MinimalCatalogJSON));
end;

procedure TCatalogTests.TestLoadAddsSource;
begin
  LoadJSON(MinimalCatalogJSON);
  AssertEquals('One source after loading', 1, Length(FCatalog.GetSources));
end;

procedure TCatalogTests.TestSourceFields;
var
  Src: THCLCatalogSource;
begin
  LoadJSON(MinimalCatalogJSON);
  Src := FCatalog.GetSources[0];
  AssertEquals('source.id',             'harpgamer',           Src.ID);
  AssertEquals('source.name',           'HarpGamer.com',       Src.Name);
  AssertEquals('source.url',            'https://harpgamer.com', Src.Url);
  AssertEquals('source.contact',        'admin@harpgamer.com', Src.Contact);
  AssertEquals('source.downloadMethod', 'ips-csrf',            Src.DownloadMethod);
end;

procedure TCatalogTests.TestCategoryCount;
begin
  LoadJSON(MinimalCatalogJSON);
  AssertEquals('One top-level category',
    1, Length(FCatalog.GetSources[0].Categories));
end;

procedure TCatalogTests.TestCategoryFields;
var
  Cat: THCLCatalogCategory;
begin
  LoadJSON(MinimalCatalogJSON);
  Cat := FCatalog.GetSources[0].Categories[0];
  AssertEquals('category.id',           'harpgamer:5', Cat.ID);
  AssertEquals('category.name',         'Scenarios',   Cat.Name);
  AssertEquals('category.parentId',     '',            Cat.ParentID);
  AssertEquals('category.artifactType', 'Scenario',    Cat.ArtifactType);
end;

procedure TCatalogTests.TestSubcategoryParsed;
var
  Cat: THCLCatalogCategory;
begin
  LoadJSON(MinimalCatalogJSON);
  Cat := FCatalog.GetSources[0].Categories[0];
  AssertEquals('One subcategory', 1, Length(Cat.Subcategories));
  AssertEquals('subcategory.id',   'harpgamer:6', Cat.Subcategories[0].ID);
  AssertEquals('subcategory.name', 'Atlantic',    Cat.Subcategories[0].Name);
end;

procedure TCatalogTests.TestFileFields;
var
  F: THCLCatalogFile;
begin
  LoadJSON(MinimalCatalogJSON);
  F := FCatalog.GetSources[0].Categories[0].Files[0];
  AssertEquals('file.id',          'harpgamer:101',                  F.ID);
  AssertEquals('file.title',       'GIUK Gap 1987',                  F.Title);
  AssertEquals('file.version',     '1.2',                            F.Version);
  AssertEquals('file.author',      'TonyE',                          F.Author);
  AssertEquals('file.size',        Int64(204800),                    F.Size);
  AssertEquals('file.downloads',   350,                              F.Downloads);
  AssertEquals('file.updated',     '2026-01-10',                     F.Updated);
  AssertEquals('file.pageUrl',     'https://harpgamer.com/files/101', F.PageUrl);
  AssertEquals('file.downloadUrl', 'https://harpgamer.com/dl/101',   F.DownloadUrl);
  AssertEquals('file.fileName',    'giuk1987.zip',                   F.FileName);
  AssertEquals('file.sha256',      'abc123',                         F.SHA256);
  AssertEquals('file.artifactType','Scenario',                       F.ArtifactType);
  AssertEquals('file.rating',      4.5,                              F.Rating, 0.001);
end;

procedure TCatalogTests.TestFileDependencies;
var
  F: THCLCatalogFile;
begin
  LoadJSON(MinimalCatalogJSON);
  F := FCatalog.GetSources[0].Categories[0].Files[0];
  AssertEquals('dep.platformDB',       'HCDB2-170909',    F.Dependencies.PlatformDB);
  AssertEquals('dep.gameVersion',      '>=2007.000',      F.Dependencies.GameVersion);
  AssertEquals('dep.battleSet',        'GIUK',            F.Dependencies.BattleSet);
  AssertEquals('dep.battleSetVariant', '.scm - EC2003',   F.Dependencies.BattleSetVariant);
end;

procedure TCatalogTests.TestMissingFieldDefaults;
var
  MinimalFileJSON: String;
begin
  // A JSON with a file that has only an id — everything else missing
  MinimalFileJSON :=
    '{"generated":"","source":{"id":"s1","name":"","url":"","contact":"",' +
    '"downloadMethod":"direct"},"categories":[{"id":"c1","name":"Cat1",' +
    '"parentId":"","artifactType":"","files":[{"id":"s1:1"}],' +
    '"subcategories":[]}]}';
  LoadJSON(MinimalFileJSON);
  AssertEquals(1, Length(FCatalog.GetSources));
  AssertEquals('s1:1', FCatalog.GetSources[0].Categories[0].Files[0].ID);
  AssertEquals('title default empty', '',  FCatalog.GetSources[0].Categories[0].Files[0].Title);
  AssertEquals('size default 0',      Int64(0), FCatalog.GetSources[0].Categories[0].Files[0].Size);
  AssertEquals('rating default 0',    0.0, FCatalog.GetSources[0].Categories[0].Files[0].Rating, 0.001);
end;

// ---------------------------------------------------------------------------
// GetTotalFileCount
// ---------------------------------------------------------------------------

procedure TCatalogTests.TestTotalFileCount;
begin
  LoadJSON(MinimalCatalogJSON);
  // 1 file in top-level category + 1 file in subcategory = 2
  AssertEquals('Total file count', 2, FCatalog.GetTotalFileCount);
end;

// ---------------------------------------------------------------------------
// FindFile
// ---------------------------------------------------------------------------

procedure TCatalogTests.TestFindFileByID;
var
  F: THCLCatalogFile;
begin
  LoadJSON(MinimalCatalogJSON);
  F := FCatalog.FindFile('harpgamer:101');
  AssertEquals('Found by ID', 'harpgamer:101', F.ID);
  AssertEquals('Title matches', 'GIUK Gap 1987', F.Title);
end;

procedure TCatalogTests.TestFindFileUnknownID;
var
  F: THCLCatalogFile;
begin
  LoadJSON(MinimalCatalogJSON);
  F := FCatalog.FindFile('harpgamer:9999');
  AssertEquals('Unknown ID returns empty ID', '', F.ID);
end;

procedure TCatalogTests.TestFindFileInSubcategory;
var
  F: THCLCatalogFile;
begin
  LoadJSON(MinimalCatalogJSON);
  // harpgamer:102 lives in the subcategory
  F := FCatalog.FindFile('harpgamer:102');
  AssertEquals('Found in subcategory', 'harpgamer:102', F.ID);
end;

// ---------------------------------------------------------------------------
// SearchFiles
// ---------------------------------------------------------------------------

procedure TCatalogTests.TestSearchFilesMatch;
var
  Results: THCLCatalogFileArray;
begin
  LoadJSON(MinimalCatalogJSON);
  Results := FCatalog.SearchFiles('GIUK');
  AssertEquals('One match for GIUK', 1, Length(Results));
  AssertEquals('Matched file ID', 'harpgamer:101', Results[0].ID);
end;

procedure TCatalogTests.TestSearchFilesNoMatch;
var
  Results: THCLCatalogFileArray;
begin
  LoadJSON(MinimalCatalogJSON);
  Results := FCatalog.SearchFiles('ZZZ_NO_MATCH_ZZZ');
  AssertEquals('No matches', 0, Length(Results));
end;

procedure TCatalogTests.TestSearchFilesEmptyQuery;
var
  Results: THCLCatalogFileArray;
begin
  LoadJSON(MinimalCatalogJSON);
  // FPC Pos('', s) returns 0, so an empty query matches no files — this is
  // consistent with the Pos-based implementation and avoids a special case.
  Results := FCatalog.SearchFiles('');
  AssertEquals('Empty query matches no files', 0, Length(Results));
end;

// ---------------------------------------------------------------------------
// GetFilesByArtifactType
// ---------------------------------------------------------------------------

procedure TCatalogTests.TestGetFilesByArtifactType;
var
  Results: THCLCatalogFileArray;
begin
  LoadJSON(MinimalCatalogJSON);
  Results := FCatalog.GetFilesByArtifactType('Scenario');
  AssertEquals('Two Scenario files', 2, Length(Results));
end;

procedure TCatalogTests.TestGetFilesByArtifactTypeUnknown;
var
  Results: THCLCatalogFileArray;
begin
  LoadJSON(MinimalCatalogJSON);
  Results := FCatalog.GetFilesByArtifactType('BattleSet');
  AssertEquals('Zero BattleSet files in first source', 0, Length(Results));
end;

// ---------------------------------------------------------------------------
// GetCategories
// ---------------------------------------------------------------------------

procedure TCatalogTests.TestGetCategoriesAll;
begin
  LoadJSON(MinimalCatalogJSON);
  // One top-level category in the only source
  AssertEquals('One category total', 1, Length(FCatalog.GetCategories));
end;

procedure TCatalogTests.TestGetCategoriesFiltered;
begin
  LoadJSON(MinimalCatalogJSON);
  AssertEquals('Filter by harpgamer', 1,
    Length(FCatalog.GetCategories('harpgamer')));
  AssertEquals('Filter by unknown', 0,
    Length(FCatalog.GetCategories('nobody')));
end;

procedure TCatalogTests.TestGetCategoriesUnknownFilter;
begin
  LoadJSON(MinimalCatalogJSON);
  AssertEquals('Zero for unknown source filter', 0,
    Length(FCatalog.GetCategories('nope')));
end;

// ---------------------------------------------------------------------------
// Multi-source / UpsertSource deduplication
// ---------------------------------------------------------------------------

procedure TCatalogTests.TestUpsertDeduplicates;
begin
  LoadJSON(MinimalCatalogJSON);
  LoadJSON(MinimalCatalogJSON); // same source ID "harpgamer"
  AssertEquals('Still one source after duplicate load', 1,
    Length(FCatalog.GetSources));
end;

procedure TCatalogTests.TestTwoSourcesAccumulate;
begin
  LoadJSON(MinimalCatalogJSON);
  LoadJSON(SecondSourceJSON);
  AssertEquals('Two sources', 2, Length(FCatalog.GetSources));
  AssertEquals('Three files total', 3, FCatalog.GetTotalFileCount);
end;

// ---------------------------------------------------------------------------
// SaveSourceToCache / round-trip
// ---------------------------------------------------------------------------

procedure TCatalogTests.TestSaveCreatesFile;
var
  SavePath: String;
  FS: TFileStream;
begin
  LoadJSON(MinimalCatalogJSON);
  SavePath := GetTempDir + 'hcl_save_test_' + IntToStr(GetTickCount64) + '.json';
  try
    FCatalog.SaveSourceToCache('harpgamer', SavePath);
    AssertTrue('Cache file was created', FileExists(SavePath));
    FS := TFileStream.Create(SavePath, fmOpenRead or fmShareDenyNone);
    try
      AssertTrue('Cache file is non-empty', FS.Size > 0);
    finally
      FS.Free;
    end;
  finally
    if FileExists(SavePath) then
      DeleteFile(SavePath);
  end;
end;

procedure TCatalogTests.TestCacheRoundTripSourceID;
var
  SavePath: String;
  Cat2: IHCLCatalog;
begin
  LoadJSON(MinimalCatalogJSON);
  SavePath := GetTempDir + 'hcl_rt_' + IntToStr(GetTickCount64) + '.json';
  try
    FCatalog.SaveSourceToCache('harpgamer', SavePath);
    Cat2 := CreateCatalog;
    Cat2.LoadSourceFromCache(SavePath);
    AssertEquals('Round-trip source ID',
      'harpgamer', Cat2.GetSources[0].ID);
  finally
    if FileExists(SavePath) then
      DeleteFile(SavePath);
  end;
end;

procedure TCatalogTests.TestCacheRoundTripFileCount;
var
  SavePath: String;
  Cat2: IHCLCatalog;
begin
  LoadJSON(MinimalCatalogJSON);
  SavePath := GetTempDir + 'hcl_rt2_' + IntToStr(GetTickCount64) + '.json';
  try
    FCatalog.SaveSourceToCache('harpgamer', SavePath);
    Cat2 := CreateCatalog;
    Cat2.LoadSourceFromCache(SavePath);
    AssertEquals('Round-trip file count', 2, Cat2.GetTotalFileCount);
  finally
    if FileExists(SavePath) then
      DeleteFile(SavePath);
  end;
end;

procedure TCatalogTests.TestSaveUnknownSourceIDNoOp;
var
  SavePath: String;
begin
  LoadJSON(MinimalCatalogJSON);
  SavePath := GetTempDir + 'hcl_noop_' + IntToStr(GetTickCount64) + '.json';
  try
    // Should not raise; should simply not create the file
    FCatalog.SaveSourceToCache('does_not_exist', SavePath);
    AssertFalse('No file created for unknown source', FileExists(SavePath));
  finally
    if FileExists(SavePath) then
      DeleteFile(SavePath);
  end;
end;

// ---------------------------------------------------------------------------
// Date parsing via Generated field
// ---------------------------------------------------------------------------

procedure TCatalogTests.TestGeneratedDateTimeZ;
var
  Src: THCLCatalogSource;
  Y, Mo, D, H, Mi, S, Ms: Word;
begin
  LoadJSON(MinimalCatalogJSON); // generated: "2026-03-15T12:00:00Z"
  Src := FCatalog.GetSources[0];
  AssertTrue('Generated is non-zero', Src.Generated <> 0);
  DecodeDateTime(Src.Generated, Y, Mo, D, H, Mi, S, Ms);
  AssertEquals('Year',   2026, Y);
  AssertEquals('Month',  3,    Mo);
  AssertEquals('Day',    15,   D);
  AssertEquals('Hour',   12,   H);
  AssertEquals('Minute', 0,    Mi);
end;

procedure TCatalogTests.TestGeneratedDateOnly;
var
  JsonDateOnly: String;
  Src: THCLCatalogSource;
  Y, Mo, D: Word;
begin
  JsonDateOnly :=
    '{"generated":"2025-07-04","source":{"id":"datetest","name":"","url":"",' +
    '"contact":"","downloadMethod":"direct"},"categories":[]}';
  LoadJSON(JsonDateOnly);
  // Find the source just loaded — it will be index 0 (only one)
  Src := FCatalog.GetSources[0];
  AssertTrue('Generated non-zero for date-only', Src.Generated <> 0);
  Y  := YearOf(Src.Generated);
  Mo := MonthOf(Src.Generated);
  D  := DayOf(Src.Generated);
  AssertEquals('Year',  2025, Y);
  AssertEquals('Month', 7,    Mo);
  AssertEquals('Day',   4,    D);
end;

procedure TCatalogTests.TestGeneratedEmptyIsZero;
var
  JsonNoDate: String;
  Src: THCLCatalogSource;
begin
  JsonNoDate :=
    '{"generated":"","source":{"id":"nodatetest","name":"","url":"",' +
    '"contact":"","downloadMethod":"direct"},"categories":[]}';
  LoadJSON(JsonNoDate);
  Src := FCatalog.GetSources[0];
  AssertEquals('Generated is 0 when empty', Double(0), Double(Src.Generated), 0.0);
end;

// ---------------------------------------------------------------------------
// Artifact-type inheritance (ParseCategory)
// ---------------------------------------------------------------------------

procedure TCatalogTests.TestInheritFileFromCategory;
var
  Cat: THCLCatalogCategory;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(
    '{"id":"c1","name":"BattleSets","artifactType":"BattleSet",' +
    '"files":[{"id":"f1","title":"Untyped"}]}')));
  Cat := FCatalog.GetSources[0].Categories[0];
  AssertEquals('category type', 'BattleSet', Cat.ArtifactType);
  AssertEquals('file inherits category type', 'BattleSet', Cat.Files[0].ArtifactType);
end;

procedure TCatalogTests.TestInheritFileExplicitWins;
var
  Cat: THCLCatalogCategory;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(
    '{"id":"c1","name":"Tools","artifactType":"Tool",' +
    '"files":[{"id":"f1","artifactType":"ExportDLL"},{"id":"f2"}]}')));
  Cat := FCatalog.GetSources[0].Categories[0];
  AssertEquals('explicit file type kept', 'ExportDLL', Cat.Files[0].ArtifactType);
  AssertEquals('sibling untyped file inherits', 'Tool', Cat.Files[1].ArtifactType);
end;

procedure TCatalogTests.TestInheritSubcategoryFromParent;
var
  Sub: THCLCatalogCategory;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(
    '{"id":"c1","name":"Scenarios","artifactType":"Scenario","files":[],' +
    '"subcategories":[{"id":"c2","name":"Atlantic","parentId":"c1",' +
    '"files":[{"id":"f1"},{"id":"f2"}]}]}')));
  Sub := FCatalog.GetSources[0].Categories[0].Subcategories[0];
  AssertEquals('subcategory inherits', 'Scenario', Sub.ArtifactType);
  AssertEquals('file 1 inherits', 'Scenario', Sub.Files[0].ArtifactType);
  AssertEquals('file 2 inherits', 'Scenario', Sub.Files[1].ArtifactType);
end;

procedure TCatalogTests.TestInheritSubcategoryExplicitWins;
var
  Cat, Sub: THCLCatalogCategory;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(
    '{"id":"c1","name":"Tools","artifactType":"Tool",' +
    '"files":[{"id":"f0"}],' +
    '"subcategories":[{"id":"c2","name":"Maps","parentId":"c1",' +
    '"artifactType":"Map","files":[{"id":"f1"}]}]}')));
  Cat := FCatalog.GetSources[0].Categories[0];
  Sub := Cat.Subcategories[0];
  AssertEquals('parent keeps own type', 'Tool', Cat.ArtifactType);
  AssertEquals('parent file inherits parent', 'Tool', Cat.Files[0].ArtifactType);
  AssertEquals('subcategory keeps explicit type', 'Map', Sub.ArtifactType);
  AssertEquals('subcategory file gets Map, not grandparent', 'Map', Sub.Files[0].ArtifactType);
end;

procedure TCatalogTests.TestInheritTwoLevelsDeep;
var
  Parent, Child: THCLCatalogCategory;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(
    '{"id":"g","name":"Grand","artifactType":"Scenario","files":[],' +
    '"subcategories":[{"id":"p","name":"Parent","parentId":"g","files":[],' +
    '"subcategories":[{"id":"c","name":"Child","parentId":"p",' +
    '"files":[{"id":"f1"}]}]}]}')));
  Parent := FCatalog.GetSources[0].Categories[0].Subcategories[0];
  Child := Parent.Subcategories[0];
  AssertEquals('parent inherits', 'Scenario', Parent.ArtifactType);
  AssertEquals('child inherits', 'Scenario', Child.ArtifactType);
  AssertEquals('grandchild file inherits', 'Scenario', Child.Files[0].ArtifactType);
end;

procedure TCatalogTests.TestInheritNothingToInherit;
var
  Cat: THCLCatalogCategory;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(
    '{"id":"c1","name":"Misc","files":[{"id":"f1"}]}')));
  Cat := FCatalog.GetSources[0].Categories[0];
  AssertEquals('untyped top-level category stays empty', '', Cat.ArtifactType);
  AssertEquals('untyped file stays empty', '', Cat.Files[0].ArtifactType);
end;

procedure TCatalogTests.TestInheritGetFilesByArtifactType;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(
    '{"id":"c1","name":"BattleSets","artifactType":"BattleSet",' +
    '"files":[{"id":"b1"},{"id":"b2"}],' +
    '"subcategories":[{"id":"c2","name":"Old","parentId":"c1",' +
    '"files":[{"id":"b3"}]}]},' +
    '{"id":"c3","name":"Scenarios","artifactType":"Scenario",' +
    '"files":[{"id":"s1"}]}')));
  AssertEquals('Three inherited BattleSet files', 3,
    Length(FCatalog.GetFilesByArtifactType('BattleSet')));
  AssertEquals('One inherited Scenario file', 1,
    Length(FCatalog.GetFilesByArtifactType('Scenario')));
end;

procedure TCatalogTests.TestInheritLiveCatalogShape;
const
  EXPECTED: array[0..2] of String = ('BattleSet', 'ExportDLL', 'Scenario');
var
  Src: THCLCatalogSource;
  I, J: Integer;
begin
  // Mirrors the live harpgamer catalog: artifactType lives only on
  // categories; files carry id/title/fileName:""/size and no artifactType key.
  AssertTrue('Load', LoadJSON(WrapCategories(
    '{"id":"harpgamer:10","name":"BattleSets","parentId":"",' +
    '"artifactType":"BattleSet","files":[' +
    '{"id":"harpgamer:274","title":"Caribbean","fileName":"","size":123456},' +
    '{"id":"harpgamer:275","title":"Med","fileName":"","size":65432}],' +
    '"subcategories":[]},' +
    '{"id":"harpgamer:20","name":"ExportDLLs","parentId":"",' +
    '"artifactType":"ExportDLL","files":[' +
    '{"id":"harpgamer:835","title":"lazHCEffects","fileName":"","size":999}],' +
    '"subcategories":[]},' +
    '{"id":"harpgamer:30","name":"Scenarios","parentId":"",' +
    '"artifactType":"Scenario","files":[' +
    '{"id":"harpgamer:900","title":"GIUK","fileName":"","size":1},' +
    '{"id":"harpgamer:901","title":"Baltic","fileName":"","size":2}],' +
    '"subcategories":[]}')));
  Src := FCatalog.GetSources[0];
  AssertEquals('Three categories', 3, Length(Src.Categories));
  AssertEquals('Total files', 5, FCatalog.GetTotalFileCount);
  for I := 0 to High(Src.Categories) do
  begin
    AssertTrue('category ' + IntToStr(I) + ' has files',
      Length(Src.Categories[I].Files) > 0);
    for J := 0 to High(Src.Categories[I].Files) do
      AssertEquals('file ' + Src.Categories[I].Files[J].ID + ' type',
        EXPECTED[I], Src.Categories[I].Files[J].ArtifactType);
  end;
end;

procedure TCatalogTests.TestExternalLinkFlag;
var
  F: THCLCatalogFileArray;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(
    '{"id":"harpgamer:12","name":"Tools","parentId":"","artifactType":"Tool","files":[' +
    '{"id":"harpgamer:1","title":"Linked","externalLink":true},' +
    '{"id":"harpgamer:2","title":"Explicitly not","externalLink":false},' +
    '{"id":"harpgamer:3","title":"Absent"},' +
    '{"id":"harpgamer:4","title":"String, not boolean","externalLink":"true"},' +
    '{"id":"harpgamer:5","title":"Number, not boolean","externalLink":1}],' +
    '"subcategories":[]}')));
  F := FCatalog.GetSources[0].Categories[0].Files;
  AssertEquals('five files', 5, Length(F));
  AssertTrue('true flags the link', F[0].ExternalLink);
  AssertFalse('false leaves it unset', F[1].ExternalLink);
  AssertFalse('absent leaves it unset', F[2].ExternalLink);
  AssertFalse('a string is not a flag', F[3].ExternalLink);
  AssertFalse('a number is not a flag', F[4].ExternalLink);
end;

procedure TCatalogTests.TestDownloadUrlCarriedThrough;
var
  F: THCLCatalogFile;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(
    '{"id":"harpgamer:15","name":"BattleSets","parentId":"","artifactType":"BattleSet","files":[' +
    '{"id":"harpgamer:289","title":"CaribbeanBasin",' +
    '"pageUrl":"https://harpgamer.com/harpforum/files/file/289-caribbeanbasin/",' +
    '"downloadUrl":"https://harpgamer.com/harpforum/files/file/289-caribbeanbasin/?do=download&r=1234"}],' +
    '"subcategories":[]}')));
  F := FCatalog.GetSources[0].Categories[0].Files[0];
  AssertEquals('downloadUrl kept verbatim',
    'https://harpgamer.com/harpforum/files/file/289-caribbeanbasin/?do=download&r=1234', F.DownloadUrl);
  AssertFalse('not an external link', F.ExternalLink);
end;

const
  /// <summary>HCDB2 as the generator publishes it: current version plus two earlier ones.</summary>
  HCDB2_WITH_VERSIONS =
    '{"id":"harpgamer:6","name":"PlatformDBs","parentId":"","artifactType":"PlatformDB","files":[' +
    '{"id":"harpgamer:934","title":"HCDB2-170909 (1980-2025)","version":"","versionLabel":"HCDB2-170909",' +
    '"updated":"2017-09-09","fileName":"HCDB2-170909.zip","size":1200000,"sha256":"aa",' +
    '"pageUrl":"https://harpgamer.com/harpforum/files/file/934-hcdb2/",' +
    '"downloadUrl":"https://harpgamer.com/harpforum/files/file/934-hcdb2/?do=download&r=5000",' +
    '"dependencies":{"platformDB":"HCDB2-170909 (1980-2025)"},' +
    '"versions":[' +
      '{"version":"","label":"HCDB2-170714","date":"2017-07-14","changelog":"More ships",' +
      '"fileName":"HCDB2-170714.zip","size":1190000,"sha256":"bb",' +
      '"downloadUrl":"https://harpgamer.com/harpforum/files/file/934-hcdb2/?do=download&version=1094&r=4990"},' +
      '{"label":"no link, not offered","fileName":"x.zip"},' +
      '"not an object",' +
      '{"version":"161204","label":"161204","date":"2016-12-04","fileName":"HCDB2-161204.zip","size":1000000,' +
      '"downloadUrl":"https://harpgamer.com/harpforum/files/file/934-hcdb2/?do=download&version=1019&r=4000"}' +
    ']}],"subcategories":[]}';

procedure TCatalogTests.TestVersionsParsed;
var
  F: THCLCatalogFile;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(HCDB2_WITH_VERSIONS)));
  F := FCatalog.GetSources[0].Categories[0].Files[0];
  AssertEquals('current label', 'HCDB2-170909', F.VersionLabel);
  AssertEquals('two offered versions', 2, Length(F.Versions));
  AssertEquals('newest first', 'HCDB2-170714', F.Versions[0].VersionLabel);
  AssertEquals('date', '2017-07-14', F.Versions[0].Updated);
  AssertEquals('changelog', 'More ships', F.Versions[0].Changelog);
  AssertEquals('file name', 'HCDB2-170714.zip', F.Versions[0].FileName);
  AssertEquals('size', 1190000, F.Versions[0].Size);
  AssertEquals('hash', 'bb', F.Versions[0].SHA256);
  AssertEquals('link',
    'https://harpgamer.com/harpforum/files/file/934-hcdb2/?do=download&version=1094&r=4990',
    F.Versions[0].DownloadUrl);
  AssertEquals('version field', '161204', F.Versions[1].Version);
  AssertEquals('missing hash is empty', '', F.Versions[1].SHA256);
  AssertFalse('parsed files are never "previous"', F.IsPreviousVersion);
  // Found through the catalog's own lookup too.
  AssertEquals('FindFile keeps versions', 2, Length(FCatalog.FindFile('harpgamer:934').Versions));
end;

procedure TCatalogTests.TestVersionsAbsent;
var
  F: THCLCatalogFile;
begin
  AssertTrue('Load', LoadJSON(MinimalCatalogJSON));
  F := FCatalog.FindFile('harpgamer:235');
  AssertEquals('no versions', 0, Length(F.Versions));
  AssertEquals('no label', '', F.VersionLabel);
  AssertFalse('not previous', F.IsPreviousVersion);
end;

procedure TCatalogTests.TestSelectVersion;
var
  F, S: THCLCatalogFile;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(HCDB2_WITH_VERSIONS)));
  F := FCatalog.GetSources[0].Categories[0].Files[0];

  S := HCLSelectVersion(F, 0);
  AssertTrue('marked previous', S.IsPreviousVersion);
  AssertEquals('file name', 'HCDB2-170714.zip', S.FileName);
  AssertEquals('size', 1190000, S.Size);
  AssertEquals('hash', 'bb', S.SHA256);
  AssertEquals('link', F.Versions[0].DownloadUrl, S.DownloadUrl);
  AssertEquals('label', 'HCDB2-170714', S.VersionLabel);
  AssertEquals('date', '2017-07-14', S.Updated);
  AssertEquals('title kept', F.Title, S.Title);
  AssertEquals('type kept', 'PlatformDB', S.ArtifactType);
  AssertEquals('page kept', F.PageUrl, S.PageUrl);
  AssertEquals('dependencies kept', F.Dependencies.PlatformDB, S.Dependencies.PlatformDB);
  AssertEquals('original untouched', 'HCDB2-170909.zip', F.FileName);

  S := HCLSelectVersion(F, -1);
  AssertFalse('-1 is the current version', S.IsPreviousVersion);
  AssertEquals('-1 unchanged', 'HCDB2-170909.zip', S.FileName);
  AssertFalse('past the end: unchanged', HCLSelectVersion(F, 2).IsPreviousVersion);
  AssertFalse('below -1: unchanged', HCLSelectVersion(F, -2).IsPreviousVersion);
end;

procedure TCatalogTests.TestVersionCaption;
var
  F: THCLCatalogFile;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(HCDB2_WITH_VERSIONS)));
  F := FCatalog.GetSources[0].Categories[0].Files[0];
  AssertEquals('current', 'HCDB2-170909 (current, 2017-09-09)', HCLVersionCaption(F, -1));
  AssertEquals('earlier', 'HCDB2-170714 (2017-07-14)', HCLVersionCaption(F, 0));
  AssertEquals('out of range', '', HCLVersionCaption(F, 5));

  F.VersionLabel := '';
  F.Version := '';
  AssertEquals('current, unnamed', 'Current version (2017-09-09)', HCLVersionCaption(F, -1));
  F.Updated := '';
  AssertEquals('current, nothing known', 'Current version', HCLVersionCaption(F, -1));
  F.Version := '2.1';
  AssertEquals('falls back to the version field', '2.1 (current)', HCLVersionCaption(F, -1));
  F.Versions[1].VersionLabel := '';
  F.Versions[1].Version := '';
  F.Versions[1].Updated := '';
  AssertEquals('earlier, unnamed', 'Earlier version', HCLVersionCaption(F, 1));
end;

procedure TCatalogTests.TestCompanionsParsed;
var
  F: THCLCatalogFile;
begin
  AssertTrue('Load', LoadJSON(WrapCategories(
    '{"id":"harpgamer:6","name":"PlatformDBs","parentId":"","artifactType":"PlatformDB","files":[' +
    '{"id":"harpgamer:605","title":"hc-timelines-db collection","companions":[' +
      '{"id":"harpgamer:328","provides":"commondb.rsr"},' +
      '{"id":"","provides":"x.rsr"},' +
      '{"id":"harpgamer:1","provides":""},' +
      '{"id":"harpgamer:2","provides":"..\\evil.rsr"},' +
      '{"id":"harpgamer:3","provides":"sub/x.rsr"},' +
      '{"id":"harpgamer:4","provides":"C:x.rsr"},' +
      '{"id":"harpgamer:5","provides":".."},' +
      '"not an object"]},' +
    '{"id":"harpgamer:934","title":"HCDB2"}],"subcategories":[]}')));
  F := FCatalog.FindFile('harpgamer:605');
  AssertEquals('one valid companion', 1, Length(F.Companions));
  AssertEquals('its id', 'harpgamer:328', F.Companions[0].ID);
  AssertEquals('what it supplies', 'commondb.rsr', F.Companions[0].Provides);
  AssertEquals('none for HCDB2', 0, Length(FCatalog.FindFile('harpgamer:934').Companions));
end;

// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------

initialization
  RegisterTest(TCatalogTests);

end.
