unit uHCLCatalog;

{$mode objfpc}{$H+}

/// <summary>
/// uHCLCatalog — catalog fetcher and parser for the HC Launcher's HarpGamer
/// downloads integration.
///
/// Downloads a JSON catalog manifest from a URL, parses it into typed Pascal
/// records, and maintains an in-memory collection of sources, categories, and
/// files.  Supports local disk caching (raw JSON) and conditional HTTP
/// requests via If-Modified-Since.
///
/// Zero UI / LCL dependency.  Requires OpenSSL at runtime for HTTPS URLs.
/// </summary>
///
/// <remarks>
/// Uses clauses: SysUtils, Classes, fpjson, jsonparser, fphttpclient,
/// uHCLOpenSSL (OpenSSL 3 names; before opensslsockets), opensslsockets, DateUtils.
///
/// Thread safety: <c>THCLCatalog</c> instances are not thread-safe.
/// Callers must synchronise access if sharing across threads.
/// </remarks>

interface

uses
  SysUtils, Classes, fpjson, jsonparser, uHCLOpenSSL, fphttpclient, opensslsockets, DateUtils;

// ---------------------------------------------------------------------------
// THCLDependencies
// ---------------------------------------------------------------------------

type
  /// <summary>
  /// Dependency constraints for a catalog file.  All fields are optional;
  /// empty string means "no constraint".
  /// </summary>
  THCLDependencies = record
    /// <summary>Required PlatformDB identifier, e.g. "HCDB2-170909".</summary>
    PlatformDB: String;
    /// <summary>Required game version expression, e.g. "&gt;=2007.000".</summary>
    GameVersion: String;
    /// <summary>Required BattleSet identifier, e.g. "GIUK".</summary>
    BattleSet: String;
    /// <summary>Required BattleSet variant string, e.g. ".scm - EC2003".</summary>
    BattleSetVariant: String;
  end;

// ---------------------------------------------------------------------------
// THCLCatalogVersion
// ---------------------------------------------------------------------------

  /// <summary>
  /// An earlier version of a catalog file that the source still offers
  /// (the catalog's <c>versions</c> array), e.g. an older HCDB2 build that a
  /// scenario names as its platform database.
  /// </summary>
  THCLCatalogVersion = record
    /// <summary>The source's own version field; often empty.</summary>
    Version: String;
    /// <summary>
    /// Display name: the version field when set, else the filename without
    /// its extension (e.g. "HCDB2-170611").
    /// </summary>
    VersionLabel: String;
    /// <summary>When this version was published, "YYYY-MM-DD"; may be empty.</summary>
    Updated: String;
    /// <summary>Plain-text changelog; may be empty.</summary>
    Changelog: String;
    /// <summary>Suggested local filename.</summary>
    FileName: String;
    /// <summary>Size in bytes (0 if unknown).</summary>
    Size: Int64;
    /// <summary>Hex-encoded SHA-256 of the download, or empty.</summary>
    SHA256: String;
    /// <summary>URL that downloads exactly this version's file.</summary>
    DownloadUrl: String;
  end;

  /// <summary>Dynamic array of <see cref="THCLCatalogVersion"/>, newest first.</summary>
  THCLCatalogVersionArray = array of THCLCatalogVersion;

  /// <summary>
  /// A file an entry needs from another catalog entry (the catalog's
  /// <c>companions</c>), e.g. the shared <c>commondb.rsr</c> that René Haar's
  /// timeline databases leave out.
  /// </summary>
  THCLCatalogCompanion = record
    /// <summary>Catalog ID of the entry that supplies it, e.g. "harpgamer:328".</summary>
    ID: String;
    /// <summary>The bare file name it supplies, e.g. "commondb.rsr".</summary>
    Provides: String;
  end;

  /// <summary>Dynamic array of <see cref="THCLCatalogCompanion"/>.</summary>
  THCLCatalogCompanionArray = array of THCLCatalogCompanion;

// ---------------------------------------------------------------------------
// THCLCatalogFile
// ---------------------------------------------------------------------------

  /// <summary>
  /// Describes a single downloadable artifact in the catalog.
  /// </summary>
  THCLCatalogFile = record
    /// <summary>Source-scoped unique identifier, e.g. "harpgamer:235".</summary>
    ID: String;
    /// <summary>Human-readable title.</summary>
    Title: String;
    /// <summary>Version string, e.g. "1.0" or "2024-01".</summary>
    Version: String;
    /// <summary>Author / uploader name.</summary>
    Author: String;
    /// <summary>File size in bytes (0 if unknown).</summary>
    Size: Int64;
    /// <summary>Total download count reported by the source (0 if unknown).</summary>
    Downloads: Integer;
    /// <summary>Last-updated date in "YYYY-MM-DD" format.</summary>
    Updated: String;
    /// <summary>Short description of the artifact.</summary>
    Description: String;
    /// <summary>URL of the file's detail page on the source site.</summary>
    PageUrl: String;
    /// <summary>Direct URL to download the artifact.</summary>
    DownloadUrl: String;
    /// <summary>Suggested local filename for the download.</summary>
    FileName: String;
    /// <summary>Hex-encoded SHA-256 checksum of the download, or empty.</summary>
    SHA256: String;
    /// <summary>URL of a representative screenshot image, or empty.</summary>
    ScreenshotUrl: String;
    /// <summary>Average user rating (0.0 if not rated).</summary>
    Rating: Double;
    /// <summary>
    /// Artifact type tag: "PlatformDB", "Scenario", "BattleSet", "ExportDLL",
    /// "Sound", "Graphic", "Map", or "Tool".
    /// </summary>
    ArtifactType: String;
    /// <summary>Optional dependency constraints.</summary>
    Dependencies: THCLDependencies;
    /// <summary>
    /// True when the entry's download is a link to another site rather than
    /// a file (the catalog's <c>externalLink</c>).  Such entries are opened in
    /// the browser; downloading would only fetch a web page.
    /// </summary>
    ExternalLink: Boolean;
    /// <summary>
    /// Display name of this version (the catalog's <c>versionLabel</c>): the
    /// version field when set, else the filename without its extension.
    /// </summary>
    VersionLabel: String;
    /// <summary>Earlier versions still offered, newest first; empty when none.</summary>
    Versions: THCLCatalogVersionArray;
    /// <summary>
    /// True for a copy made by <see cref="HCLSelectVersion"/> that describes
    /// one of <c>Versions</c> rather than the current version.  Never set by
    /// the parser.
    /// </summary>
    IsPreviousVersion: Boolean;
    /// <summary>Files it needs from other entries; empty when none.</summary>
    Companions: THCLCatalogCompanionArray;
  end;

  /// <summary>Dynamic array of <see cref="THCLCatalogFile"/>.</summary>
  THCLCatalogFileArray = array of THCLCatalogFile;

  /// <summary>Typed pointer to <see cref="THCLCatalogFile"/>.</summary>
  PHCLCatalogFile = ^THCLCatalogFile;

