unit uHCLAutosaves;

{$mode objfpc}{$H+}

/// <summary>
/// uHCLAutosaves — finds, groups, prunes and moves the GE's iterative
/// autosaves.
///
/// With <c>-S</c> the GE saves every N game seconds to
/// <c>&lt;GE folder&gt;\ITER_SAVE_GAMETIME\&lt;base&gt;.&lt;NNNN&gt;&lt;ext&gt;</c>
/// (HC <c>libge/src/Command.c</c>), where the base is the <c>-k</c> name, else
/// the user scenario's file name (<c>PINGER</c>), else
/// <c>&lt;battleset&gt;-&lt;scenario&gt;</c> for a built-in scenario (<c>026-000</c>,
/// scenario numbers from 0), and the extension is the save game's
/// (<c>.hpq</c>, <c>.USNI-hp1</c>).  The counter restarts at 0001 whenever the
/// GE starts, so one base mixes runs and a later run overwrites an earlier
/// one's first saves: "newest" is by file time, never by number.
///
/// Nothing here asks the user; callers confirm first.  Zero UI dependency.
/// </summary>

interface

uses
  SysUtils, uHCLFileOrganization;

const
  /// <summary>The folder the GE creates beside itself for iterative saves.</summary>
  HCL_ITER_SAVE_DIR = 'ITER_SAVE_GAMETIME';

type
  /// <summary>One iterative autosave file.</summary>
  THCLAutosave = record
    Path: String;
    Name: String;
    Base: String;
    Counter: Integer;
    Size: Int64;
    Time: TDateTime;
  end;

  THCLAutosaveArray = array of THCLAutosave;

  /// <summary>All the autosaves sharing a base name, newest first.</summary>
  THCLAutosaveGroup = record
    Base: String;
    Files: THCLAutosaveArray;
    TotalSize: Int64;
    Oldest, Newest: TDateTime;
  end;

  THCLAutosaveGroupArray = array of THCLAutosaveGroup;

/// <summary>Splits an iterative autosave's file name.</summary>
/// <param name="AName">e.g. "026-000.0017.hpq" or "PINGER.0001.USNI-hp1".</param>
/// <param name="ABase">Receives "026-000" / "PINGER".</param>
/// <param name="ACounter">Receives 17 / 1.</param>
/// <returns>False unless the name is &lt;base&gt;.&lt;four digits&gt;.&lt;extension containing "hp"&gt;.</returns>
function HCLParseAutosaveName(const AName: String; out ABase: String;
  out ACounter: Integer): Boolean;

/// <summary>Finds and groups the autosaves in a folder.</summary>
/// <param name="ADir">Usually &lt;GE folder&gt;\ITER_SAVE_GAMETIME.</param>
/// <returns>Groups sorted by base name (case-insensitive), each newest first;
/// empty when the folder is missing.  Other files are ignored.</returns>
function HCLScanAutosaves(const ADir: String): THCLAutosaveGroupArray;

/// <summary>The files to delete to keep only the newest N of a group.</summary>
/// <param name="AGroup">A group (newest first).</param>
/// <param name="AKeep">How many to keep; 0 keeps none.</param>
/// <returns>The rest, newest first.</returns>
function HCLKeepNewest(const AGroup: THCLAutosaveGroup; AKeep: Integer): THCLAutosaveArray;

/// <summary>The files of a group older than a number of days.</summary>
/// <param name="AGroup">A group.</param>
/// <param name="ADays">Age limit in days.</param>
/// <param name="ANow">The current time.</param>
/// <returns>Files last written before ANow - ADays.</returns>
function HCLOlderThan(const AGroup: THCLAutosaveGroup; ADays: Integer;
  ANow: TDateTime): THCLAutosaveArray;

/// <summary>Total size of some autosaves.</summary>
function HCLAutosaveBytes(const AFiles: THCLAutosaveArray): Int64;

/// <summary>
/// Finds the installed scenario an autosave base name belongs to.
/// </summary>
/// <param name="AFileOrg">The managed tree.</param>
/// <param name="ABase">A base name, e.g. "PINGER".</param>
/// <returns>The scenario's folder (…\Scenarios\&lt;name&gt;) holding a
/// scenario file (<c>.SC?</c>) whose name, without extension, equals ABase
/// (case-insensitive); '' when none.</returns>
function HCLFindScenarioForBase(const AFileOrg: IHCLFileOrganization;
  const ABase: String): String;

