unit uHCLImport;

{$mode objfpc}{$H+}

/// <summary>
/// uHCLImport — places a downloaded archive into the managed file tree.
///
/// Sits between <c>uHCLArchive</c> (which knows how to unpack a ZIP safely but
/// nothing about HC) and <c>uHCLFileOrganization</c> (which knows the managed
/// directory layout but nothing about archives).  This unit supplies the
/// policy: which category an artifact type belongs to, what the artifact's
/// directory should be called, whether the user must first nominate a parent
/// BattleSet or PlatformDB or an ExportDLL target, and which of the archive's
/// leading directories are redundant packaging to be stripped.
///
/// Zero UI dependency — no LCL units are referenced — so the whole mapping is
/// independently testable.
/// </summary>
///
/// <remarks>
/// Import is deliberately split into two calls.  <see cref="IHCLImport.PlanImport"/>
/// is pure: it resolves a destination and reports what is still missing,
/// without touching the disk.  The caller can show that plan to the user (a
/// scenario needs a parent chosen, for instance) before
/// <see cref="IHCLImport.Execute"/> creates anything.  Keeping the decision
/// separate from the side effect is what lets the UI prompt sensibly and lets
/// the tests assert on destinations without extracting.
///
/// Redundant wrapper directories are removed by passing a strip prefix to
/// <c>IHCLArchive.Extract</c>, so every file is written straight to its final
/// path.  An earlier version moved files up after extraction instead; that
/// could not tell the archive's files from ones already in a shared directory
/// such as <c>ExportDLLs\GE\</c>, and could not merge into an existing
/// directory of the same name.
/// </remarks>

interface

uses
  SysUtils, Classes, uHCLArchive, uHCLFileOrganization, uHCLCatalog;

// ---------------------------------------------------------------------------
// Artifact type tags and directory names
// ---------------------------------------------------------------------------

const
  /// <summary>Artifact type tags emitted by the server-side catalog generator.</summary>
  HCL_ARTIFACT_PLATFORMDB = 'PlatformDB';
  HCL_ARTIFACT_SCENARIO   = 'Scenario';
  HCL_ARTIFACT_BATTLESET  = 'BattleSet';
  HCL_ARTIFACT_EXPORTDLL  = 'ExportDLL';
  HCL_ARTIFACT_SOUND      = 'Sound';
  HCL_ARTIFACT_GRAPHIC    = 'Graphic';
  HCL_ARTIFACT_MAP        = 'Map';
  HCL_ARTIFACT_TOOL       = 'Tool';

  /// <summary>GE-only ExportDLL subdirectory, mirroring the HC install.</summary>
  HCL_EXPORTDLL_GE_DIR = 'GE';
  /// <summary>SE-only ExportDLL subdirectory, mirroring the HC install.</summary>
  HCL_EXPORTDLL_SE_DIR = 'SE';

  /// <summary>PlatformDB annex subdirectory.</summary>
  HCL_PLATFORMDB_RES_DIR = 'res';
  /// <summary>PlatformDB resource-bitmap subdirectory.</summary>
  HCL_PLATFORMDB_RSR_DIR = 'rsr';

  /// <summary>A compiled platform database: the annex data.</summary>
  HCL_COMMONDB_RES = 'commondb.res';
  /// <summary>A compiled platform database's pictures ("media file"), often shipped separately.</summary>
  HCL_COMMONDB_RSR = 'commondb.rsr';

  /// <summary>Directory name used when an artifact yields no usable name.</summary>
  HCL_UNNAMED_ARTIFACT = 'Unnamed';

  /// <summary>Upper bound on a generated directory name, well inside MAX_PATH budget.</summary>
  HCL_MAX_DIRNAME_LEN = 96;

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

