(*
   Parser.pas - Mapfile parser unit

   Copyright (C) 2013 - 2015 Martin Bittermann (martinbittermann@gmx.de)

   This file is part of ddrescueview.

   ddrescueview is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 3 of the License, or
   (at your option) any later version.

   ddrescueview is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with ddrescueview.  If not, see <http://www.gnu.org/licenses/>.
*)

// {$DEFINE VerboseProfiler}

unit Parser;

interface
uses Classes, Shared;

type
  TSimpleParser = class(TObservablePersistent)
  private
    FMap : TMap;
    FRescueStatus : TRescueStatus;
    FComments : TStringList; // comment lines of mapfile
    FVersion : String; // ddrescue version in first comment line / for future use?
    FMapStream : TStream;
    FFileName : String;
    procedure logMsg(msg : String);
    function isEmptyLine(strLine : String) : boolean;
    function isCommentLine(strLine : String) : boolean;
  public
    constructor Create;
    destructor Destroy; override;
    procedure OpenFile(filename : String);
    procedure CloseFile;
    procedure parse();
    function hasFile() : boolean;
    property rescueStatus : TRescueStatus read FRescueStatus;
    property map : TMap read FMap;
    property CommentLines : TStringList read FComments;
    property Version : String read FVersion;
    property MapFileName :String read FFileName;
  end;

  // This parser can also embed a domain mapfile parser
  TMapParser = class(TObservablePersistent)
  private
    FMapParser : TSimpleParser;
    FDomParser : TSimpleParser;
    FMap: TMap;
    FRescueStatus : TRescueStatus;
    FContiguous : Boolean;
    FHasFile : Boolean;
    function getMap(): TMap;
    procedure postParse();
    procedure setContiguous(mode: Boolean);
  public
    constructor Create;
    destructor Destroy; override;
    procedure OpenFile(filename : String);
    procedure OpenDomainFile(filename : String);
    procedure CloseFile;
    procedure parse();
    function hasFile() : boolean;
    function hasDomFile() : Boolean;
    property rescueStatus : TRescueStatus read FRescueStatus;
    property map : TMap read getMap;
    property CommentLines : TStringList read FMapParser.FComments;
    property Version : String read FMapParser.FVersion;
    property MapFileName : String read FMapParser.FFileName;
    property DomFileName : String read FDomParser.FFileName;
    property ContiguousDomain : Boolean read FContiguous write setContiguous;
  end;

implementation
uses SysUtils, Math, GUI;

{ TMapParser }

constructor TMapParser.Create;
begin
  inherited;
  FRescueStatus := emptyRescueStatus;
  FMapParser:=TSimpleParser.Create;
  FDomParser:=TSimpleParser.Create;
end;

destructor TMapParser.Destroy;
begin
  CloseFile();
  FreeAndNil(FDomParser);
  FreeAndNil(FMapParser);
  inherited Destroy;
end;

procedure TMapParser.OpenFile(filename: String);
begin
  CloseFile;
  FMapParser.OpenFile(filename);
  postParse;
end;

procedure TMapParser.OpenDomainFile(filename: String);
begin
  FDomParser.OpenFile(filename);
  if FDomParser.hasFile and FContiguous then begin
    FHasFile:=false;
    FPONotifyObservers(self, ooDeleteItem, nil);
  end;
  postParse;
end;

procedure TMapParser.CloseFile;
begin
  FDomParser.CloseFile;
  FMapParser.CloseFile;
  FRescueStatus := emptyRescueStatus;
  SetLength(FMap, 0);
  FHasFile:=false;
  FPONotifyObservers(self, ooDeleteItem, nil);
end;

procedure TMapParser.postParse;
var
  ResMap, DomMap: TMap;
  i, iRes, iDom : Longint;
  newEntry : TMapEntry;
  mapCurOffset : Int64;