/// <summary>Reads a built-in scenario's base name.</summary>
/// <param name="ABase">e.g. "026-000".</param>
/// <param name="ABattleSet">Receives 26.</param>
/// <param name="AScenario">Receives 1 (the GE counts scenarios from 0).</param>
/// <returns>False when ABase is not &lt;three digits&gt;-&lt;three digits&gt;.</returns>
function HCLParseBuiltinBase(const ABase: String; out ABattleSet, AScenario: Integer): Boolean;

/// <summary>
/// Removes files, to the Recycle Bin where the platform has one.
/// </summary>
/// <param name="AFiles">Files to remove.</param>
/// <param name="AToRecycleBin">True for the Recycle Bin (Windows); False, or
/// elsewhere, deletes permanently.</param>
/// <param name="AError">Receives the first failure.</param>
/// <returns>How many were removed.</returns>
function HCLRemoveAutosaves(const AFiles: THCLAutosaveArray; AToRecycleBin: Boolean;
  out AError: String): Integer;

/// <summary>
/// Moves files into a folder, such as a scenario's SavedGames\AutoSaves.
/// </summary>
/// <param name="AFiles">Files to move.</param>
/// <param name="ADestDir">Created if needed; a file of the same name there is replaced.</param>
/// <param name="AError">Receives the first failure.</param>
/// <returns>How many were moved.</returns>
/// <remarks>Renames within a volume; across volumes copies, then deletes the original.</remarks>
function HCLMoveAutosaves(const AFiles: THCLAutosaveArray; const ADestDir: String;
  out AError: String): Integer;

implementation

uses
  Classes
  {$IFDEF MSWINDOWS}, Windows, ShellApi{$ENDIF};

const
  /// <summary>Where installed scenarios live: PlatformDBs\*\Scenarios, then BattleSets\*\Scenarios.</summary>
  SCENARIO_PARENTS: array[0..1] of THCLCategory = (hclPlatformDBs, hclBattleSets);

function HCLParseAutosaveName(const AName: String; out ABase: String;
  out ACounter: Integer): Boolean;
var
  LastDot, PrevDot, I: Integer;
  Ext, Digits: String;
begin
  ABase := '';
  ACounter := 0;
  Result := False;
  LastDot := LastDelimiter('.', AName);
  if LastDot <= 1 then
    Exit;
  Ext := Copy(AName, LastDot + 1, MaxInt);
  if Pos('hp', LowerCase(Ext)) = 0 then
    Exit;
  PrevDot := LastDot - 1;
  while (PrevDot > 0) and (AName[PrevDot] <> '.') do
    Dec(PrevDot);
  if PrevDot <= 1 then
    Exit;
  Digits := Copy(AName, PrevDot + 1, LastDot - PrevDot - 1);
  if Length(Digits) <> 4 then
    Exit;
  for I := 1 to 4 do
    if not (Digits[I] in ['0'..'9']) then
      Exit;
  ABase := Copy(AName, 1, PrevDot - 1);
  ACounter := StrToInt(Digits);
  Result := ABase <> '';
end;

/// <summary>Newest first; same time: higher counter first.</summary>
procedure SortNewestFirst(var AFiles: THCLAutosaveArray);
var
  I, J: Integer;
  T: THCLAutosave;
begin
  for I := 1 to High(AFiles) do
  begin
    T := AFiles[I];
    J := I - 1;
    while (J >= 0) and ((AFiles[J].Time < T.Time) or
          ((AFiles[J].Time = T.Time) and (AFiles[J].Counter < T.Counter))) do
    begin
      AFiles[J + 1] := AFiles[J];
      Dec(J);
    end;
    AFiles[J + 1] := T;
  end;
end;

function HCLScanAutosaves(const ADir: String): THCLAutosaveGroupArray;
var
  SR: TSearchRec;
  Dir, Base: String;
  Counter, G, I, J: Integer;
  F: THCLAutosave;
  T: THCLAutosaveGroup;