// ---------------------------------------------------------------------------
// THCLCatalogCategory
// ---------------------------------------------------------------------------
// ObjFPC allows a record type to contain a dynamic array of itself when the
// array element type is fully defined — self-referential dynamic arrays in
// records compile cleanly because dynamic arrays are internally a pointer.
// No explicit forward declaration is needed.
// ---------------------------------------------------------------------------

  /// <summary>
  /// A catalog category, which may contain files and nested sub-categories.
  /// </summary>
  THCLCatalogCategory = record
    /// <summary>Source-scoped unique identifier, e.g. "harpgamer:5".</summary>
    ID: String;
    /// <summary>Human-readable category name.</summary>
    Name: String;
    /// <summary>Parent category ID, or empty for a top-level category.</summary>
    ParentID: String;
    /// <summary>Artifact type tag shared by all files in this category.</summary>
    ArtifactType: String;
    /// <summary>Files belonging directly to this category.</summary>
    Files: THCLCatalogFileArray;
    /// <summary>Nested sub-categories (recursive).</summary>
    Subcategories: array of THCLCatalogCategory;
  end;

  /// <summary>Dynamic array of <see cref="THCLCatalogCategory"/>.</summary>
  THCLCatalogCategoryArray = array of THCLCatalogCategory;

// ---------------------------------------------------------------------------
// THCLCatalogSource
// ---------------------------------------------------------------------------

  /// <summary>
  /// A single catalog source (e.g. HarpGamer.com), containing one or more
  /// top-level categories.
  /// </summary>
  THCLCatalogSource = record
    /// <summary>Short identifier for the source, e.g. "harpgamer".</summary>
    ID: String;
    /// <summary>Display name, e.g. "HarpGamer.com".</summary>
    Name: String;
    /// <summary>Base URL of the source site.</summary>
    Url: String;
    /// <summary>Contact information for the source.</summary>
    Contact: String;
    /// <summary>Download method: "direct" or "ips-csrf".</summary>
    DownloadMethod: String;
    /// <summary>Date/time the catalog manifest was generated (UTC).</summary>
    Generated: TDateTime;
    /// <summary>Top-level categories in this source.</summary>
    Categories: THCLCatalogCategoryArray;
    /// <summary>
    /// Raw JSON text of the last successful fetch or cache load.
    /// Used by <c>SaveSourceToCache</c> to avoid re-serialising.
    /// </summary>
    RawJSON: String;
  end;

  /// <summary>Dynamic array of <see cref="THCLCatalogSource"/>.</summary>
  THCLCatalogSourceArray = array of THCLCatalogSource;

// ---------------------------------------------------------------------------
// IHCLCatalog
// ---------------------------------------------------------------------------

  /// <summary>
  /// Interface for a catalog manager that fetches, caches, and queries
  /// HC artifact manifests.
  /// </summary>
  IHCLCatalog = interface
    ['{A3F7C2D1-8E4B-4F6A-9D2C-1B5E7F3A8C04}']

    /// <summary>
    /// Fetch a catalog manifest from a remote URL and add it as a source.
    /// Sends If-Modified-Since if a cached copy is already loaded.
    /// </summary>
    /// <param name="AManifestURL">Full URL of the JSON catalog manifest.</param>
    /// <returns>True on success (new data parsed or 304 Not Modified), False on
    /// any network or parse error.</returns>
    function FetchSource(const AManifestURL: String): Boolean;

    /// <summary>
    /// Load a previously cached catalog source from a local JSON file and add
    /// it to the in-memory collection.
    /// </summary>
    /// <param name="ACachePath">Full path to the cached JSON file.</param>
    /// <returns>True on success, False if the file does not exist or is
    /// malformed.</returns>
    function LoadSourceFromCache(const ACachePath: String): Boolean;

    /// <summary>
    /// Write the raw JSON of a loaded source to a local cache file.
    /// </summary>
    /// <param name="ASourceID">Source ID string, e.g. "harpgamer".</param>
    /// <param name="ACachePath">Destination file path for the JSON cache.</param>
    procedure SaveSourceToCache(const ASourceID, ACachePath: String);

    /// <summary>Return all loaded catalog sources.</summary>
    /// <returns>Array of <see cref="THCLCatalogSource"/> records.</returns>
    function GetSources: THCLCatalogSourceArray;

    /// <summary>
    /// Return all top-level categories across all sources, optionally filtered
    /// to a single source.
    /// </summary>
    /// <param name="ASourceFilter">Source ID to restrict results, or empty for
    /// all sources.</param>
    /// <returns>Flat array of top-level <see cref="THCLCatalogCategory"/>
    /// records.</returns>
    function GetCategories(const ASourceFilter: String = ''): THCLCatalogCategoryArray;

    /// <summary>
    /// Find a file by its source-scoped ID (e.g. "harpgamer:235").
    /// </summary>
    /// <param name="AFileID">Source-scoped file identifier.</param>
    /// <returns>Matching <see cref="THCLCatalogFile"/> record, or a default
    /// (empty) record if not found.</returns>
    function FindFile(const AFileID: String): THCLCatalogFile;

    /// <summary>
    /// Search all loaded files for a case-insensitive substring match on
    /// the <c>Title</c> field.
    /// </summary>
    /// <param name="AQuery">Substring to search for.</param>
    /// <returns>Array of matching <see cref="THCLCatalogFile"/> records.</returns>
    function SearchFiles(const AQuery: String): THCLCatalogFileArray;

    /// <summary>
    /// Return all files whose <c>ArtifactType</c> exactly matches the given
    /// string (case-sensitive).
    /// </summary>
    /// <param name="AArtifactType">Artifact type tag, e.g. "BattleSet".</param>
    /// <returns>Array of matching <see cref="THCLCatalogFile"/> records.</returns>
    function GetFilesByArtifactType(const AArtifactType: String): THCLCatalogFileArray;

    /// <summary>
    /// Return the total count of all files across all sources and categories
    /// (recursive).
    /// </summary>
    /// <returns>Total file count.</returns>
    function GetTotalFileCount: Integer;
  end;