begin
  if FDomParser.hasFile then begin // post-process the maps
    FRescueStatus:=emptyRescueStatus;
    ResMap:=FMapParser.map;
    DomMap:=FDomParser.map;
    //build map and rescuestatus
    i:=0; iRes:=0; iDom:=0; // current map indices
    mapCurOffset:=0; // needed for contiguous mode, but also used for normal mode
    while iDom < Length(DomMap) do begin
      if (DomMap[iDom].status<>'+') then begin // outside domain entry
        if not FContiguous then begin
          newEntry.status:='d';
          newEntry.offset:=DomMap[iDom].offset;
          newEntry.length:=DomMap[iDom].length;
          inc(FRescueStatus.outsidedomain, DomMap[iDom].length);
          mapCurOffset:=newEntry.offset+newEntry.length;
          if InRange(FMapParser.rescueStatus.pos, newEntry.offset, mapCurOffset-1) then
             FRescueStatus.pos:=FMapParser.rescueStatus.pos;
          if Length(FMap) <= i then SetLength(FMap, i+1024);
          FMap[i]:=newEntry;
          Inc(i);
        end;
      end else begin // iterate over the ResEntries intersecting current DomEntry
        while iRes < Length(ResMap) do begin
          // increase iRes until intersecting dom block
          if ResMap[iRes].offset+ResMap[iRes].length <= DomMap[iDom].offset then begin
            Inc(iRes);
            Continue;
          end;
          // if intersecting current dom block, create new block
          newEntry:=IntersectEntries(ResMap[iRes], DomMap[iDom]);
          if newEntry.length > 0 then begin //can be 0 for rescue mapfiles with gaps
            if InRange(FMapParser.rescueStatus.pos, newEntry.offset,
                       newEntry.offset+newEntry.length-1) then
              FRescueStatus.pos:=mapCurOffset+FMapParser.rescueStatus.pos-newEntry.offset;
            newEntry.offset:=mapCurOffset;
            mapCurOffset+=newEntry.length;
            if Length(FMap) <= i then SetLength(FMap, i+1024);
            FMap[i]:=newEntry;
            Inc(i);
            case newEntry.status of
              '?' : inc(FRescueStatus.nontried, newEntry.length);
              '+' : inc(FRescueStatus.rescued, newEntry.length);
              '*' : inc(FRescueStatus.nontrimmed, newEntry.length);
              '/' : inc(FRescueStatus.nonscraped, newEntry.length);
              '-' : begin
                      inc(FRescueStatus.bad, newEntry.length);
                      inc(FRescueStatus.errors);
                    end;
            end;
          end;
          // ensure iRes is set to last intersecting entry
          if ResMap[iRes].offset+ResMap[iRes].length >=
             DomMap[iDom].offset+DomMap[iDom].length then break;
          Inc(iRes);
        end;
      end;
      Inc(iDom);
    end;
    SetLength(FMap, i);
    FRescueStatus.curOperation:=FMapParser.rescueStatus.curOperation;
    FRescueStatus.strCurOperation:=FMapParser.rescueStatus.strCurOperation;
    if i > 0 then begin  // calculate device's size
      FRescueStatus.devicesize:=FMap[i-1].offset + FMap[i-1].length;
    end;
    FRescueStatus.suggestedBlockSize:=FMapParser.rescueStatus.suggestedBlockSize;
  end else begin
    FRescueStatus:=FMapParser.rescueStatus;
  end;
  if (not FHasFile) and FMapParser.hasFile then begin  // just been opened
    FHasFile:=true;
    FPONotifyObservers(self, ooAddItem, nil);
  end else FPONotifyObservers(self, ooChange, nil)
end;

procedure TMapParser.setContiguous(mode: Boolean);
begin
  if mode <> FContiguous then begin
    FContiguous:=mode;
    if FDomParser.hasFile then begin
      FHasFile:=false;
      FPONotifyObservers(self, ooDeleteItem, nil);
      postParse;
    end;
  end;
end;

procedure TMapParser.parse;
begin
  if FMapParser.hasFile() then FMapParser.parse();
  if FDomParser.hasFile() then FDomParser.parse();
  postParse;
end;

function TMapParser.hasFile: boolean;
begin
  result:=FHasFile;
end;

function TMapParser.hasDomFile: Boolean;
begin
  result:=FHasFile and FDomParser.hasFile;
end;

function TMapParser.getMap: TMap;
begin
  if not FDomParser.hasFile then result:=FMapParser.map
  else result:=FMap;
end;


{ TSimpleParser }

constructor TSimpleParser.Create;
begin
  inherited;
  FRescueStatus := emptyRescueStatus;
end;

destructor TSimpleParser.Destroy;
begin
  CloseFile();
  inherited;
end;