begin
  Result := nil;
  if not DirectoryExists(ADir) then
    Exit;
  Dir := IncludeTrailingPathDelimiter(ADir);
  if FindFirst(Dir + '*', faAnyFile and not faDirectory, SR) = 0 then
  try
    repeat
      if not HCLParseAutosaveName(SR.Name, Base, Counter) then
        Continue;
      F.Path := Dir + SR.Name;
      F.Name := SR.Name;
      F.Base := Base;
      F.Counter := Counter;
      F.Size := SR.Size;
      F.Time := FileDateToDateTime(SR.Time);

      G := -1;
      for I := 0 to High(Result) do
        if SameText(Result[I].Base, Base) then
          G := I;
      if G < 0 then
      begin
        SetLength(Result, Length(Result) + 1);
        G := High(Result);
        Result[G] := Default(THCLAutosaveGroup);
        Result[G].Base := Base;
      end;
      SetLength(Result[G].Files, Length(Result[G].Files) + 1);
      Result[G].Files[High(Result[G].Files)] := F;
    until FindNext(SR) <> 0;
  finally
    SysUtils.FindClose(SR);
  end;

  for G := 0 to High(Result) do
  begin
    SortNewestFirst(Result[G].Files);
    Result[G].TotalSize := HCLAutosaveBytes(Result[G].Files);
    Result[G].Newest := Result[G].Files[0].Time;
    Result[G].Oldest := Result[G].Files[High(Result[G].Files)].Time;
  end;

  // Groups by base name.
  for I := 1 to High(Result) do
  begin
    T := Result[I];
    J := I - 1;
    while (J >= 0) and (CompareText(Result[J].Base, T.Base) > 0) do
    begin
      Result[J + 1] := Result[J];
      Dec(J);
    end;
    Result[J + 1] := T;
  end;
end;

function HCLKeepNewest(const AGroup: THCLAutosaveGroup; AKeep: Integer): THCLAutosaveArray;
var
  I: Integer;
begin
  Result := nil;
  if AKeep < 0 then
    AKeep := 0;
  for I := AKeep to High(AGroup.Files) do
  begin
    SetLength(Result, Length(Result) + 1);
    Result[High(Result)] := AGroup.Files[I];
  end;
end;

function HCLOlderThan(const AGroup: THCLAutosaveGroup; ADays: Integer;
  ANow: TDateTime): THCLAutosaveArray;
var
  I: Integer;
begin
  Result := nil;
  for I := 0 to High(AGroup.Files) do
    if AGroup.Files[I].Time < ANow - ADays then
    begin
      SetLength(Result, Length(Result) + 1);
      Result[High(Result)] := AGroup.Files[I];
    end;
end;

function HCLAutosaveBytes(const AFiles: THCLAutosaveArray): Int64;
var
  I: Integer;
begin
  Result := 0;
  for I := 0 to High(AFiles) do
    Inc(Result, AFiles[I].Size);
end;

function HCLFindScenarioForBase(const AFileOrg: IHCLFileOrganization;
  const ABase: String): String;
var
  C: Integer;
  Base, Scens: String;
  Parents, Scen, Files: TSearchRec;
  Ext: String;
begin
  Result := '';
  if (AFileOrg = nil) or (ABase = '') then
    Exit;
  for C := Low(SCENARIO_PARENTS) to High(SCENARIO_PARENTS) do
  begin
    Base := IncludeTrailingPathDelimiter(AFileOrg.GetCategoryPath(SCENARIO_PARENTS[C]));
    if FindFirst(Base + '*', faDirectory, Parents) = 0 then
    try
      repeat
        if ((Parents.Attr and faDirectory) = 0) or (Parents.Name = '.') or (Parents.Name = '..') then
          Continue;
        Scens := Base + Parents.Name + PathDelim + 'Scenarios' + PathDelim;
        if FindFirst(Scens + '*', faDirectory, Scen) = 0 then
        try
          repeat
            if ((Scen.Attr and faDirectory) = 0) or (Scen.Name = '.') or (Scen.Name = '..') then
              Continue;
            if FindFirst(Scens + Scen.Name + PathDelim + '*', faAnyFile and not faDirectory, Files) = 0 then
            try
              repeat
                Ext := LowerCase(ExtractFileExt(Files.Name));
                if (Length(Ext) = 4) and (Copy(Ext, 1, 3) = '.sc') and
                   SameText(ChangeFileExt(Files.Name, ''), ABase) then
                  Exit(Scens + Scen.Name);
              until FindNext(Files) <> 0;
            finally
              SysUtils.FindClose(Files);
            end;
          until FindNext(Scen) <> 0;
        finally
          SysUtils.FindClose(Scen);
        end;
      until FindNext(Parents) <> 0;
    finally
      SysUtils.FindClose(Parents);
    end;
  end;