type
  /// <summary>
  /// Shape of the destination an artifact type resolves to.
  /// </summary>
  THCLImportKind = (
    ikUnknown,       ///< Artifact type not recognised; no destination.
    ikCategoryRoot,  ///< Extract into a shared directory: the category root, or ExportDLLs GE\ or SE\.
    ikNamedArtifact, ///< Extract into a per-artifact subdirectory.
    ikScenario       ///< Extract under a parent BattleSet or PlatformDB.
  );

  /// <summary>
  /// Which program(s) an ExportDLL is installed for.  HC loads DLLs from
  /// <c>ExportDLLs\</c> (both GE and SE), <c>ExportDLLs\GE\</c> and
  /// <c>ExportDLLs\SE\</c>, and the managed tree mirrors that split.
  /// </summary>
  THCLDLLTarget = (
    dtUnspecified, ///< Not yet known; the caller must ask.
    dtAsPackaged,  ///< The archive carries its own GE\ / SE\ layout; install at the root as packaged.
    dtCommon,      ///< ExportDLLs\ — loaded by both the GE and the SE.
    dtGE,          ///< ExportDLLs\GE\ — GE only.
    dtSE           ///< ExportDLLs\SE\ — SE only.
  );

  /// <summary>
  /// A resolved (or blocked) import destination.  Produced by
  /// <see cref="IHCLImport.PlanImport"/>; never touches the disk.
  /// </summary>
  THCLImportPlan = record
    /// <summary>True when <c>DestDir</c> is resolved and Execute may proceed.</summary>
    Valid: Boolean;
    /// <summary>Managed category the artifact belongs to.</summary>
    Category: THCLCategory;
    /// <summary>Destination shape.</summary>
    Kind: THCLImportKind;
    /// <summary>Sanitised per-artifact directory name; empty for shared-directory imports.</summary>
    ArtifactName: String;
    /// <summary>Fully resolved destination directory; empty when not valid.</summary>
    DestDir: String;
    /// <summary>True when a parent BattleSet or PlatformDB must be nominated first.</summary>
    RequiresParent: Boolean;
    /// <summary>Parent BattleSet name or PlatformDB version, when supplied.</summary>
    ParentName: String;
    /// <summary>True when the parent is a PlatformDB rather than a BattleSet.</summary>
    IsCommonDB: Boolean;
    /// <summary>True when an ExportDLL target must be chosen first.</summary>
    RequiresDLLTarget: Boolean;
    /// <summary>ExportDLL target the plan was resolved for; <c>dtUnspecified</c> otherwise.</summary>
    DLLTarget: THCLDLLTarget;
    /// <summary>True when redundant leading wrapper directories should be stripped.</summary>
    StripWrapper: Boolean;
    /// <summary>
    /// When <c>OnlyUnderPrefix</c> is set: the archive directory to take the
    /// files from (its own path is stripped); '' = the archive's top level.
    /// </summary>
    EntryPrefix: String;
    /// <summary>
    /// True to extract only <c>EntryPrefix</c>'s files, as for one database of
    /// several in a collection (<see cref="IHCLImport.PlanBundledDB"/>).
    /// </summary>
    OnlyUnderPrefix: Boolean;
    /// <summary>Explanation when <c>Valid</c> is False.</summary>
    Reason: String;
  end;

  /// <summary>
  /// A compiled platform database found inside an archive: a directory
  /// holding <c>commondb.res</c>.
  /// </summary>
  THCLBundledDB = record
    /// <summary>Archive-relative directory, '' for the top level.</summary>
    Prefix: String;
    /// <summary>
    /// Its name: the stem of a dated file beside it (a collection's manual,
    /// <c>HC-80to95-DB_110101.pdf</c>, names its build), else the directory
    /// name; '' for a top-level database with neither.
    /// </summary>
    Name: String;
    /// <summary>True when the directory also holds <c>commondb.rsr</c>.</summary>
    HasRsr: Boolean;
  end;

  /// <summary>Databases found in one archive, in archive order.</summary>
  THCLBundledDBArray = array of THCLBundledDB;

  /// <summary>
  /// Outcome of an <see cref="IHCLImport.Execute"/> call.
  /// </summary>
  THCLImportResult = record
    /// <summary>True when the archive was placed successfully.</summary>
    Success: Boolean;
    /// <summary>Directory the artifact was imported into.</summary>
    DestDir: String;
    /// <summary>Count of files written.</summary>
    FilesExtracted: Integer;
    /// <summary>Total bytes written.</summary>
    BytesExtracted: Int64;
    /// <summary>Count of existing files left alone under <c>omSkip</c>.</summary>
    FilesSkipped: Integer;
    /// <summary>True when a redundant wrapper directory was stripped.</summary>
    WrapperStripped: Boolean;
    /// <summary>Archive-relative prefix that was stripped; empty when none.</summary>
    StrippedPrefix: String;
    /// <summary>Entries rejected by the archive path-safety check.</summary>
    UnsafeEntries: TStringArray;
    /// <summary>Human-readable error text; empty on success.</summary>
    ErrorMessage: String;
  end;

  /// <summary>
  /// Resolves and performs imports of downloaded archives into the managed
  /// directory tree.
  /// </summary>
  IHCLImport = interface
    ['{2D84A7F0-91C6-4B3E-8A57-0E6C3B9D1F42}']

    /// <summary>
    /// Works out where a catalog file should be placed, without creating
    /// anything.
    /// </summary>
    /// <param name="AFile">Catalog entry describing the artifact.</param>
    /// <param name="AParentName">Parent BattleSet name or PlatformDB version
    /// for scenario imports; ignored for other artifact types.</param>
    /// <param name="AParentIsCommonDB">True when <paramref name="AParentName"/>
    /// names a PlatformDB rather than a BattleSet.</param>
    /// <param name="ADLLTarget">Install target for ExportDLL imports; ignored
    /// for other artifact types.  Use <see cref="HCLDetectDLLTarget"/> to find
    /// out whether the archive already answers the question.</param>
    /// <returns>A plan whose <c>Valid</c> field says whether Execute may run,
    /// and whose <c>RequiresParent</c> / <c>RequiresDLLTarget</c> fields say
    /// what the caller must ask the user first.</returns>
    function PlanImport(const AFile: THCLCatalogFile;
      const AParentName: String = '';
      AParentIsCommonDB: Boolean = False;
      ADLLTarget: THCLDLLTarget = dtUnspecified): THCLImportPlan;

    /// <summary>
    /// Works out where one database of an archive holding several
    /// (<see cref="HCLFindBundledDBs"/>) should go, without creating anything.
    /// </summary>
    /// <param name="AFile">Catalog entry the archive came from.</param>
    /// <param name="ADB">The database to place.</param>
    /// <returns>A plan for <c>PlatformDBs\&lt;name&gt;\</c> that extracts only
    /// that database's directory.  Invalid when it has no name.</returns>
    function PlanBundledDB(const AFile: THCLCatalogFile;
      const ADB: THCLBundledDB): THCLImportPlan;

    /// <summary>
    /// Creates the destination and extracts the archive into it.
    /// </summary>
    /// <param name="APlan">A plan returned by <see cref="PlanImport"/>.</param>
    /// <param name="AArchivePath">Full path to the downloaded <c>.zip</c>.</param>
    /// <param name="AOverwrite">Policy for files that already exist.</param>
    /// <param name="AOnProgress">Optional per-entry progress callback; may be nil.</param>
    /// <returns>A populated <see cref="THCLImportResult"/>.</returns>
    function Execute(const APlan: THCLImportPlan; const AArchivePath: String;
      AOverwrite: THCLOverwriteMode;
      AOnProgress: THCLExtractProgressEvent): THCLImportResult;
  end;

// ---------------------------------------------------------------------------
// Factory and helpers
// ---------------------------------------------------------------------------

/// <summary>Creates an importer bound to a file organization and archive handler.</summary>
/// <param name="AFileOrg">Managed directory tree to import into.</param>
/// <param name="AArchive">Archive handler used for extraction.</param>
/// <returns>A reference-counted <see cref="IHCLImport"/>.</returns>
function CreateImport(const AFileOrg: IHCLFileOrganization;
  const AArchive: IHCLArchive): IHCLImport;

/// <summary>
/// Maps a catalog artifact type tag onto a managed category.
/// </summary>
/// <param name="AArtifactType">Tag such as <c>BattleSet</c> or <c>Sound</c>.</param>
/// <param name="ACategory">Receives the category on success.</param>
/// <returns>True when the tag is recognised.  Comparison is case-insensitive.</returns>
/// <remarks>
/// <c>Scenario</c> maps to <c>hclBattleSets</c> here because a scenario with no
/// PlatformDB dependency belongs under a BattleSet; scenarios that declare a
/// PlatformDB are redirected to <c>hclPlatformDBs</c> by <c>PlanImport</c>,
/// which has the dependency metadata this function does not.
/// </remarks>
function HCLArtifactTypeToCategory(const AArtifactType: String;
  out ACategory: THCLCategory): Boolean;

/// <summary>
/// Tests whether a category may receive imported archives at all.
/// </summary>
/// <param name="ACategory">Category to test.</param>
/// <returns>False for <c>hclLogs</c>, which the GE writes and nothing imports;
/// True for every other category.</returns>
/// <remarks>
/// No catalog tag maps to <c>hclLogs</c> today, so this guard in
/// <c>PlanImport</c> only fires if that mapping changes.  Stating the rule as a
/// predicate keeps it testable rather than leaving it as an unreachable branch.
/// </remarks>
function HCLCategoryAcceptsImports(ACategory: THCLCategory): Boolean;

/// <summary>
/// Converts an arbitrary title into a directory name that is safe on Windows.
/// </summary>
/// <param name="AName">Proposed name, typically a catalog file title.</param>
/// <returns>A sanitised name, never empty and never a reserved device name.</returns>
/// <remarks>
/// Replaces the characters Windows forbids in a path component, collapses runs
/// of underscores, strips trailing dots and spaces (which Windows silently
/// drops, producing a directory whose name does not match what was asked for),
/// caps the length, and renames reserved device names such as <c>CON</c> and
/// <c>LPT1</c>, which cannot exist as directories at all.
/// </remarks>
function HCLSanitizeDirName(const AName: String): String;