procedure TSimpleParser.OpenFile(filename : String);
begin
  try
    CloseFile(); // close already open file
    // open the mapfile using shared read access, so ddrescue can still write to it.
    FMapStream := TFileStream.Create(filename, fmOpenRead or fmShareDenyNone);
    FComments := TStringList.Create;
    FFileName := filename;
    parse();  // start parsing after opening
  except
    on E: Exception do begin
        CloseFile;
        logMsg('Parser error: '+E.Message);
    end;
  end;
end;

procedure TSimpleParser.CloseFile;
begin
  FFileName := '';
  FreeAndNil(FComments);
  FreeAndNil(FMapStream);
  FRescueStatus := emptyRescueStatus;
  SetLength(FMap, 0);
  FPONotifyObservers(self, ooDeleteItem, nil);
end;

(* returns true if a line is empty, assuming the line is already trimmed *)
function TSimpleParser.isEmptyLine(strLine : String) : boolean;
begin
  result := (Length(strLine) = 0); // empty line
end;

(* returns true if a line is a comment line, assuming the line is already trimmed *)
function TSimpleParser.isCommentLine(strLine : String) : boolean;
begin
  result := (Length(strLine) > 0) and (strLine[1] = '#'); // comment line
end;

(* add a log line to the application log *)
procedure TSimpleParser.logMsg(msg : String);
begin
  MainForm.DbgLog.Lines.Add(msg);
end;

(* Parse the mapfile.
   After parsing, the results are available in
   these properties: map, rescueStatus and Comments *)
procedure TSimpleParser.parse();
var
  line : string;
  token : array[0..2] of string;
  i, mapEntry, lineIdx, idx : Integer;
  mapStrings : TStringList = nil;
  prevHadFile, statuslineFound : boolean;
  {$IFDEF VerboseProfiler}
  lTimeStart: TDateTime;
  {$ENDIF}