end;

function HCLParseBuiltinBase(const ABase: String; out ABattleSet, AScenario: Integer): Boolean;
var
  I: Integer;
begin
  ABattleSet := 0;
  AScenario := 0;
  Result := False;
  if (Length(ABase) <> 7) or (ABase[4] <> '-') then
    Exit;
  for I := 1 to 7 do
    if (I <> 4) and not (ABase[I] in ['0'..'9']) then
      Exit;
  ABattleSet := StrToInt(Copy(ABase, 1, 3));
  AScenario := StrToInt(Copy(ABase, 5, 3)) + 1;
  Result := True;
end;

{$IFDEF MSWINDOWS}
/// <summary>Sends one file to the Recycle Bin, silently.</summary>
function RecycleFile(const APath: String): Boolean;
var
  Op: TSHFileOpStructW;
  From: WideString;
begin
  // pFrom is a list ending in two NULs.
  From := UTF8Decode(APath) + #0#0;
  FillChar(Op, SizeOf(Op), 0);
  Op.wFunc := FO_DELETE;
  Op.pFrom := PWideChar(From);
  Op.fFlags := FOF_ALLOWUNDO or FOF_NOCONFIRMATION or FOF_SILENT or FOF_NOERRORUI;
  Result := (SHFileOperationW(@Op) = 0) and not Op.fAnyOperationsAborted and not FileExists(APath);
end;
{$ENDIF}

function HCLRemoveAutosaves(const AFiles: THCLAutosaveArray; AToRecycleBin: Boolean;
  out AError: String): Integer;
var
  I: Integer;
  Ok: Boolean;
begin
  Result := 0;
  AError := '';
  for I := 0 to High(AFiles) do
  begin
    {$IFDEF MSWINDOWS}
    if AToRecycleBin then
      Ok := RecycleFile(AFiles[I].Path)
    else
    {$ENDIF}
      Ok := SysUtils.DeleteFile(AFiles[I].Path);
    if Ok then
      Inc(Result)
    else if AError = '' then
      AError := 'Could not remove ' + AFiles[I].Path;
  end;
end;

function HCLMoveAutosaves(const AFiles: THCLAutosaveArray; const ADestDir: String;
  out AError: String): Integer;
var
  I: Integer;
  Target: String;
  InS, OutS: TFileStream;
  Ok: Boolean;
begin
  Result := 0;
  AError := '';
  if not ForceDirectories(ADestDir) then
  begin
    AError := 'Could not create ' + ADestDir;
    Exit;
  end;
  for I := 0 to High(AFiles) do
  begin
    Target := IncludeTrailingPathDelimiter(ADestDir) + AFiles[I].Name;
    if FileExists(Target) then
      SysUtils.DeleteFile(Target);
    Ok := RenameFile(AFiles[I].Path, Target);
    if not Ok then
    try
      // Another volume: copy, keep the time, then remove the original.
      InS := TFileStream.Create(AFiles[I].Path, fmOpenRead or fmShareDenyWrite);
      try
        OutS := TFileStream.Create(Target, fmCreate);
        try
          OutS.CopyFrom(InS, 0);
        finally
          OutS.Free;
        end;
      finally
        InS.Free;
      end;
      FileSetDate(Target, DateTimeToFileDate(AFiles[I].Time));
      Ok := SysUtils.DeleteFile(AFiles[I].Path);
    except
      on E: Exception do
        Ok := False;
    end;
    if Ok then
      Inc(Result)
    else if AError = '' then
      AError := 'Could not move ' + AFiles[I].Path;
  end;
end;

end.