/// <summary>
/// The name an artifact's directory takes, allowing for an earlier version
/// chosen with <c>HCLSelectVersion</c>.
/// </summary>
/// <param name="ABaseName">The name the current version's directory takes.</param>
/// <param name="AFile">The file being imported.</param>
/// <returns>
/// <c>ABaseName</c> for the current version.  For an earlier version, a name
/// of its own so it sits beside the current one rather than over it: the
/// version label when it names the artifact by itself (it contains a letter,
/// as "HCDB2-170611" does), else <c>ABaseName</c> plus the label ("Toolbox 1.2").
/// </returns>
function HCLVersionedName(const ABaseName: String;
  const AFile: THCLCatalogFile): String;

/// <summary>
/// Works out which leading directories of an archive are redundant packaging
/// for the given destination, and should be stripped on extraction.
/// </summary>
/// <param name="AEntries">Entries as returned by <c>IHCLArchive.ListEntries</c>.</param>
/// <param name="APlan">A valid plan from <c>PlanImport</c>.</param>
/// <returns>An archive-relative prefix such as <c>GIUK-v1.2</c> or
/// <c>Pack\ExportDLLs</c>, or an empty string when nothing should be stripped
/// (or <c>APlan.StripWrapper</c> is False).</returns>
/// <remarks>
/// A level is only ever considered when every file in the archive sits
/// beneath one common directory.  That directory is then:
/// <list type="bullet">
/// <item>kept, if it is structural for the destination — <c>GE</c>/<c>SE</c>
/// for ExportDLLs, <c>res</c>/<c>rsr</c>/<c>Scenarios</c> for a PlatformDB,
/// <c>Scenarios</c> for a BattleSet, <c>SavedGames</c> for a scenario;</item>
/// <item>stripped, if it merely repeats the destination — the category name,
/// the artifact's own name, or <c>GE</c>/<c>SE</c> when that is the chosen
/// target — and the next level is examined in turn;</item>
/// <item>otherwise stripped once as a release wrapper, but only at the top
/// level, so a genuine subdirectory further down is never eaten.</item>
/// </list>
/// </remarks>
function HCLResolveWrapperPrefix(const AEntries: THCLArchiveEntryArray;
  const APlan: THCLImportPlan): String;

/// <summary>
/// Reports whether an ExportDLL archive already says where its DLLs belong.
/// </summary>
/// <param name="AEntries">Entries as returned by <c>IHCLArchive.ListEntries</c>.</param>
/// <returns><c>dtAsPackaged</c> when, beneath any wrapper, the archive places
/// files in a <c>GE</c> or <c>SE</c> directory; otherwise <c>dtUnspecified</c>,
/// meaning the user has to be asked.</returns>
function HCLDetectDLLTarget(const AEntries: THCLArchiveEntryArray): THCLDLLTarget;

/// <summary>
/// Finds the compiled platform databases in an archive.
/// </summary>
/// <param name="AEntries">Entries as returned by <c>IHCLArchive.ListEntries</c>.</param>
/// <returns>One item per directory holding <c>commondb.res</c> (compared
/// case-insensitively); empty when there are none.  More than one means a
/// collection, such as René Haar's hc-timelines-db, to be split into one
/// PlatformDB each.  Empty directories are ignored.</returns>
function HCLFindBundledDBs(const AEntries: THCLArchiveEntryArray): THCLBundledDBArray;

/// <summary>
/// Tests whether a PlatformDB directory still lacks its pictures.
/// </summary>
/// <param name="ADir">An imported PlatformDB directory.</param>
/// <returns>True when it has <c>commondb.res</c> but neither
/// <c>commondb.rsr</c> nor an <c>rsr\</c> directory.</returns>
function HCLPlatformDBNeedsRsr(const ADir: String): Boolean;

/// <summary>
/// Copies one file out of a companion archive into each directory that
/// lacks it, such as the shared <c>commondb.rsr</c> a set of databases needs.
/// </summary>
/// <param name="AArchive">Archive handler.</param>
/// <param name="ACompanionZip">The downloaded companion archive.</param>
/// <param name="AFileName">File to place, e.g. <c>commondb.rsr</c>; found at
/// any depth in the archive, compared case-insensitively.</param>
/// <param name="AStagingDir">Where the file is extracted once before copying;
/// kept, so a later import copies from it without extracting again.</param>
/// <param name="ATargetDirs">Directories to receive a copy.  One that already
/// has the file is left alone.</param>
/// <param name="AError">Why nothing (or not everything) was placed.</param>
/// <returns>How many directories received a copy.</returns>
function HCLPlaceCompanionFile(const AArchive: IHCLArchive;
  const ACompanionZip, AFileName, AStagingDir: String;
  const ATargetDirs: array of String; out AError: String): Integer;

/// <summary>
/// The platform database a scenario file was built with, from its header.
/// </summary>
/// <param name="AHead">The scenario file's first bytes (36 are enough).</param>
/// <returns>
/// The database name, e.g. <c>HCDB-060103</c> or <c>HCDB2-170909</c>; '' when
/// the file does not say.
/// </returns>
/// <remarks>
/// A versioned scenario file (HC <c>Common/SRC/Initscen.c</c>) starts with
/// u16 <c>$FFFF</c> and a u16 format version; its scenario data follows at
/// byte 4 and opens with <c>DATABASE_VERSION.RESFile.flavor</c>, a
/// NUL-terminated name of up to 32 bytes.  A file without the <c>$FFFF</c>
/// is a version-0 scenario (an embedded-database battleset scenario) that
/// records no database, so nothing is read from it.  Anything that is not a
/// plain printable name is refused rather than trusted.
/// </remarks>
function HCLScenarioDBFlavor(const AHead: RawByteString): String;

/// <summary>Tests whether an archive entry is a scenario file: extension <c>.SC</c> plus one character.</summary>
/// <param name="AName">Entry name.</param>
/// <returns>True for <c>.scm</c>, <c>.SCq</c>, <c>.sc1</c> and the like; not their <c>.xml</c> sidecars.</returns>
function HCLIsScenarioFileName(const AName: String): Boolean;

/// <summary>
/// The platform database the scenarios in an archive were built with.
/// </summary>
/// <param name="AArchive">Archive handler.</param>
/// <param name="AArchivePath">The downloaded archive.</param>
/// <returns>The one database named by its scenario files; '' when none says,
/// or when they disagree (then the catalog's dependency, or the user, decides).</returns>
/// <remarks>
/// Only the scenario file's own record counts.  In particular nothing is
/// inferred from the extension's battleset letter: slots such as HDSM-HDSU
/// hold whatever BSBuilder battleset a player installed, so for custom
/// battlesets the letter does not identify one.
/// </remarks>
function HCLScenarioDatabase(const AArchive: IHCLArchive; const AArchivePath: String): String;

/// <summary>
/// The managed category whose <c>cache\</c> a catalog download goes to.
/// </summary>
/// <param name="AFile">The catalog file.</param>
/// <param name="ACategory">Receives the category.</param>
/// <returns>False when the artifact type is not recognised.</returns>
/// <remarks>
/// Follows where the file will be installed: a scenario that declares a
/// platform database goes to <c>PlatformDBs\cache</c>, like its install
/// under <c>PlatformDBs\</c>; any other scenario to <c>BattleSets\cache</c>.
/// Everything else maps as <see cref="HCLArtifactTypeToCategory"/>.
/// </remarks>
function HCLDownloadCategory(const AFile: THCLCatalogFile;
  out ACategory: THCLCategory): Boolean;