begin
  // make sure the file is open
  if not hasFile then begin
    logMsg('Parser: No mapfile opened.');
    FPONotifyObservers(self, ooChange, nil);
    exit;
  end;
  {$IFDEF VerboseProfiler}
  lTimeStart := Now();
  {$ENDIF}
  // read file contents into string list
  try
    mapStrings := TStringList.Create;
    if FMapStream.Seek(0, soFromBeginning) <> 0 then
      raise Exception.Create('Seek error!');
    mapStrings.LoadFromStream(FMapStream);
    if (FMapStream.Size = 0) or (mapStrings.Count = 0) then begin
      logMsg('Parser: mapfile seems empty, trying to reopen.');
      FreeAndNil(FMapStream);
      FMapStream := TFileStream.Create(FFilename, fmOpenRead or fmShareDenyNone);
      FMapStream.Seek(0, soFromBeginning);
      mapStrings.LoadFromStream(FMapStream);
    end;
    logMsg('Reading mapfile: ' +IntToStr(mapStrings.Count) + ' lines.');
  except
    on E : Exception do begin
      logMsg('Error: Cannot read mapfile: '+E.Message+'('+E.ClassName+')');
      FreeAndNil(mapStrings);
      FPONotifyObservers(self, ooChange, nil);
      exit;
    end;
  end;

  {$IFDEF VerboseProfiler}
  logMsg(Format('[TSimpleParser.parse] File load Duration: %d ms', [DateTimeToMilliseconds(Now() - lTimeStart)]));
  lTimeStart := Now();
  {$ENDIF}

  // reserve space in the map. Less entries will be actually needed, due
  // to comment lines and the status line being counted.
  SetLength(FMap, mapStrings.Count);
  // initialize many things to their initial values
  FRescueStatus := emptyRescueStatus;
  FComments.Clear;
  FVersion:='';
  lineIdx := 0;
  mapEntry := 0;
  prevHadFile := Length(FMap) > 0;
  statuslineFound := false;

  // parse the mapfile lines into map array, rescueStatus and Comments.
  try
    while lineIdx < mapStrings.Count do begin
      line:=Trim(mapStrings[lineIdx]);
      if isCommentLine(line) then begin
        if not statuslineFound then begin //process comments only until the status line is found
          // copy comment line into FComments (but not the #currentpos one...)
          if pos('# current_pos', line) = 0 then FComments.Add(line);
          if pos('Command line:', line) > 0 then begin // try to find ddrescue's block size argument
             repeat // not a loop, but goto replacement
               idx := pos(' -b', line);
               if idx <> 0 then idx := idx+3 // point to start of number
               else begin
                 idx := pos(' --block-size=', line);
                 if idx <> 0 then idx := idx+14 // point to start of number
                 else break; // argument not found, jump over the rest
               end;
               token[0] :=TrimLeft(Copy(line, idx, Length(line)));
               idx := pos(' ', token[0]); //space after argument
               if idx <> 0 then token[0] :=Copy(token[0], 1, idx-1);
               FRescueStatus.suggestedBlockSize:=StrToIntDef(token[0], DEF_BSIZE);
             until true;
          end;
          idx:=pos('ddrescue version ', line);  // get ddrescue version
          if idx > 0 then FVersion := Copy(line, idx+17, 1337);
        end;
      end else if Length(line) > 0 then begin
        // split line into maximum of 3 tokens
        for i:= 0 to 2 do begin
          idx:= Pos(' ', line); //get pos of next delimiter
          if idx = 0 then begin //if no more delimiter...
            token[i] := line; //...the whole remainder of line is ours!
            line := '';
          end else begin   // otherwise our token = line until the pos of delimiter
            token[i] := Copy(line, 1, idx-1);
            line := TrimLeft(Copy(line, idx, Length(line)-idx+1));
          end;
        end;
        if (not statuslineFound) and (token[0] <> '') and (token[1] <> '') then begin
          // found the status line
          FRescueStatus.pos:=StrToInt64Def(token[0], 0);
          FRescueStatus.curOperation:=token[1][1];
          if token[2] <> '' then FRescueStatus.current_pass:=StrToIntDef(token[2], 0);
          FRescueStatus.strCurOperation:=OperationToText(FRescueStatus.curOperation, FRescueStatus.current_pass);
          statuslineFound := true;
        end else if (token[0] <> '') and (token[1] <> '') and (token[2] <> '') then begin
          // standard block line
          FMap[mapEntry].offset:=StrToInt64(token[0]);
          FMap[mapEntry].length:=StrToInt64(token[1]);
          FMap[mapEntry].status:=token[2][1];
          case FMap[mapEntry].status of
            '?' : inc(FRescueStatus.nontried, FMap[mapEntry].length);
            '+' : inc(FRescueStatus.rescued, FMap[mapEntry].length);
            '*' : inc(FRescueStatus.nontrimmed, FMap[mapEntry].length);
            '/' : inc(FRescueStatus.nonscraped, FMap[mapEntry].length);
            '-' : begin
                    inc(FRescueStatus.bad, FMap[mapEntry].length);
                    inc(FRescueStatus.errors);
                  end;
          end;
          inc(mapEntry);
        end else begin // unknown type or formatting of line
          logMsg('Parser: Cannot parse line '+inttostr(lineIdx));
        end;
      end;
      lineIdx+=1;
    end;
    SetLength(FMap, mapEntry); // trim array to actually needed size
  except
    on E : Exception do begin
      logMsg('Error parsing mapfile on line '+IntToStr(lineIdx+1)+': '+
             E.Message+'('+E.ClassName+')');
      FRescueStatus := emptyRescueStatus; // some fail-safe values
      FRescueStatus.strCurOperation:='PARSER ERROR';
      SetLength(FMap, 1);
      FMap[0].offset:=0;
      FMap[0].length:=DEF_BSIZE*(1 shl 18); // one block of 2^18 bad sectors
      FMap[0].status:='-';
      FRescueStatus.bad:=FMap[0].length;
      FRescueStatus.devicesize:=FMap[0].length;
    end;
  end;
  FreeAndNil(mapStrings);

  {$IFDEF VerboseProfiler}
  logMsg(Format('[TSimpleParser.parse] Parse Duration: %d ms', [DateTimeToMilliseconds(Now() - lTimeStart)]));
  {$ENDIF}

  // notify listeners
  if Length(FMap) > 0 then begin
    // calculate device's size from last block's offset and length
    FRescueStatus.devicesize:=FMap[Length(FMap)-1].offset + FMap[Length(FMap)-1].length;
    if prevHadFile then FPONotifyObservers(self, ooChange, nil)
      else FPONotifyObservers(self, ooAddItem, nil); // notify of new file
  end else begin
    logMsg('Parser: No blocks in mapfile!');
    FPONotifyObservers(self, ooChange, nil); // notify any observers of the changes
  end;
end;

function TSimpleParser.hasFile: boolean;
begin
  hasFile:=Assigned(FMapStream);
end;

end.