// ---------------------------------------------------------------------------
// Factory function
// ---------------------------------------------------------------------------

/// <summary>
/// Create and return a new <see cref="IHCLCatalog"/> instance.
/// </summary>
/// <returns>Reference-counted catalog manager.</returns>
function CreateCatalog: IHCLCatalog;

/// <summary>
/// The file as it would be downloaded in the chosen version.
/// </summary>
/// <param name="AFile">A catalog file.</param>
/// <param name="AIndex">-1 for the current version, else an index into
/// <c>AFile.Versions</c>.</param>
/// <returns>
/// For -1 (or an index out of range), <c>AFile</c> unchanged.  Otherwise a
/// copy whose download fields (FileName, Size, SHA256, DownloadUrl), Version,
/// VersionLabel and Updated describe that version, with
/// <c>IsPreviousVersion</c> set; title, type and dependencies are kept.
/// </returns>
function HCLSelectVersion(const AFile: THCLCatalogFile;
  AIndex: Integer): THCLCatalogFile;

/// <summary>
/// One line naming a version, for a version picker.
/// </summary>
/// <param name="AFile">A catalog file.</param>
/// <param name="AIndex">-1 for the current version, else an index into
/// <c>AFile.Versions</c>.</param>
/// <returns>e.g. "HCDB2-170909 (current, 2017-09-09)" or
/// "HCDB2-170611 (2017-06-11)"; '' for an index out of range.</returns>
function HCLVersionCaption(const AFile: THCLCatalogFile;
  AIndex: Integer): String;

// ---------------------------------------------------------------------------
implementation
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// THCLCatalog — private implementation class
// ---------------------------------------------------------------------------