/// <summary>
/// Finds where a catalog scenario was installed.
/// </summary>
/// <param name="AFileOrg">The managed tree.</param>
/// <param name="AFile">The scenario, in the version wanted.</param>
/// <param name="ADBDir">Receives the platform database directory it sits under;
/// '' when it sits under a BattleSet or is not found.</param>
/// <returns>
/// The first scenario file (<c>.SC?</c>) in <c>PlatformDBs\*\Scenarios\&lt;name&gt;\</c>,
/// else <c>BattleSets\*\Scenarios\&lt;name&gt;\</c>, where &lt;name&gt; is the folder
/// name the import gives it (<c>HCLVersionedName</c>, sanitised); '' when none,
/// or when AFile is not a scenario.
/// </returns>
function HCLFindInstalledScenario(const AFileOrg: IHCLFileOrganization;
  const AFile: THCLCatalogFile; out ADBDir: String): String;

implementation

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

function HCLArtifactTypeToCategory(const AArtifactType: String;
  out ACategory: THCLCategory): Boolean;
var
  LType: String;
begin
  ACategory := hclBattleSets;
  Result := True;
  LType := Trim(AArtifactType);

  if SameText(LType, HCL_ARTIFACT_PLATFORMDB) then
    ACategory := hclPlatformDBs
  else if SameText(LType, HCL_ARTIFACT_BATTLESET) then
    ACategory := hclBattleSets
  else if SameText(LType, HCL_ARTIFACT_SCENARIO) then
    ACategory := hclBattleSets
  else if SameText(LType, HCL_ARTIFACT_EXPORTDLL) then
    ACategory := hclExportDLLs
  else if SameText(LType, HCL_ARTIFACT_SOUND) then
    ACategory := hclSounds
  else if SameText(LType, HCL_ARTIFACT_GRAPHIC) then
    ACategory := hclGraphics
  else if SameText(LType, HCL_ARTIFACT_MAP) then
    ACategory := hclMaps
  else if SameText(LType, HCL_ARTIFACT_TOOL) then
    ACategory := hclTools
  else
    Result := False;
end;

function HCLCategoryAcceptsImports(ACategory: THCLCategory): Boolean;
begin
  Result := ACategory <> hclLogs;
end;

function HCLVersionedName(const ABaseName: String;
  const AFile: THCLCatalogFile): String;
var
  LLabel: String;
  I: Integer;
begin
  Result := ABaseName;
  if not AFile.IsPreviousVersion then
    Exit;
  LLabel := Trim(AFile.VersionLabel);
  if LLabel = '' then
    LLabel := Trim(AFile.Version);
  if LLabel = '' then
    LLabel := ChangeFileExt(Trim(AFile.FileName), '');
  if LLabel = '' then
    Exit(Trim(ABaseName) + ' (earlier version)');
  for I := 1 to Length(LLabel) do
    if LLabel[I] in ['A'..'Z', 'a'..'z'] then
      Exit(LLabel);
  Result := Trim(ABaseName) + ' ' + LLabel;
end;