type
  /// <summary>
  /// Concrete implementation of <see cref="IHCLCatalog"/>.
  /// </summary>
  THCLCatalog = class(TInterfacedObject, IHCLCatalog)
  private
    FSources: THCLCatalogSourceArray;

    // -----------------------------------------------------------------------
    // JSON helpers
    // -----------------------------------------------------------------------

    /// <summary>
    /// Return a string field from a JSON object by key, or ADefault if absent
    /// or null.
    /// </summary>
    function JSONStr(AObj: TJSONObject; const AKey, ADefault: String): String;

    /// <summary>
    /// Return an integer field from a JSON object by key, or ADefault.
    /// </summary>
    function JSONInt(AObj: TJSONObject; const AKey: String;
      ADefault: Integer): Integer;

    /// <summary>
    /// Return an Int64 field from a JSON object by key, or ADefault.
    /// </summary>
    function JSONInt64(AObj: TJSONObject; const AKey: String;
      ADefault: Int64): Int64;

    /// <summary>
    /// Return a float field from a JSON object by key, or ADefault.
    /// </summary>
    function JSONFloat(AObj: TJSONObject; const AKey: String;
      ADefault: Double): Double;

    /// <summary>
    /// Return a boolean field from a JSON object by key, or ADefault when
    /// absent, null, or not a JSON boolean.
    /// </summary>
    function JSONBool(AObj: TJSONObject; const AKey: String;
      ADefault: Boolean): Boolean;

    /// <summary>
    /// Parse an ISO 8601 date-time string ("YYYY-MM-DDTHH:MM:SSZ" or
    /// "YYYY-MM-DD") into a TDateTime (UTC).  Returns 0 on failure.
    /// </summary>
    function ParseISO8601(const S: String): TDateTime;

    // -----------------------------------------------------------------------
    // Parsing
    // -----------------------------------------------------------------------

    /// <summary>
    /// Parse a JSON dependencies sub-object into a
    /// <see cref="THCLDependencies"/> record.
    /// </summary>
    function ParseDependencies(AObj: TJSONObject): THCLDependencies;

    /// <summary>
    /// Parse a JSON file object into a <see cref="THCLCatalogFile"/> record.
    /// </summary>
    function ParseFile(AObj: TJSONObject): THCLCatalogFile;

    /// <summary>
    /// Parse a JSON category object (with optional nested subcategories) into a
    /// <see cref="THCLCatalogCategory"/> record.
    /// </summary>
    /// <param name="AObj">The category JSON object.</param>
    /// <param name="AParentArtifactType">Artifact type of the enclosing
    /// category; empty for a top-level category.</param>
    /// <returns>The parsed category.</returns>
    /// <remarks>
    /// Artifact types are inherited downwards: a category with no
    /// <c>artifactType</c> of its own takes its parent's, and a file with none
    /// takes its category's.  The live catalog only tags categories, so without
    /// this every file would arrive with an empty type and nothing downstream
    /// (cache placement, import) could classify it.  An explicit type on a file
    /// or subcategory always wins, so per-file classification by the generator
    /// still takes precedence.
    /// </remarks>
    function ParseCategory(AObj: TJSONObject;
      const AParentArtifactType: String = ''): THCLCatalogCategory;

    /// <summary>
    /// Parse the top-level JSON document into a <see cref="THCLCatalogSource"/>
    /// record.
    /// </summary>
    function ParseCatalogJSON(AJson: TJSONData): THCLCatalogSource;

    // -----------------------------------------------------------------------
    // Internal search / storage helpers
    // -----------------------------------------------------------------------

    /// <summary>
    /// Recursively collect all files in <c>ACat</c> and its subcategories,
    /// appending heap-allocated <c>PHCLCatalogFile</c> pointers to
    /// <c>AList</c>.  The caller is responsible for freeing them.
    /// </summary>
    procedure CollectFilesFromCategory(const ACat: THCLCatalogCategory;
      AList: TList);

    /// <summary>
    /// Recursively count all files in <c>ACat</c> and its subcategories.
    /// </summary>
    function CountFilesInCategory(const ACat: THCLCatalogCategory): Integer;

    /// <summary>
    /// Find the index of a source by its ID string; returns -1 if not found.
    /// </summary>
    function IndexOfSource(const ASourceID: String): Integer;

    /// <summary>
    /// Add or replace a source in FSources.  If a source with the same ID
    /// already exists it is replaced; otherwise the array is extended.
    /// </summary>
    procedure UpsertSource(const ASrc: THCLCatalogSource);

    /// <summary>
    /// Free all heap-allocated <c>PHCLCatalogFile</c> pointers stored in
    /// <c>AList</c> and clear the list.  Does NOT free the TList itself.
    /// </summary>
    procedure FreeFilePointers(AList: TList);

  public
    // IHCLCatalog
    function FetchSource(const AManifestURL: String): Boolean;
    function LoadSourceFromCache(const ACachePath: String): Boolean;
    procedure SaveSourceToCache(const ASourceID, ACachePath: String);
    function GetSources: THCLCatalogSourceArray;
    function GetCategories(const ASourceFilter: String = ''): THCLCatalogCategoryArray;
    function FindFile(const AFileID: String): THCLCatalogFile;
    function SearchFiles(const AQuery: String): THCLCatalogFileArray;
    function GetFilesByArtifactType(const AArtifactType: String): THCLCatalogFileArray;
    function GetTotalFileCount: Integer;
  end;

// ---------------------------------------------------------------------------
// JSON helpers
// ---------------------------------------------------------------------------

function THCLCatalog.JSONStr(AObj: TJSONObject; const AKey,
  ADefault: String): String;
var
  N: TJSONData;
begin
  Result := ADefault;
  if AObj = nil then Exit;
  N := AObj.Find(AKey);
  if (N <> nil) and (N.JSONType <> jtNull) then
    Result := N.AsString;
end;

function THCLCatalog.JSONInt(AObj: TJSONObject; const AKey: String;
  ADefault: Integer): Integer;
var
  N: TJSONData;
begin
  Result := ADefault;
  if AObj = nil then Exit;
  N := AObj.Find(AKey);
  if (N <> nil) and (N.JSONType <> jtNull) then
  try
    Result := N.AsInteger;
  except
    // swallow — return default
  end;
end;

function THCLCatalog.JSONInt64(AObj: TJSONObject; const AKey: String;
  ADefault: Int64): Int64;
var
  N: TJSONData;
begin
  Result := ADefault;
  if AObj = nil then Exit;
  N := AObj.Find(AKey);
  if (N <> nil) and (N.JSONType <> jtNull) then
  try
    Result := N.AsInt64;
  except
    // swallow — return default
  end;
end;

function THCLCatalog.JSONFloat(AObj: TJSONObject; const AKey: String;
  ADefault: Double): Double;
var
  N: TJSONData;
begin
  Result := ADefault;
  if AObj = nil then Exit;
  N := AObj.Find(AKey);
  if (N <> nil) and (N.JSONType <> jtNull) then
  try
    Result := N.AsFloat;
  except
    // swallow — return default
  end;
end;

function THCLCatalog.JSONBool(AObj: TJSONObject; const AKey: String;
  ADefault: Boolean): Boolean;
var
  N: TJSONData;
begin
  Result := ADefault;
  if AObj = nil then Exit;
  N := AObj.Find(AKey);
  // Only a real JSON boolean counts: "true" as a string or 1 as a number
  // is not taken as a flag.
  if (N <> nil) and (N.JSONType = jtBoolean) then
    Result := N.AsBoolean;
end;

function THCLCatalog.ParseISO8601(const S: String): TDateTime;
var
  Clean: String;
  Y, Mo, D, H, Mi, Sec: Integer;
  DotPos: Integer;
begin
  Result := 0;
  if S = '' then Exit;

  // Strip trailing 'Z' and any sub-second fraction (e.g. ".000Z")
  Clean := S;
  if (Length(Clean) > 0) and (Clean[Length(Clean)] = 'Z') then
    SetLength(Clean, Length(Clean) - 1);
  // Strip sub-second fraction
  DotPos := Pos('.', Clean);
  if DotPos > 0 then
    SetLength(Clean, DotPos - 1);

  // Try "YYYY-MM-DDTHH:MM:SS"
  if (Length(Clean) >= 19) and (Clean[5] = '-') and (Clean[8] = '-') and
     (Clean[11] = 'T') and (Clean[14] = ':') and (Clean[17] = ':') then
  begin
    try
      Y   := StrToInt(Copy(Clean,  1, 4));
      Mo  := StrToInt(Copy(Clean,  6, 2));
      D   := StrToInt(Copy(Clean,  9, 2));
      H   := StrToInt(Copy(Clean, 12, 2));
      Mi  := StrToInt(Copy(Clean, 15, 2));
      Sec := StrToInt(Copy(Clean, 18, 2));
      Result := EncodeDateTime(Y, Mo, D, H, Mi, Sec, 0);
    except
      Result := 0;
    end;
    Exit;
  end;

  // Try "YYYY-MM-DD"
  if (Length(Clean) >= 10) and (Clean[5] = '-') and (Clean[8] = '-') then
  begin
    try
      Y  := StrToInt(Copy(Clean, 1, 4));
      Mo := StrToInt(Copy(Clean, 6, 2));
      D  := StrToInt(Copy(Clean, 9, 2));
      Result := EncodeDate(Y, Mo, D);
    except
      Result := 0;
    end;
  end;
end;

// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------

function THCLCatalog.ParseDependencies(AObj: TJSONObject): THCLDependencies;
begin
  Result.PlatformDB       := JSONStr(AObj, 'platformDB',       '');
  Result.GameVersion      := JSONStr(AObj, 'gameVersion',      '');
  Result.BattleSet        := JSONStr(AObj, 'battleSet',        '');
  Result.BattleSetVariant := JSONStr(AObj, 'battleSetVariant', '');
end;

function THCLCatalog.ParseFile(AObj: TJSONObject): THCLCatalogFile;
var
  DepNode, VersNode, VerNode: TJSONData;
  V: THCLCatalogVersion;
  C: THCLCatalogCompanion;
  I: Integer;