function HCLSanitizeDirName(const AName: String): String;
const
  // Windows forbids these in a path component; '/' and '\' would also split
  // the name into extra directory levels, which is never what a title means.
  INVALID_CHARS = ['<', '>', ':', '"', '/', '\', '|', '?', '*'];
  RESERVED: array[0..21] of String = (
    'CON', 'PRN', 'AUX', 'NUL',
    'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
    'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
  );
var
  I: Integer;
  LCh: Char;
  LOut: String;
begin
  LOut := '';
  for I := 1 to Length(AName) do
  begin
    LCh := AName[I];
    if (LCh in INVALID_CHARS) or (LCh < #32) then
      LCh := '_';

    // Collapse runs of underscores so "a: b" does not become "a__b".
    if (LCh = '_') and (Length(LOut) > 0) and (LOut[Length(LOut)] = '_') then
      Continue;

    LOut := LOut + LCh;
  end;

  LOut := Trim(LOut);

  if Length(LOut) > HCL_MAX_DIRNAME_LEN then
    LOut := Trim(Copy(LOut, 1, HCL_MAX_DIRNAME_LEN));

  // Windows silently discards trailing dots and spaces on creation.
  while (Length(LOut) > 0) and ((LOut[Length(LOut)] = '.') or (LOut[Length(LOut)] = ' ')) do
    Delete(LOut, Length(LOut), 1);

  LOut := Trim(LOut);

  // A name of nothing but underscores carries no information.
  if StringReplace(LOut, '_', '', [rfReplaceAll]) = '' then
    LOut := '';

  if LOut = '' then
  begin
    Result := HCL_UNNAMED_ARTIFACT;
    Exit;
  end;

  for I := Low(RESERVED) to High(RESERVED) do
    if SameText(LOut, RESERVED[I]) then
    begin
      Result := LOut + '_';
      Exit;
    end;

  Result := LOut;
end;

// ---------------------------------------------------------------------------
// Wrapper-prefix resolution
// ---------------------------------------------------------------------------

/// <summary>Case-insensitive membership test against a list of names.</summary>
/// <param name="AName">Name to look for.</param>
/// <param name="ANames">Candidates; empty strings never match.</param>
/// <returns>True when <paramref name="AName"/> is in the list.</returns>
function InNames(const AName: String; const ANames: array of String): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := Low(ANames) to High(ANames) do
    if (ANames[I] <> '') and SameText(AName, ANames[I]) then
      Exit(True);
end;

/// <summary>
/// Returns the one directory that every safe file beneath
/// <paramref name="APrefix"/> sits in, or an empty string when there isn't one.
/// </summary>
/// <param name="AEntries">Archive entries.</param>
/// <param name="APrefix">Archive-relative prefix already accounted for; may be empty.</param>
/// <returns>The common next-level directory name, or empty when any file sits
/// directly at this level or the files diverge.</returns>
/// <remarks>
/// Directory entries are ignored: only files decide what is a wrapper, so an
/// empty directory in the archive cannot stop a wrapper being recognised.
/// </remarks>
function CommonTopUnder(const AEntries: THCLArchiveEntryArray;
  const APrefix: String): String;
var
  I, P: Integer;
  LHead, LRel, LSeg, LFirst: String;
begin
  Result := '';
  LFirst := '';

  if APrefix <> '' then
    LHead := APrefix + PathDelim
  else
    LHead := '';

  for I := 0 to High(AEntries) do
  begin
    if (not AEntries[I].IsSafe) or AEntries[I].IsDirectory then
      Continue;

    LRel := AEntries[I].Name;
    if LHead <> '' then
    begin
      if not ((Length(LRel) > Length(LHead)) and
              SameText(Copy(LRel, 1, Length(LHead)), LHead)) then
        Exit('');
      Delete(LRel, 1, Length(LHead));
    end;

    P := Pos(PathDelim, LRel);
    if P = 0 then
      Exit('');  // a file directly at this level: nothing wraps it

    LSeg := Copy(LRel, 1, P - 1);
    if LFirst = '' then
      LFirst := LSeg
    else if not SameText(LFirst, LSeg) then
      Exit('');
  end;

  Result := LFirst;
end;

/// <summary>
/// Core of <see cref="HCLResolveWrapperPrefix"/>: walks down through common
/// directories, stripping container names freely and one release wrapper at
/// the top, and stopping at the first structural directory.
/// </summary>
/// <param name="AEntries">Archive entries.</param>
/// <param name="AContainers">Names that repeat the destination; always strippable.</param>
/// <param name="AStructural">Names that carry meaning; never stripped.</param>
/// <returns>The prefix to strip, or an empty string.</returns>
function ResolvePrefix(const AEntries: THCLArchiveEntryArray;
  const AContainers, AStructural: array of String): String;
var
  LTop: String;
begin
  Result := '';

  // Each pass consumes one directory level, and CommonTopUnder only answers
  // when every file sits deeper still, so the loop always terminates.
  repeat
    LTop := CommonTopUnder(AEntries, Result);

    if (LTop = '') or InNames(LTop, AStructural) then
      Break;

    // A release wrapper is only recognised at the very top; below that, an
    // unrecognised directory is part of the artifact's own layout.
    if not InNames(LTop, AContainers) and (Result <> '') then
      Break;

    if Result = '' then
      Result := LTop
    else
      Result := Result + PathDelim + LTop;
  until False;
end;

function HCLResolveWrapperPrefix(const AEntries: THCLArchiveEntryArray;
  const APlan: THCLImportPlan): String;
var
  LCat: String;
  LNone: TStringArray;
begin
  Result := '';
  if not APlan.StripWrapper then
    Exit;

  LNone := nil;
  LCat := HCL_CATEGORY_NAMES[APlan.Category];

  if APlan.Kind = ikScenario then
  begin
    Result := ResolvePrefix(AEntries,
      [HCL_SCENARIOS_DIR, APlan.ArtifactName],
      [HCL_SAVEDGAMES_DIR]);
    Exit;
  end;

  case APlan.Category of
    hclBattleSets:
      Result := ResolvePrefix(AEntries,
        [LCat, APlan.ArtifactName],
        [HCL_SCENARIOS_DIR]);

    hclPlatformDBs:
      Result := ResolvePrefix(AEntries,
        [LCat, APlan.ArtifactName],
        [HCL_PLATFORMDB_RES_DIR, HCL_PLATFORMDB_RSR_DIR, HCL_SCENARIOS_DIR]);

    hclExportDLLs:
      case APlan.DLLTarget of
        // A chosen GE/SE target makes that directory a container: an archive
        // that also says GE/ must not land in GE\GE\.
        dtGE:
          Result := ResolvePrefix(AEntries,
            [LCat, HCL_EXPORTDLL_GE_DIR], [HCL_EXPORTDLL_SE_DIR]);
        dtSE:
          Result := ResolvePrefix(AEntries,
            [LCat, HCL_EXPORTDLL_SE_DIR], [HCL_EXPORTDLL_GE_DIR]);
      else
        Result := ResolvePrefix(AEntries,
          [LCat], [HCL_EXPORTDLL_GE_DIR, HCL_EXPORTDLL_SE_DIR]);
      end;
  else
    Result := ResolvePrefix(AEntries, [LCat, APlan.ArtifactName], LNone);
  end;
end;

function HCLDetectDLLTarget(const AEntries: THCLArchiveEntryArray): THCLDLLTarget;
var
  I, P: Integer;
  LPrefix, LHead, LRel: String;
begin
  Result := dtUnspecified;

  LPrefix := ResolvePrefix(AEntries,
    [HCL_CATEGORY_NAMES[hclExportDLLs]],
    [HCL_EXPORTDLL_GE_DIR, HCL_EXPORTDLL_SE_DIR]);
  if LPrefix <> '' then
    LHead := LPrefix + PathDelim
  else
    LHead := '';

  for I := 0 to High(AEntries) do
  begin
    if (not AEntries[I].IsSafe) or AEntries[I].IsDirectory then
      Continue;

    LRel := AEntries[I].Name;
    if (LHead <> '') and SameText(Copy(LRel, 1, Length(LHead)), LHead) then
      Delete(LRel, 1, Length(LHead));

    P := Pos(PathDelim, LRel);
    if (P > 0) and InNames(Copy(LRel, 1, P - 1),
         [HCL_EXPORTDLL_GE_DIR, HCL_EXPORTDLL_SE_DIR]) then
      Exit(dtAsPackaged);
  end;
end;

// ---------------------------------------------------------------------------
// Platform database collections and companion files
// ---------------------------------------------------------------------------

/// <summary>True when S holds six digits in a row, as a yymmdd build date does.</summary>
function HasSixDigitRun(const S: String): Boolean;
var
  I, LRun: Integer;
begin
  LRun := 0;
  for I := 1 to Length(S) do
  begin
    if S[I] in ['0'..'9'] then
    begin
      Inc(LRun);
      if LRun >= 6 then
        Exit(True);
    end
    else
      LRun := 0;
  end;
  Result := False;
end;

/// <summary>Splits an archive-relative file name into its directory ('' at the top) and file name.</summary>
procedure SplitEntryName(const AName: String; out ADir, AFile: String);
var
  P: Integer;
begin
  P := Length(AName);
  while (P > 0) and (AName[P] <> PathDelim) do
    Dec(P);
  ADir  := Copy(AName, 1, P - 1);
  AFile := Copy(AName, P + 1, MaxInt);
end;

function HCLFindBundledDBs(const AEntries: THCLArchiveEntryArray): THCLBundledDBArray;
var
  I, J, P: Integer;
  LDir, LFile, LOtherDir, LOtherFile, LStem, LBest: String;
  LSeen: Boolean;
begin
  Result := nil;
  for I := 0 to High(AEntries) do
  begin
    if (not AEntries[I].IsSafe) or AEntries[I].IsDirectory then
      Continue;
    SplitEntryName(AEntries[I].Name, LDir, LFile);
    if not SameText(LFile, HCL_COMMONDB_RES) then
      Continue;

    LSeen := False;
    for J := 0 to High(Result) do
      if SameText(Result[J].Prefix, LDir) then
        LSeen := True;
    if LSeen then
      Continue;

    // Look beside it for the pictures and for a file naming the build.
    SetLength(Result, Length(Result) + 1);
    Result[High(Result)].Prefix := LDir;
    Result[High(Result)].HasRsr := False;
    LBest := '';
    for J := 0 to High(AEntries) do
    begin
      if (not AEntries[J].IsSafe) or AEntries[J].IsDirectory then
        Continue;
      SplitEntryName(AEntries[J].Name, LOtherDir, LOtherFile);
      if not SameText(LOtherDir, LDir) then
        Continue;
      if SameText(LOtherFile, HCL_COMMONDB_RSR) then
        Result[High(Result)].HasRsr := True
      else if not SameText(LOtherFile, HCL_COMMONDB_RES) then
      begin
        LStem := ChangeFileExt(LOtherFile, '');
        // The alphabetically first dated name, so the choice never depends
        // on the order the archive happens to list its files in.
        if HasSixDigitRun(LStem) and ((LBest = '') or (CompareText(LStem, LBest) < 0)) then
          LBest := LStem;
      end;
    end;

    if LBest = '' then
    begin
      P := Length(LDir);
      while (P > 0) and (LDir[P] <> PathDelim) do
        Dec(P);
      LBest := Copy(LDir, P + 1, MaxInt);  // '' for the top level
    end;
    Result[High(Result)].Name := LBest;
  end;
end;

function HCLPlatformDBNeedsRsr(const ADir: String): Boolean;
var
  LDir: String;
begin
  LDir := IncludeTrailingPathDelimiter(ADir);
  Result := FileExists(LDir + HCL_COMMONDB_RES) and
            not FileExists(LDir + HCL_COMMONDB_RSR) and
            not DirectoryExists(LDir + HCL_PLATFORMDB_RSR_DIR);
end;

/// <summary>Copies ASource to ATarget via a temporary name, so a failed copy never leaves a partial file behind.</summary>
function CopyFileSafely(const ASource, ATarget: String; out AError: String): Boolean;
var
  LIn, LOut: TFileStream;
  LTemp: String;
begin
  Result := False;
  AError := '';
  LTemp := ATarget + '.part';
  try
    LIn := TFileStream.Create(ASource, fmOpenRead or fmShareDenyWrite);
    try
      LOut := TFileStream.Create(LTemp, fmCreate);
      try
        LOut.CopyFrom(LIn, 0);
      finally
        LOut.Free;
      end;
    finally
      LIn.Free;
    end;
    if not RenameFile(LTemp, ATarget) then
    begin
      AError := 'Could not rename ' + LTemp + ' to ' + ATarget;
      DeleteFile(LTemp);
      Exit;
    end;
    Result := True;
  except
    on E: Exception do
    begin
      AError := E.Message;
      DeleteFile(LTemp);
    end;
  end;
end;

function HCLPlaceCompanionFile(const AArchive: IHCLArchive;
  const ACompanionZip, AFileName, AStagingDir: String;
  const ATargetDirs: array of String; out AError: String): Integer;
var
  LEntries: THCLArchiveEntryArray;
  LDir, LFile, LEntryDir, LActual, LStaged, LTarget, LCopyError: String;
  LFound: Boolean;
  LExtract: THCLExtractResult;
  I: Integer;
begin
  Result := 0;
  AError := '';
  LStaged := IncludeTrailingPathDelimiter(AStagingDir) + AFileName;

  if not FileExists(LStaged) then
  begin
    if AArchive = nil then
    begin
      AError := 'No archive handler is available';
      Exit;
    end;
    LEntries := AArchive.ListEntries(ACompanionZip);
    LFound := False;
    LEntryDir := '';
    for I := 0 to High(LEntries) do
    begin
      if (not LEntries[I].IsSafe) or LEntries[I].IsDirectory then
        Continue;
      SplitEntryName(LEntries[I].Name, LDir, LFile);
      if SameText(LFile, AFileName) then
      begin
        LFound := True;
        LEntryDir := LDir;
        LActual := LFile;
        Break;
      end;
    end;
    if not LFound then
    begin
      AError := ExtractFileName(ACompanionZip) + ' does not contain ' + AFileName;
      Exit;
    end;

    // Only that one directory, flattened into the staging directory.
    LExtract := AArchive.Extract(ACompanionZip, AStagingDir, omOverwrite, nil,
      LEntryDir, True);
    if not LExtract.Success then
    begin
      AError := LExtract.ErrorMessage;
      Exit;
    end;
    // The archive may spell it differently (COMMONDB.RSR), which matters
    // wherever file names are case-sensitive.
    if (LActual <> AFileName) and
       FileExists(IncludeTrailingPathDelimiter(AStagingDir) + LActual) and
       not FileExists(LStaged) then
      RenameFile(IncludeTrailingPathDelimiter(AStagingDir) + LActual, LStaged);
    if not FileExists(LStaged) then
    begin
      AError := 'Extracted ' + ExtractFileName(ACompanionZip) + ' but found no ' + AFileName;
      Exit;
    end;
  end;

  for I := 0 to High(ATargetDirs) do
  begin
    LTarget := IncludeTrailingPathDelimiter(ATargetDirs[I]) + AFileName;
    if FileExists(LTarget) then
      Continue;
    if CopyFileSafely(LStaged, LTarget, LCopyError) then
      Inc(Result)
    else if AError = '' then
      AError := LCopyError;
  end;
end;

// ---------------------------------------------------------------------------
// Scenario databases and download categories
// ---------------------------------------------------------------------------

const
  /// <summary>DATABASE_FLAVER_LEN in HC Common/Include/Typedefs.h.</summary>
  SCEN_DB_FLAVOR_LEN = 32;
  /// <summary>Where the flavor starts in a versioned scenario file.</summary>
  SCEN_DB_FLAVOR_OFFSET = 4;
  /// <summary>Largest scenario file read to find it; real ones are tens of KB.</summary>
  SCEN_MAX_READ = 16 * 1024 * 1024;

function HCLScenarioDBFlavor(const AHead: RawByteString): String;
var
  I, Last: Integer;
  Ch: Char;
begin
  Result := '';
  if (Length(AHead) < SCEN_DB_FLAVOR_OFFSET + 2) or
     (Byte(AHead[1]) <> $FF) or (Byte(AHead[2]) <> $FF) then
    Exit;  // version-0 scenario: no database recorded

  Last := SCEN_DB_FLAVOR_OFFSET + SCEN_DB_FLAVOR_LEN;
  if Last > Length(AHead) then
    Last := Length(AHead);
  for I := SCEN_DB_FLAVOR_OFFSET + 1 to Last do
  begin
    Ch := AHead[I];
    if Ch = #0 then
      Break;
    // A plain name only: letters, digits, space and - _ . @ ( ).
    if not (Ch in ['A'..'Z', 'a'..'z', '0'..'9', ' ', '-', '_', '.', '@', '(', ')']) then
      Exit('');
    Result := Result + Ch;
  end;
  Result := Trim(Result);
  if (Length(Result) < 3) or not (Result[1] in ['A'..'Z', 'a'..'z']) then
    Result := '';
end;

function HCLIsScenarioFileName(const AName: String): Boolean;
var
  Ext: String;
begin
  Ext := LowerCase(ExtractFileExt(AName));
  Result := (Length(Ext) = 4) and (Copy(Ext, 1, 3) = '.sc');
end;

function HCLScenarioDatabase(const AArchive: IHCLArchive; const AArchivePath: String): String;
var
  Entries: THCLArchiveEntryArray;
  I: Integer;
  Flavor: String;
begin
  Result := '';
  if AArchive = nil then
    Exit;
  Entries := AArchive.ListEntries(AArchivePath);
  for I := 0 to High(Entries) do
  begin
    if (not Entries[I].IsSafe) or Entries[I].IsDirectory or
       not HCLIsScenarioFileName(Entries[I].Name) then
      Continue;
    Flavor := HCLScenarioDBFlavor(AArchive.ReadEntry(AArchivePath, Entries[I].RawName, SCEN_MAX_READ));
    if Flavor = '' then
      Continue;
    if Result = '' then
      Result := Flavor
    else if not SameText(Result, Flavor) then
      Exit('');  // scenarios built on different databases: not ours to pick
  end;
end;

function HCLDownloadCategory(const AFile: THCLCatalogFile;
  out ACategory: THCLCategory): Boolean;
begin
  Result := HCLArtifactTypeToCategory(AFile.ArtifactType, ACategory);
  if Result and SameText(Trim(AFile.ArtifactType), HCL_ARTIFACT_SCENARIO) and
     (Trim(AFile.Dependencies.PlatformDB) <> '') then
    ACategory := hclPlatformDBs;
end;

function HCLFindInstalledScenario(const AFileOrg: IHCLFileOrganization;
  const AFile: THCLCatalogFile; out ADBDir: String): String;
var
  ScenName, Base, Dir: String;
  Cat: THCLCategory;
  Parents, Files: TSearchRec;
begin
  Result := '';
  ADBDir := '';
  if (AFileOrg = nil) or not SameText(Trim(AFile.ArtifactType), HCL_ARTIFACT_SCENARIO) then
    Exit;
  ScenName := HCLSanitizeDirName(HCLVersionedName(Trim(AFile.Title), AFile));
  for Cat in [hclPlatformDBs, hclBattleSets] do
  begin
    Base := IncludeTrailingPathDelimiter(AFileOrg.GetCategoryPath(Cat));
    if FindFirst(Base + '*', faDirectory, Parents) = 0 then
    try
      repeat
        if ((Parents.Attr and faDirectory) = 0) or (Parents.Name = '.') or
           (Parents.Name = '..') or SameText(Parents.Name, 'cache') then
          Continue;
        Dir := Base + Parents.Name + PathDelim + HCL_SCENARIOS_DIR + PathDelim +
          ScenName + PathDelim;
        if FindFirst(Dir + '*', faAnyFile and not faDirectory, Files) = 0 then
        try
          repeat
            if HCLIsScenarioFileName(Files.Name) then
            begin
              Result := Dir + Files.Name;
              if Cat = hclPlatformDBs then
                ADBDir := Base + Parents.Name;
              Exit;
            end;
          until FindNext(Files) <> 0;
        finally
          FindClose(Files);
        end;
      until FindNext(Parents) <> 0;
    finally
      FindClose(Parents);
    end;
  end;
end;

// ---------------------------------------------------------------------------
// THCLImport
// ---------------------------------------------------------------------------

type
  /// <summary>
  /// Default <see cref="IHCLImport"/> implementation.
  /// </summary>
  THCLImport = class(TInterfacedObject, IHCLImport)
  private
    FFileOrg: IHCLFileOrganization;
    FArchive: IHCLArchive;

    /// <summary>
    /// Chooses the per-artifact directory name for a catalog file, preferring
    /// declared dependency metadata over the free-text title.
    /// </summary>
    /// <param name="AFile">Catalog entry.</param>
    /// <param name="ACategory">Category the artifact resolved to.</param>
    /// <returns>A sanitised directory name.</returns>
    function ResolveArtifactName(const AFile: THCLCatalogFile;
      ACategory: THCLCategory): String;
  public
    constructor Create(const AFileOrg: IHCLFileOrganization;
      const AArchive: IHCLArchive);

    function PlanImport(const AFile: THCLCatalogFile;
      const AParentName: String = '';
      AParentIsCommonDB: Boolean = False;
      ADLLTarget: THCLDLLTarget = dtUnspecified): THCLImportPlan;
    function PlanBundledDB(const AFile: THCLCatalogFile;
      const ADB: THCLBundledDB): THCLImportPlan;
    function Execute(const APlan: THCLImportPlan; const AArchivePath: String;
      AOverwrite: THCLOverwriteMode;
      AOnProgress: THCLExtractProgressEvent): THCLImportResult;
  end;

constructor THCLImport.Create(const AFileOrg: IHCLFileOrganization;
  const AArchive: IHCLArchive);
begin
  inherited Create;
  FFileOrg := AFileOrg;
  FArchive := AArchive;
end;

function THCLImport.ResolveArtifactName(const AFile: THCLCatalogFile;
  ACategory: THCLCategory): String;
var
  LName: String;
begin
  LName := '';

  // Dependency metadata comes from the uploader's structured custom fields and
  // is far more consistent than a free-text title, so prefer it where it maps
  // directly onto the directory being created.
  case ACategory of
    hclPlatformDBs:
      LName := Trim(AFile.Dependencies.PlatformDB);
    hclBattleSets:
      LName := Trim(AFile.Dependencies.BattleSet);
  end;

  if LName = '' then
    LName := Trim(AFile.Title);

  // The dependency field describes the current version; an earlier one gets
  // a directory of its own.
  Result := HCLSanitizeDirName(HCLVersionedName(LName, AFile));
end;

function THCLImport.PlanImport(const AFile: THCLCatalogFile;
  const AParentName: String = '';
  AParentIsCommonDB: Boolean = False;
  ADLLTarget: THCLDLLTarget = dtUnspecified): THCLImportPlan;
var
  LCategory: THCLCategory;
  LParent: String;
  LScenarioName: String;
  LRoot: String;
begin
  Result := Default(THCLImportPlan);
  Result.Valid := False;
  Result.Kind := ikUnknown;
  Result.Category := hclBattleSets;
  Result.DLLTarget := dtUnspecified;

  if FFileOrg = nil then
  begin
    Result.Reason := 'No managed file organization is available';
    Exit;
  end;

  if not HCLArtifactTypeToCategory(AFile.ArtifactType, LCategory) then
  begin
    Result.Reason := Format('Unrecognised artifact type "%s"', [AFile.ArtifactType]);
    Exit;
  end;

  if not HCLCategoryAcceptsImports(LCategory) then
  begin
    Result.Reason := Format('%s is not an import destination',
      [HCL_CATEGORY_NAMES[LCategory]]);
    Exit;
  end;

  Result.Category := LCategory;

  // --- Scenarios: need a parent BattleSet or PlatformDB -------------------
  if SameText(Trim(AFile.ArtifactType), HCL_ARTIFACT_SCENARIO) then
  begin
    Result.Kind := ikScenario;
    Result.StripWrapper := True;

    LParent := Trim(AParentName);
    Result.IsCommonDB := AParentIsCommonDB;

    if LParent = '' then
    begin
      // Fall back to whatever the catalog declares, preferring a PlatformDB
      // dependency because a commondb scenario cannot live under a BattleSet.
      if Trim(AFile.Dependencies.PlatformDB) <> '' then
      begin
        LParent := Trim(AFile.Dependencies.PlatformDB);
        Result.IsCommonDB := True;
      end
      else if Trim(AFile.Dependencies.BattleSet) <> '' then
      begin
        LParent := Trim(AFile.Dependencies.BattleSet);
        Result.IsCommonDB := False;
      end;
    end;

    if LParent = '' then
    begin
      Result.RequiresParent := True;
      Result.Reason := 'Scenario has no declared BattleSet or PlatformDB — ' +
        'choose where to install it';
      Exit;
    end;

    Result.ParentName := HCLSanitizeDirName(LParent);
    if Result.IsCommonDB then
      Result.Category := hclPlatformDBs
    else
      Result.Category := hclBattleSets;

    LScenarioName := HCLSanitizeDirName(HCLVersionedName(Trim(AFile.Title), AFile));
    Result.ArtifactName := LScenarioName;

    if Result.IsCommonDB then
      Result.DestDir := FFileOrg.GetPlatformDBScenarioPath(Result.ParentName, LScenarioName)
    else
      Result.DestDir := FFileOrg.GetBattleSetScenarioPath(Result.ParentName, LScenarioName);

    Result.Valid := True;
    Exit;
  end;

  // --- Everything else ----------------------------------------------------
  Result.StripWrapper := True;

  case LCategory of
    hclBattleSets:
      begin
        Result.Kind := ikNamedArtifact;
        Result.ArtifactName := ResolveArtifactName(AFile, LCategory);
        Result.DestDir := FFileOrg.GetBattleSetPath(Result.ArtifactName);
      end;

    hclPlatformDBs:
      begin
        Result.Kind := ikNamedArtifact;
        Result.ArtifactName := ResolveArtifactName(AFile, LCategory);
        Result.DestDir := FFileOrg.GetPlatformDBPath(Result.ArtifactName);
      end;

    hclMaps, hclTools:
      begin
        // One directory per artifact: map sets and loose tools both ship
        // generic filenames that would otherwise collide.
        Result.Kind := ikNamedArtifact;
        Result.ArtifactName := ResolveArtifactName(AFile, LCategory);
        Result.DestDir := FFileOrg.GetCategoryPath(LCategory) + PathDelim +
          Result.ArtifactName;
      end;

    hclSounds, hclGraphics:
      begin
        // Flat by design — the GE loads these from the category directory
        // itself, so a per-artifact subdirectory would hide them from the game.
        Result.Kind := ikCategoryRoot;
        Result.DestDir := FFileOrg.GetCategoryPath(LCategory);
      end;

    hclExportDLLs:
      begin
        // Also flat, but split three ways: HC loads ExportDLLs\ into both the
        // GE and the SE, and GE\ and SE\ into one each.  Guessing wrong either
        // hides a DLL or loads it into a program it was never built for.
        Result.Kind := ikCategoryRoot;
        Result.DLLTarget := ADLLTarget;
        LRoot := FFileOrg.GetCategoryPath(LCategory);

        case ADLLTarget of
          dtUnspecified:
            begin
              Result.RequiresDLLTarget := True;
              Result.Reason := 'Choose whether this ExportDLL is for the GE, ' +
                'the SE, or both';
              Exit;
            end;
          dtAsPackaged, dtCommon:
            Result.DestDir := LRoot;
          dtGE:
            Result.DestDir := LRoot + PathDelim + HCL_EXPORTDLL_GE_DIR;
          dtSE:
            Result.DestDir := LRoot + PathDelim + HCL_EXPORTDLL_SE_DIR;
        end;
      end;
  end;

  Result.Valid := Result.DestDir <> '';
  if not Result.Valid and (Result.Reason = '') then
    Result.Reason := 'Could not resolve a destination directory';
end;

function THCLImport.PlanBundledDB(const AFile: THCLCatalogFile;
  const ADB: THCLBundledDB): THCLImportPlan;
begin
  Result := Default(THCLImportPlan);
  Result.Category := hclPlatformDBs;
  Result.Kind     := ikNamedArtifact;

  if FFileOrg = nil then
  begin
    Result.Reason := 'No managed file organization is available';
    Exit;
  end;
  if Trim(ADB.Name) = '' then
  begin
    Result.Reason := 'The database in ' + AFile.Title + ' has no name to install it under';
    Exit;
  end;

  // The collection's own name for each database, e.g. HC-80to95-DB_110101;
  // an earlier version of the collection still keeps each build apart.
  Result.ArtifactName    := HCLSanitizeDirName(HCLVersionedName(Trim(ADB.Name), AFile));
  Result.DestDir         := FFileOrg.GetPlatformDBPath(Result.ArtifactName);
  Result.EntryPrefix     := ADB.Prefix;
  Result.OnlyUnderPrefix := True;
  Result.Valid           := True;
end;

function THCLImport.Execute(const APlan: THCLImportPlan; const AArchivePath: String;
  AOverwrite: THCLOverwriteMode;
  AOnProgress: THCLExtractProgressEvent): THCLImportResult;
var
  LExtract: THCLExtractResult;
  LPrefix: String;
begin
  Result := Default(THCLImportResult);
  Result.DestDir := APlan.DestDir;

  if not APlan.Valid then
  begin
    Result.ErrorMessage := APlan.Reason;
    if Result.ErrorMessage = '' then
      Result.ErrorMessage := 'Import plan is not valid';
    Exit;
  end;

  if FArchive = nil then
  begin
    Result.ErrorMessage := 'No archive handler is available';
    Exit;
  end;

  if FFileOrg = nil then
  begin
    Result.ErrorMessage := 'No managed file organization is available';
    Exit;
  end;

  if not FileExists(AArchivePath) then
  begin
    Result.ErrorMessage := 'Archive not found: ' + AArchivePath;
    Exit;
  end;

  if APlan.OnlyUnderPrefix then
    LPrefix := APlan.EntryPrefix  // one database of several: exactly its directory
  else
    LPrefix := HCLResolveWrapperPrefix(FArchive.ListEntries(AArchivePath), APlan);

  // Scenarios get their SavedGames\AutoSaves scaffolding up front so the tree
  // is complete the moment the scenario appears, whether or not the archive
  // happens to contain saves.
  if (APlan.Kind = ikScenario) and (APlan.ParentName <> '') and
     (APlan.ArtifactName <> '') then
    FFileOrg.EnsureScenarioPath(APlan.ParentName, APlan.ArtifactName, APlan.IsCommonDB);

  if not ForceDirectories(APlan.DestDir) then
  begin
    Result.ErrorMessage := 'Could not create destination: ' + APlan.DestDir;
    Exit;
  end;

  LExtract := FArchive.Extract(AArchivePath, APlan.DestDir, AOverwrite,
    AOnProgress, LPrefix, APlan.OnlyUnderPrefix);

  Result.FilesExtracted  := LExtract.FilesExtracted;
  Result.BytesExtracted  := LExtract.BytesExtracted;
  Result.FilesSkipped    := LExtract.FilesSkipped;
  Result.UnsafeEntries   := LExtract.UnsafeEntries;
  Result.ErrorMessage    := LExtract.ErrorMessage;
  Result.Success         := LExtract.Success;
  Result.StrippedPrefix  := LPrefix;
  Result.WrapperStripped := Result.Success and (LPrefix <> '');
end;

function CreateImport(const AFileOrg: IHCLFileOrganization;
  const AArchive: IHCLArchive): IHCLImport;
begin
  Result := THCLImport.Create(AFileOrg, AArchive);
end;

end.