begin
  Result.ID            := JSONStr  (AObj, 'id',            '');
  Result.Title         := JSONStr  (AObj, 'title',         '');
  Result.Version       := JSONStr  (AObj, 'version',       '');
  Result.Author        := JSONStr  (AObj, 'author',        '');
  Result.Size          := JSONInt64(AObj, 'size',          0);
  Result.Downloads     := JSONInt  (AObj, 'downloads',     0);
  Result.Updated       := JSONStr  (AObj, 'updated',       '');
  Result.Description   := JSONStr  (AObj, 'description',   '');
  Result.PageUrl       := JSONStr  (AObj, 'pageUrl',       '');
  Result.DownloadUrl   := JSONStr  (AObj, 'downloadUrl',   '');
  Result.FileName      := JSONStr  (AObj, 'fileName',      '');
  Result.SHA256        := JSONStr  (AObj, 'sha256',        '');
  Result.ScreenshotUrl := JSONStr  (AObj, 'screenshotUrl', '');
  Result.Rating        := JSONFloat(AObj, 'rating',        0.0);
  Result.ExternalLink  := JSONBool (AObj, 'externalLink',  False);
  Result.ArtifactType  := JSONStr  (AObj, 'artifactType',  '');
  Result.VersionLabel  := JSONStr  (AObj, 'versionLabel',  '');
  Result.IsPreviousVersion := False;

  SetLength(Result.Versions, 0);
  VersNode := AObj.Find('versions');
  if (VersNode <> nil) and (VersNode.JSONType = jtArray) then
    for I := 0 to TJSONArray(VersNode).Count - 1 do
    begin
      VerNode := TJSONArray(VersNode).Items[I];
      if VerNode.JSONType <> jtObject then
        Continue;
      V.Version      := JSONStr  (TJSONObject(VerNode), 'version',     '');
      V.VersionLabel := JSONStr  (TJSONObject(VerNode), 'label',       '');
      V.Updated      := JSONStr  (TJSONObject(VerNode), 'date',        '');
      V.Changelog    := JSONStr  (TJSONObject(VerNode), 'changelog',   '');
      V.FileName     := JSONStr  (TJSONObject(VerNode), 'fileName',    '');
      V.Size         := JSONInt64(TJSONObject(VerNode), 'size',        0);
      V.SHA256       := JSONStr  (TJSONObject(VerNode), 'sha256',      '');
      V.DownloadUrl  := JSONStr  (TJSONObject(VerNode), 'downloadUrl', '');
      // Without a link there is nothing to download, so nothing to offer.
      if V.DownloadUrl = '' then
        Continue;
      SetLength(Result.Versions, Length(Result.Versions) + 1);
      Result.Versions[High(Result.Versions)] := V;
    end;

  SetLength(Result.Companions, 0);
  VersNode := AObj.Find('companions');
  if (VersNode <> nil) and (VersNode.JSONType = jtArray) then
    for I := 0 to TJSONArray(VersNode).Count - 1 do
    begin
      VerNode := TJSONArray(VersNode).Items[I];
      if VerNode.JSONType <> jtObject then
        Continue;
      C.ID       := Trim(JSONStr(TJSONObject(VerNode), 'id',       ''));
      C.Provides := Trim(JSONStr(TJSONObject(VerNode), 'provides', ''));
      // A bare file name only: it is placed by this name, so a path in it
      // could write outside the database directory.
      if (C.ID = '') or (C.Provides = '') or (C.Provides = '.') or (C.Provides = '..') or
         (Pos('/', C.Provides) > 0) or (Pos('\', C.Provides) > 0) or (Pos(':', C.Provides) > 0) then
        Continue;
      SetLength(Result.Companions, Length(Result.Companions) + 1);
      Result.Companions[High(Result.Companions)] := C;
    end;

  // Initialise dependency record fields to empty before optional parse
  Result.Dependencies.PlatformDB       := '';
  Result.Dependencies.GameVersion      := '';
  Result.Dependencies.BattleSet        := '';
  Result.Dependencies.BattleSetVariant := '';

  DepNode := AObj.Find('dependencies');
  if (DepNode <> nil) and (DepNode.JSONType = jtObject) then
    Result.Dependencies := ParseDependencies(TJSONObject(DepNode));
end;

function THCLCatalog.ParseCategory(AObj: TJSONObject;
  const AParentArtifactType: String = ''): THCLCatalogCategory;
var
  FilesNode, SubcatsNode: TJSONData;
  FilesArr, SubcatsArr: TJSONArray;
  I: Integer;
begin
  Result.ID           := JSONStr(AObj, 'id',           '');
  Result.Name         := JSONStr(AObj, 'name',         '');
  Result.ParentID     := JSONStr(AObj, 'parentId',     '');
  Result.ArtifactType := JSONStr(AObj, 'artifactType', '');
  if Result.ArtifactType = '' then
    Result.ArtifactType := AParentArtifactType;

  SetLength(Result.Files, 0);
  FilesNode := AObj.Find('files');
  if (FilesNode <> nil) and (FilesNode.JSONType = jtArray) then
  begin
    FilesArr := TJSONArray(FilesNode);
    SetLength(Result.Files, FilesArr.Count);
    for I := 0 to FilesArr.Count - 1 do
    begin
      if FilesArr.Items[I].JSONType = jtObject then
        Result.Files[I] := ParseFile(TJSONObject(FilesArr.Items[I]))
      else
        Result.Files[I] := Default(THCLCatalogFile);

      if Result.Files[I].ArtifactType = '' then
        Result.Files[I].ArtifactType := Result.ArtifactType;
    end;
  end;

  SetLength(Result.Subcategories, 0);
  SubcatsNode := AObj.Find('subcategories');
  if (SubcatsNode <> nil) and (SubcatsNode.JSONType = jtArray) then
  begin
    SubcatsArr := TJSONArray(SubcatsNode);
    SetLength(Result.Subcategories, SubcatsArr.Count);
    for I := 0 to SubcatsArr.Count - 1 do
    begin
      if SubcatsArr.Items[I].JSONType = jtObject then
        Result.Subcategories[I] := ParseCategory(TJSONObject(SubcatsArr.Items[I]),
          Result.ArtifactType)
      else
        Result.Subcategories[I] := Default(THCLCatalogCategory);
    end;
  end;
end;

function THCLCatalog.ParseCatalogJSON(AJson: TJSONData): THCLCatalogSource;
var
  Root: TJSONObject;
  SrcNode: TJSONData;
  SourceObj: TJSONObject;
  CatsNode: TJSONData;
  CatsArr: TJSONArray;
  GenStr: String;
  I: Integer;
begin
  Result := Default(THCLCatalogSource);

  if (AJson = nil) or (AJson.JSONType <> jtObject) then Exit;
  Root := TJSONObject(AJson);

  // "source" sub-object
  SourceObj := nil;
  SrcNode := Root.Find('source');
  if (SrcNode <> nil) and (SrcNode.JSONType = jtObject) then
    SourceObj := TJSONObject(SrcNode);

  Result.ID             := JSONStr(SourceObj, 'id',             '');
  Result.Name           := JSONStr(SourceObj, 'name',           '');
  Result.Url            := JSONStr(SourceObj, 'url',            '');
  Result.Contact        := JSONStr(SourceObj, 'contact',        '');
  Result.DownloadMethod := JSONStr(SourceObj, 'downloadMethod', 'direct');

  // "generated" at root level
  GenStr := JSONStr(Root, 'generated', '');
  Result.Generated := ParseISO8601(GenStr);

  // "categories" array
  SetLength(Result.Categories, 0);
  CatsNode := Root.Find('categories');
  if (CatsNode <> nil) and (CatsNode.JSONType = jtArray) then
  begin
    CatsArr := TJSONArray(CatsNode);
    SetLength(Result.Categories, CatsArr.Count);
    for I := 0 to CatsArr.Count - 1 do
    begin
      if CatsArr.Items[I].JSONType = jtObject then
        Result.Categories[I] := ParseCategory(TJSONObject(CatsArr.Items[I]))
      else
        Result.Categories[I] := Default(THCLCatalogCategory);
    end;
  end;
end;

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

procedure THCLCatalog.CollectFilesFromCategory(const ACat: THCLCatalogCategory;
  AList: TList);
var
  I: Integer;
  FP: PHCLCatalogFile;
begin
  for I := 0 to High(ACat.Files) do
  begin
    New(FP);
    FP^ := ACat.Files[I];
    AList.Add(FP);
  end;
  for I := 0 to High(ACat.Subcategories) do
    CollectFilesFromCategory(ACat.Subcategories[I], AList);
end;

function THCLCatalog.CountFilesInCategory(const ACat: THCLCatalogCategory): Integer;
var
  I: Integer;
begin
  Result := Length(ACat.Files);
  for I := 0 to High(ACat.Subcategories) do
    Inc(Result, CountFilesInCategory(ACat.Subcategories[I]));
end;

function THCLCatalog.IndexOfSource(const ASourceID: String): Integer;
var
  I: Integer;
begin
  Result := -1;
  for I := 0 to High(FSources) do
    if FSources[I].ID = ASourceID then
    begin
      Result := I;
      Exit;
    end;
end;

procedure THCLCatalog.UpsertSource(const ASrc: THCLCatalogSource);
var
  Idx: Integer;
begin
  Idx := IndexOfSource(ASrc.ID);
  if Idx >= 0 then
    FSources[Idx] := ASrc
  else
  begin
    SetLength(FSources, Length(FSources) + 1);
    FSources[High(FSources)] := ASrc;
  end;
end;

procedure THCLCatalog.FreeFilePointers(AList: TList);
var
  I: Integer;
begin
  for I := 0 to AList.Count - 1 do
    Dispose(PHCLCatalogFile(AList[I]));
  AList.Clear;
end;

// ---------------------------------------------------------------------------
// IHCLCatalog — FetchSource
// ---------------------------------------------------------------------------

function THCLCatalog.FetchSource(const AManifestURL: String): Boolean;
var
  Client: TFPHTTPClient;
  ResponseStream: TStringStream;
  ResponseCode: Integer;
  JsonData: TJSONData;
  Src: THCLCatalogSource;
  ExistingIdx: Integer;
begin
  Result := False;
  Client := TFPHTTPClient.Create(nil);
  ResponseStream := TStringStream.Create('');
  try
    try
      Client.AddHeader('User-Agent', 'HCLauncher/1.0');
      Client.AddHeader('X-HCL-Version', '1.0');

      // If we already have this source, send If-Modified-Since using the
      // Generated timestamp from the previously loaded copy.
      for ExistingIdx := 0 to High(FSources) do
        if FSources[ExistingIdx].Url = AManifestURL then
        begin
          if FSources[ExistingIdx].Generated <> 0 then
            Client.AddHeader('If-Modified-Since',
              FormatDateTime('ddd, dd mmm yyyy hh:nn:ss',
                FSources[ExistingIdx].Generated) + ' GMT');
          Break;
        end;

      Client.Get(AManifestURL, ResponseStream);
      ResponseCode := Client.ResponseStatusCode;

      // 304 Not Modified — cached data is still current
      if ResponseCode = 304 then
      begin
        Result := True;
        Exit;
      end;

      // Any non-2xx is a failure
      if (ResponseCode < 200) or (ResponseCode > 299) then
        Exit;

      JsonData := GetJSON(ResponseStream.DataString);
      try
        Src := ParseCatalogJSON(JsonData);
      finally
        JsonData.Free;
      end;

      Src.RawJSON := ResponseStream.DataString;

      // Preserve the manifest URL in Url if the source JSON omitted it
      if Src.Url = '' then
        Src.Url := AManifestURL;

      UpsertSource(Src);
      Result := True;

    except
      on E: Exception do
        Result := False;
    end;
  finally
    ResponseStream.Free;
    Client.Free;
  end;
end;

// ---------------------------------------------------------------------------
// IHCLCatalog — LoadSourceFromCache
// ---------------------------------------------------------------------------

function THCLCatalog.LoadSourceFromCache(const ACachePath: String): Boolean;
var
  SL: TStringList;
  JsonText: String;
  JsonData: TJSONData;
  Src: THCLCatalogSource;
begin
  Result := False;
  if not FileExists(ACachePath) then Exit;

  SL := TStringList.Create;
  try
    try
      SL.LoadFromFile(ACachePath);
      JsonText := SL.Text;

      JsonData := GetJSON(JsonText);
      try
        Src := ParseCatalogJSON(JsonData);
      finally
        JsonData.Free;
      end;

      Src.RawJSON := JsonText;
      UpsertSource(Src);
      Result := True;

    except
      on E: Exception do
        Result := False;
    end;
  finally
    SL.Free;
  end;
end;

// ---------------------------------------------------------------------------
// IHCLCatalog — SaveSourceToCache
// ---------------------------------------------------------------------------

procedure THCLCatalog.SaveSourceToCache(const ASourceID, ACachePath: String);
var
  Idx: Integer;
  SL: TStringList;
begin
  Idx := IndexOfSource(ASourceID);
  if Idx < 0 then Exit;
  if FSources[Idx].RawJSON = '' then Exit;

  SL := TStringList.Create;
  try
    SL.Text := FSources[Idx].RawJSON;
    SL.SaveToFile(ACachePath);
  finally
    SL.Free;
  end;
end;

// ---------------------------------------------------------------------------
// IHCLCatalog — GetSources
// ---------------------------------------------------------------------------

function THCLCatalog.GetSources: THCLCatalogSourceArray;
begin
  Result := FSources;
end;

// ---------------------------------------------------------------------------
// IHCLCatalog — GetCategories
// ---------------------------------------------------------------------------

function THCLCatalog.GetCategories(const ASourceFilter: String = ''): THCLCatalogCategoryArray;
var
  SrcI, CatI, Total, Idx: Integer;
begin
  Result := nil;
  // Count total top-level categories across selected sources
  Total := 0;
  for SrcI := 0 to High(FSources) do
    if (ASourceFilter = '') or (FSources[SrcI].ID = ASourceFilter) then
      Inc(Total, Length(FSources[SrcI].Categories));

  SetLength(Result, Total);
  Idx := 0;
  for SrcI := 0 to High(FSources) do
    if (ASourceFilter = '') or (FSources[SrcI].ID = ASourceFilter) then
      for CatI := 0 to High(FSources[SrcI].Categories) do
      begin
        Result[Idx] := FSources[SrcI].Categories[CatI];
        Inc(Idx);
      end;
end;

// ---------------------------------------------------------------------------
// IHCLCatalog — FindFile
// ---------------------------------------------------------------------------

function THCLCatalog.FindFile(const AFileID: String): THCLCatalogFile;
var
  AllFiles: TList;
  I, SrcI, CatI: Integer;
  FP: PHCLCatalogFile;
begin
  Result := Default(THCLCatalogFile); // initialise all string fields to ''
  AllFiles := TList.Create;
  try
    for SrcI := 0 to High(FSources) do
      for CatI := 0 to High(FSources[SrcI].Categories) do
        CollectFilesFromCategory(FSources[SrcI].Categories[CatI], AllFiles);

    for I := 0 to AllFiles.Count - 1 do
    begin
      FP := PHCLCatalogFile(AllFiles[I]);
      if FP^.ID = AFileID then
      begin
        Result := FP^;
        Break;
      end;
    end;
  finally
    FreeFilePointers(AllFiles);
    AllFiles.Free;
  end;
end;

// ---------------------------------------------------------------------------
// IHCLCatalog — SearchFiles
// ---------------------------------------------------------------------------

function THCLCatalog.SearchFiles(const AQuery: String): THCLCatalogFileArray;
var
  AllFiles, Matches: TList;
  I, SrcI, CatI: Integer;
  FP: PHCLCatalogFile;
  LQuery: String;
begin
  Result := nil;
  LQuery := LowerCase(AQuery);
  AllFiles := TList.Create;
  Matches := TList.Create;
  try
    for SrcI := 0 to High(FSources) do
      for CatI := 0 to High(FSources[SrcI].Categories) do
        CollectFilesFromCategory(FSources[SrcI].Categories[CatI], AllFiles);

    for I := 0 to AllFiles.Count - 1 do
    begin
      FP := PHCLCatalogFile(AllFiles[I]);
      if Pos(LQuery, LowerCase(FP^.Title)) > 0 then
        Matches.Add(FP);
    end;

    SetLength(Result, Matches.Count);
    for I := 0 to Matches.Count - 1 do
      Result[I] := PHCLCatalogFile(Matches[I])^;

  finally
    Matches.Free; // does not own the pointers — AllFiles does
    FreeFilePointers(AllFiles);
    AllFiles.Free;
  end;
end;

// ---------------------------------------------------------------------------
// IHCLCatalog — GetFilesByArtifactType
// ---------------------------------------------------------------------------

function THCLCatalog.GetFilesByArtifactType(
  const AArtifactType: String): THCLCatalogFileArray;
var
  AllFiles, Matches: TList;
  I, SrcI, CatI: Integer;
  FP: PHCLCatalogFile;
begin
  Result := nil;
  AllFiles := TList.Create;
  Matches := TList.Create;
  try
    for SrcI := 0 to High(FSources) do
      for CatI := 0 to High(FSources[SrcI].Categories) do
        CollectFilesFromCategory(FSources[SrcI].Categories[CatI], AllFiles);

    for I := 0 to AllFiles.Count - 1 do
    begin
      FP := PHCLCatalogFile(AllFiles[I]);
      if FP^.ArtifactType = AArtifactType then
        Matches.Add(FP);
    end;

    SetLength(Result, Matches.Count);
    for I := 0 to Matches.Count - 1 do
      Result[I] := PHCLCatalogFile(Matches[I])^;

  finally
    Matches.Free; // does not own the pointers — AllFiles does
    FreeFilePointers(AllFiles);
    AllFiles.Free;
  end;
end;

// ---------------------------------------------------------------------------
// IHCLCatalog — GetTotalFileCount
// ---------------------------------------------------------------------------

function THCLCatalog.GetTotalFileCount: Integer;
var
  SrcI, CatI: Integer;
begin
  Result := 0;
  for SrcI := 0 to High(FSources) do
    for CatI := 0 to High(FSources[SrcI].Categories) do
      Inc(Result, CountFilesInCategory(FSources[SrcI].Categories[CatI]));
end;

// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------

function CreateCatalog: IHCLCatalog;
begin
  Result := THCLCatalog.Create;
end;

// ---------------------------------------------------------------------------
// Versions
// ---------------------------------------------------------------------------

function HCLSelectVersion(const AFile: THCLCatalogFile;
  AIndex: Integer): THCLCatalogFile;
var
  V: THCLCatalogVersion;
begin
  Result := AFile;
  if (AIndex < 0) or (AIndex > High(AFile.Versions)) then
    Exit;
  V := AFile.Versions[AIndex];
  Result.Version      := V.Version;
  Result.VersionLabel := V.VersionLabel;
  Result.Updated      := V.Updated;
  Result.FileName     := V.FileName;
  Result.Size         := V.Size;
  Result.SHA256       := V.SHA256;
  Result.DownloadUrl  := V.DownloadUrl;
  // An old version is a file, never a link elsewhere.
  Result.ExternalLink := False;
  Result.IsPreviousVersion := True;
end;

function HCLVersionCaption(const AFile: THCLCatalogFile;
  AIndex: Integer): String;
var
  LName, LDate: String;
begin
  if AIndex = -1 then
  begin
    LName := AFile.VersionLabel;
    if LName = '' then
      LName := AFile.Version;
    LDate := AFile.Updated;
    if LName = '' then
      Result := 'Current version'
    else
      Result := LName + ' (current';
    if LDate <> '' then
    begin
      if LName = '' then
        Result := Result + ' (' + LDate + ')'
      else
        Result := Result + ', ' + LDate + ')';
    end
    else if LName <> '' then
      Result := Result + ')';
    Exit;
  end;

  if (AIndex < 0) or (AIndex > High(AFile.Versions)) then
    Exit('');
  LName := AFile.Versions[AIndex].VersionLabel;
  if LName = '' then
    LName := AFile.Versions[AIndex].Version;
  if LName = '' then
    LName := 'Earlier version';
  LDate := AFile.Versions[AIndex].Updated;
  if LDate <> '' then
    Result := LName + ' (' + LDate + ')'
  else
    Result := LName;
end;

end.
