--
--   FMCTipTrigger with delayed fruit-conversion ability
--
-- @author  Decker    (ls-uk.info, Decker_MMIV)
-- @date    (started) 2011-November
--          (resumed) 2012-August
--
-- @comment Found problems or bugs? Please post them in the www.ls-uk.info support-thread for this mod. Thank you.
--          This mod is placed in the download section 'Maps -> Triggers', which at time of writing is; http://www.ls-uk.info/downloads/186
--
-- @history v0.2(alpha) - First release for internal testing at FMC.
--          v0.3(alpha) - Redone triggers, inherit from TipTrigger. Still no multiplayer support. Still no proper GUI.
--          v0.4(alpha) - Some simple UNTESTED multiplayer event added, to update values at the clients.
--          v0.41(alpha)- Initial MP testing. Some flaws found.
--      2012-August
--          v0.5(alpha) - Renamed from 'Brewery' to 'FMCTipTrigger'.
--                      - Made better GUI, by adding subpages to the PDA 'Weather'-page.
--          v0.51(alpha)- Stumpled upon some useful information on http://gdn.giants-software.com/thread.php?categoryId=16&threadId=556
--          v0.53(beta) - Renamed value for user-attribute 'scriptCallback' to 'modOnCreate.onCreateFMCTipTrigger'
--      2012-september
--          v0.54(beta) - Fixed problem of 'completionTimeMS' being nil and trying to write that to networkStream. Thank you Napalm & FarmerYip for discovering this bug.
--          v0.55(beta) - Added optional 'destStationName' user-attribute, which could point to another FMCTipTrigger where the output should be delivered to.
--          v0.56(beta) - Option to take input from a farm-silo, by writing "silo:<fruitname>" in the fruitTypes user-attribute.
--      2012-october
--          v0.57(beta) - Problem with "multiple triggers, one groupId" hopefully fixed.
--                      - Fix for clients not seeing storage amounts.
--                      - Fix for clients seeing more than "Working 100%", due to host may have "paused" the game.
--                      - Send update/refresh every 300 seconds (5 minutes) to clients.
--          v0.58(beta) - Added 'pageSortOrder' user-attribute, to be able to keep specific ordering of 'Processing Status' sub-pages.
--          v0.59(beta) - Removed (commented out) all log statements.
--


--[[

MAP01.I3D requirements for "FMC tip trigger":

  <UserAttribute nodeId="....">
    <!-- The LUA-script callback -->
    <Attribute name="onCreate"    type="scriptCallback" value="modOnCreate.onCreateFMCTipTrigger"/>  <!-- REQUIRED -->

    <!-- TipTrigger attributes (same as normal TipTrigger) -->
    <Attribute name="stationName"       type="string"   value="Acme Brewing"/>             <!-- REQUIRED, name of the 'group', and will appear in PDA 'Prices'-page if so enabled -->
    <Attribute name="appearsOnPDA"      type="boolean"  value="true"/>                     <!-- optional, true/false to show in PDA 'Prices'-page -->
    <Attribute name="fruitTypes"        type="string"   value="wheat barley silo:water"/>  <!-- REQUIRED, fruits needed for conversion -->
                                                                                           <!--     Note: If fruit should be taken from a farm-silo, then put "silo:" in front of fruit-name. -->
    <Attribute name="priceMultipliers"  type="string"   value="0.0 0.0 0.0"/>              <!-- REQUIRED, same number of elements as in "fruitTypes" -->

    <!-- Fruit conversion attributes -->
    <Attribute name="pageSortOrder"     type="integer"  value="42" />                   <!-- optional, sort order of the groups/station-names in 'Processing Status' sub-pages in PDA -->
    <Attribute name="inputAmounts"      type="string"   value="1000 1000 3500" />       <!-- REQUIRED, same number of elements as in "fruitTypes", minimum amounts before process begins -->
    <Attribute name="startIntervalSecs" type="integer"  value="30" />                   <!-- optional, seconds interval to check if conversion can begin -->
    <Attribute name="convertTimeSecs"   type="integer"  value="300" />                  <!-- REQUIRED, number of seconds the process takes -->
    <Attribute name="outputAmount"      type="integer"  value="1000" />                 <!-- REQUIRED, output amount after conversion -->
    <Attribute name="convertToFruit"    type="string"   value="beer" />                 <!-- REQUIRED, fruitType to convert to -->
    
    <Attribute name="destStationName"   type="string"   value="<stationName>" />        <!-- optional, station-name of other (FMC)TipTrigger, where output should be added to. -->
                                                                                        <!--           If not given or empty, the output will be added to a farm-silo. -->

    <!-- Move plane attributes (same as normal TipTrigger) -->
    <Attribute name="movingIndex"       type="string"   value="0"/>       <!-- optional -->
    <Attribute name="moveBackTime"      type="float"    value="120000"/>  <!-- optional -->
    <Attribute name="moveMaxY"          type="float"    value="0.6"/>     <!-- optional -->
    <Attribute name="moveMinY"          type="float"    value="0"/>       <!-- optional -->
    <Attribute name="moveScale"         type="float"    value="0.004"/>   <!-- optional -->
  </UserAttribute>

]]--

----------------
----------------
---- network-stream-tracing-overloads
NetTrace = {};
function NetTrace:newSession()
  self.dbg="";
end;
function NetTrace:endSession()
  return self.dbg;
end;

--[[
-- Network stream... functions WITH tracing
function NetTrace:streamWriteBool(streamId,val)                      streamWriteBool(streamId,val);          self.dbg=self.dbg..",b:"..tostring(val);                                        end;
function NetTrace:streamWriteString(streamId,val)                    streamWriteString(streamId,val);        self.dbg=self.dbg..",s:"..tostring(val);                                        end;
function NetTrace:streamWriteInt8(streamId,val)                      streamWriteInt8(streamId,val);          self.dbg=self.dbg..",i8:"..tostring(val);                                       end;
function NetTrace:streamWriteInt16(streamId,val)                     streamWriteInt16(streamId,val);         self.dbg=self.dbg..",i16:"..tostring(val);                                      end;
function NetTrace:streamWriteInt32(streamId,val)                     streamWriteInt32(streamId,val);         self.dbg=self.dbg..",i32:"..tostring(val);                                      end;
function NetTrace:streamWriteIntN(streamId,val,numbits)              streamWriteIntN(streamId,val,numbits);  self.dbg=self.dbg..string.format(",i%d:",numbits)..tostring(val);               end;
function NetTrace:streamWriteUInt8(streamId,val)                     streamWriteUInt8(streamId,val);         self.dbg=self.dbg..",u8:"..tostring(val);                                       end;
function NetTrace:streamWriteUIntN(streamId,val,numbits)             streamWriteUIntN(streamId,val,numbits); self.dbg=self.dbg..string.format(",u%d",numbits)..tostring(val);                end;
function NetTrace:streamWriteFloat32(streamId,val)                   streamWriteFloat32(streamId,val);       self.dbg=self.dbg..",f:"..tostring(val);                                        end;
function NetTrace:streamReadBool(streamId)               local val = streamReadBool(streamId);               self.dbg=self.dbg..",b:"..tostring(val);                            return val; end;
function NetTrace:streamReadString(streamId)             local val = streamReadString(streamId);             self.dbg=self.dbg..",s:"..tostring(val);                            return val; end;
function NetTrace:streamReadInt8(streamId)               local val = streamReadInt8(streamId);               self.dbg=self.dbg..",i8:"..tostring(val);                           return val; end;
function NetTrace:streamReadInt16(streamId)              local val = streamReadInt16(streamId);              self.dbg=self.dbg..",i16:"..tostring(val);                          return val; end;
function NetTrace:streamReadInt32(streamId)              local val = streamReadInt32(streamId);              self.dbg=self.dbg..",i32:"..tostring(val);                          return val; end;
function NetTrace:streamReadIntN(streamId,numbits)       local val = streamReadIntN(streamId,numbits);       self.dbg=self.dbg..string.format(",i%d:",numbits)..tostring(val);   return val; end;
function NetTrace:streamReadUInt8(streamId)              local val = streamReadUInt8(streamId);              self.dbg=self.dbg..",u8:"..tostring(val);                           return val; end;
function NetTrace:streamReadUIntN(streamId,numbits)      local val = streamReadUIntN(streamId,numbits);      self.dbg=self.dbg..string.format(",u%d:",numbits)..tostring(val);   return val; end;
function NetTrace:streamReadFloat32(streamId)            local val = streamReadFloat32(streamId);            self.dbg=self.dbg..",f:"..tostring(val);                            return val; end;
]]

-- Network stream... functions WITHOUT tracing
function NetTrace:streamWriteBool(streamId,val)                      streamWriteBool(streamId,val);            end;
function NetTrace:streamWriteString(streamId,val)                    streamWriteString(streamId,val);          end;
function NetTrace:streamWriteInt8(streamId,val)                      streamWriteInt8(streamId,val);            end;
function NetTrace:streamWriteInt16(streamId,val)                     streamWriteInt16(streamId,val);           end;
function NetTrace:streamWriteInt32(streamId,val)                     streamWriteInt32(streamId,val);           end;
function NetTrace:streamWriteIntN(streamId,val,numbits)              streamWriteIntN(streamId,val,numbits);    end;
function NetTrace:streamWriteUInt8(streamId,val)                     streamWriteUInt8(streamId,val);           end;
function NetTrace:streamWriteUIntN(streamId,val,numbits)             streamWriteUIntN(streamId,val,numbits);   end;
function NetTrace:streamWriteFloat32(streamId,val)                   streamWriteFloat32(streamId,val);         end;
function NetTrace:streamReadBool(streamId)                    return streamReadBool(streamId);                 end;
function NetTrace:streamReadString(streamId)                  return streamReadString(streamId);               end;
function NetTrace:streamReadInt8(streamId)                    return streamReadInt8(streamId);                 end;
function NetTrace:streamReadInt16(streamId)                   return streamReadInt16(streamId);                end;
function NetTrace:streamReadInt32(streamId)                   return streamReadInt32(streamId);                end;
function NetTrace:streamReadIntN(streamId,numbits)            return streamReadIntN(streamId,numbits);         end;
function NetTrace:streamReadUInt8(streamId)                   return streamReadUInt8(streamId);                end;
function NetTrace:streamReadUIntN(streamId,numbits)           return streamReadUIntN(streamId,numbits);        end;
function NetTrace:streamReadFloat32(streamId)                 return streamReadFloat32(streamId);              end;

----------------
----------------

FMCConversionController = {};
FMCConversionController.groups = {};
FMCConversionController.idxToGroupId = {}
FMCConversionController.pdaSubpage = 0;


--function FMCConversionController.log(txt)
--  print(string.format("%7ums FMCConversionController ", g_currentMission.time) .. txt);
--end;

function fileExist(pathAndFilename)
    local rb = false;
    local fileHandle = io.open(pathAndFilename, "r");
    if (fileHandle ~= nil) then
        fileHandle:close();
        rb = true;
    end;
    return rb;
end;

function FMCConversionController:loadMap(name)
  if self.initialized then
    return;
  end;
  --FMCConversionController.log("FMCConversionController:loadMap("..name..")  g_currentMission.time="..tostring(g_currentMission.time));
  --
  if g_server then
    FMCConversionController.savegameFilename = g_currentMission.missionInfo.savegameDirectory.."/FMCTipTriggers.xml";
    if g_currentMission.missionInfo.isValid then -- Is this a previous savegame thats loaded?
      if (fileExist(FMCConversionController.savegameFilename)) then
        FMCConversionController:loadGroups();
      end;
    end;
    -- Find destination stations
    for _,grp in pairs(FMCConversionController.groups) do
      if grp.output.destStationName ~= nil then
        for _,tipTrig in pairs(g_currentMission.tipTriggers) do
          if (grp.output.destStationName == tipTrig.stationName) then
            --FMCConversionController.log("Found destination name   TipTrigger:"..tostring(tipTrig.stationName));
            if (tipTrig:isa(TipTrigger)) then
              --FMCConversionController.log("Found destination object TipTrigger:"..tostring(tipTrig.stationName));
              grp.output.destObject = tipTrig;
            end;
            break;
          end
        end;
      end;
    end;
  end;
  --
  table.sort( -- v0.58
    FMCConversionController.idxToGroupId
    ,function(l,r)
      return FMCConversionController.groups[l].timings.pageSortOrder < FMCConversionController.groups[r].timings.pageSortOrder
    end
  );
  --
  self.initialized = true;
end;

function FMCConversionController:deleteMap()
  --FMCConversionController.log("FMCConversionController:deleteMap()");
  --
  FMCConversionController.groups = {};
  FMCConversionController.idxToGroupId = {}
  FMCConversionController.pdaSubpage = 0;
  --
  self.initialized = false;
end;

function FMCConversionController:loadGroups()
  --FMCConversionController.log(string.format("FMCConversionController:loadGroups()"));

  local rootTag = "FMCTipTriggers";
  local xmlFile = loadXMLFile(rootTag, FMCConversionController.savegameFilename);
  --
  local i=0;
  while (true) do
    local tag = string.format(rootTag..".FMCTipTrigger(%d)", i);
    local groupId = getXMLString(xmlFile, tag.."#name");
    if (groupId == nil) then
      break;
    end;
    local grp = FMCConversionController.groups[groupId];
    if (grp ~= nil) then
      local tag2 = tag..".output";
      local remainTime = getXMLInt(xmlFile, tag2.."#remainTime");
      if (remainTime ~= nil) then
        grp.output.completionTimeMS = g_currentMission.time + remainTime;
      end;
      --
      local j=0;
      while (true) do
        tag2 = tag..string.format(".input(%d)", j);
        local fillTypeStr = getXMLString(xmlFile, tag2.."#filltype");
        if (fillTypeStr == nil) then
          break;
        end;
        local fillType = Fillable.fillTypeNameToInt[fillTypeStr];
        if (fillType == nil) then
          --FMCConversionController.log(string.format("ERROR: Invalid-or-unknown filltype: %s", fillTypeStr));
          print(string.format("ERROR [FMCTipTrigger] Invalid-or-unknown filltype: %s", fillTypeStr));
          break;
        end;
        if (grp.inputs[fillType].useFarmSilo ~= true) then
          local currentAmount = Utils.getNoNil(getXMLInt(xmlFile, tag2.."#currentAmount"), 0);
          grp.inputs[fillType].currentAmount = currentAmount;
        end;
        j=j+1;
      end;
    end;
    i=i+1;
  end;
  --
  delete(xmlFile);
end;

function FMCConversionController:saveGroups()
  --FMCConversionController.log(string.format("FMCConversionController:saveGroups()"));

  local rootTag = "FMCTipTriggers";
  local xmlFile = createXMLFile(rootTag, FMCConversionController.savegameFilename, rootTag);
  --
  local i=0;
  for grpId,grp in pairs(FMCConversionController.groups) do
    local tag = string.format(rootTag..".FMCTipTrigger(%d)", i);
    setXMLString(xmlFile, tag.."#name", tostring(grpId));

    local j=0;
    for fillType,inputs in pairs(grp.inputs) do
      local tag2 = tag..string.format(".input(%d)", j);
      setXMLString(xmlFile, tag2.."#filltype", Fillable.fillTypeIntToName[fillType]);
      if (inputs.useFarmSilo ~= true) then
        setXMLInt(xmlFile, tag2.."#currentAmount", inputs.currentAmount);
      end;
      j=j+1;
    end;

    if (grp.output.completionTimeMS ~= 0) then
      local tag2 = tag..".output";
      setXMLInt(xmlFile, tag2.."#remainTime", grp.output.completionTimeMS - g_currentMission.time);
    end;

    i=i+1;
  end;
  --
  saveXMLFile(xmlFile);   -- TODO - How to determine if the file was saved or not?
  delete(xmlFile);
end;

-- Add our save-function to the career-screen's save function.
CareerScreen.saveSelectedGame = Utils.appendedFunction(CareerScreen.saveSelectedGame, FMCConversionController.saveGroups);


--function FMCConversionController:delete()
--end;

function FMCConversionController:mouseEvent(posX, posY, isDown, isUp, button)
end;

function FMCConversionController:keyEvent(unicode, sym, modifier, isDown)
end;


--[[
function FMCConversionController:readStream(streamId, connection)
  --local isServer = connection:getIsServer();
  FMCConversionController.log(string.format("readStream(%s, %s) isServer=%s", tostring(streamId), tostring(connection), tostring(isServer)));
end;

function FMCConversionController:writeStream(streamId, connection)
  --local isServer = connection:getIsServer();
  FMCConversionController.log(string.format("writeStream(%s, %s) isServer=%s", tostring(streamId), tostring(connection), tostring(isServer)));

  -- Does not work...
  ---- I assume this writeStream() function is called, when a client joins the server.
  ---- So mark all groups as dirty, so an update-event is sent.
  --for grpId,grp in pairs(FMCConversionController.groups) do
  --  grp.nextUpdateTimeMS = g_currentMission.time;
  --end;
end;
]]

--[[
function FMCConversionController:readUpdateStream(streamId, timestamp, connection)
  --local isServer = connection:getIsServer();
  --FMCConversionController.log(string.format("readUpdateStream(%s, %s, %s) isServer=%s", tostring(streamId), tostring(timestamp), tostring(connection), tostring(isServer)));
end;

function FMCConversionController:writeUpdateStream(streamId, connection, dirtyMask)
  --local isServer = connection:getIsServer();
  --FMCConversionController.log(string.format("writeUpdateStream(%s, %s, %s) isServer=%s", tostring(streamId), tostring(connection), tostring(dirtyMask), tostring(isServer)));
end;
]]

function FMCConversionController:update(dt)
    if g_server ~= nil then
        -- Only the server must do this.
        FMCConversionController.process();
    end;

    -- Added sub-pages to PDA page-2 (Weather), so we need to toggle through them.
    if (g_currentMission.missionPDA.showPDA) and InputBinding.hasEvent(InputBinding.TOGGLE_PDA_ZOOM) then
        if (g_currentMission.missionPDA.screen == 2) then
            FMCConversionController.pdaSubpage = (FMCConversionController.pdaSubpage + 1) % (table.getn(FMCConversionController.idxToGroupId)+1);
            playSample(g_currentMission.missionPDA.pdaBeepSound, 1, 0.5, 0);
        end;
    end;
end;

function FMCConversionController:draw()
end;

function FMCConversionController.process()
  for grpId,grp in pairs(FMCConversionController.groups) do
    if (grp.output.completionTimeMS ~= 0) then
      -- This 'group' is currently processing a batch of ingredients.
      if (grp.output.completionTimeMS < g_currentMission.time) then
        -- Processing finished, so add resulting amount to silo.
        --FMCConversionController.log(string.format("Group#%s, convert result; %d units of %s", tostring(grpId), grp.output.outputAmount, Fillable.fillTypeIntToName[grp.output.fillType]));
        
        -- TODO: Should converted be added to something else, than standard silos?
        if (grp.output.destObject ~= nil) then
          --FMCConversionController.log(string.format("Group#%s, adding %d units of %s to:%s", tostring(grpId), grp.output.outputAmount, Fillable.fillTypeIntToName[grp.output.fillType], tostring(grp.output.destStationName)));
          FMCTipTrigger.updateMoving(grp.output.destObject, grp.output.outputAmount, grp.output.fillType); -- v0.55(beta)
        else
          --FMCConversionController.log(string.format("Group#%s, adding %d units of %s to:farm-silo", tostring(grpId), grp.output.outputAmount, Fillable.fillTypeIntToName[grp.output.fillType]));
          g_currentMission:setSiloAmount(grp.output.fillType, g_currentMission:getSiloAmount(grp.output.fillType) + grp.output.outputAmount);
        end;
        
        grp.output.completionTimeMS = 0;
        -- TODO: 'pdaDisplay...' is not MP ready, so only the hosting server gets to see this.
        grp.output.pdaDisplayText = string.format(g_i18n:getText("ConvertResult"), grp.output.outputAmount, Fillable.fillTypeIntToName[grp.output.fillType]);
        grp.output.pdaDisplayTime = g_currentMission.time;
        --
        grp.nextUpdateTimeMS = g_currentMission.time;
      end;
    elseif (grp.timings.nextCheckMS < g_currentMission.time) then
      -- Check-interval was hit, so check if there is enough ingredients to start a new batch.
      grp.timings.nextCheckMS = g_currentMission.time + grp.timings.startIntervalMS;
      -- Update amount from farm-silos
      for fillType,inputs in pairs(grp.inputs) do -- v0.56
        if inputs.useFarmSilo == true then 
          local curAmount = Utils.getNoNil(g_currentMission:getSiloAmount(fillType), 0);
          if (inputs.currentAmount ~= curAmount) then
            inputs.currentAmount = curAmount;
            -- Set dirty-flag, to indicate that an update-event must be sent to clients soon.
            grp.nextUpdateTimeMS = g_currentMission.time;
          end;
        end
      end
      -- Check if there is enough
      local canStart = true;
      for fillType,inputs in pairs(grp.inputs) do
        if inputs.requiredAmount > inputs.currentAmount then
          -- Not enough of the ingredient, so can not start a new batch.
          canStart = false;
          break;
        end
      end;
      if canStart then
        -- There was enough of all ingredients, so setup a batch to process them.
        grp.output.completionTimeMS = g_currentMission.time + grp.timings.convertTimeMS;
        local msg = "";
        for fillType,inputs in pairs(grp.inputs) do
          msg = msg.." "..Fillable.fillTypeIntToName[fillType]..string.format("%d:%d", inputs.requiredAmount, inputs.currentAmount);
          if inputs.useFarmSilo == true then -- v0.56
            g_currentMission:setSiloAmount(fillType, g_currentMission:getSiloAmount(fillType) - inputs.requiredAmount);
          end
          inputs.currentAmount = inputs.currentAmount - inputs.requiredAmount;
        end;
        --FMCConversionController.log(string.format("Group#%s, started convert; %s", tostring(grpId), msg));
        -- Set dirty-flag, to indicate that an update-event must be sent to clients soon.
        grp.nextUpdateTimeMS = g_currentMission.time;
      end;
    end;
    --
    if (grp.nextUpdateTimeMS ~= nil) and (grp.nextUpdateTimeMS+1000 < g_currentMission.time) then 
      -- 1 second after dirty, then send update-event to clients.
      FMCConversionControllerEvent.sendEvent(grpId); 
      -- Update again in 300 seconds (5 minutes).
      grp.nextUpdateTimeMS = g_currentMission.time + (300*1000); -- v0.57
    end;
  end;
end;

function FMCConversionController.addFillDelta(groupId, fillDelta, fillType)
  if FMCConversionController.groups[groupId] ~= nil then
    local grpInput = FMCConversionController.groups[groupId].inputs[fillType];
    if grpInput ~= nil then
      grpInput.currentAmount = grpInput.currentAmount + fillDelta;
      --
      if (FMCConversionController.groups[groupId].nextUpdateTimeMS > g_currentMission.time) then
        FMCConversionController.groups[groupId].nextUpdateTimeMS = g_currentMission.time;
      end;
    else
      -- WTF?
    end;
  end;
end;

function FMCConversionController.createGroup(groupId)
  if groupId == nil then
    return false;
  end;

  if FMCConversionController.groups[groupId] == nil then
    --FMCConversionController.log(string.format("Creating new group/stationName '%s'", tostring(groupId)));
    --
    FMCConversionController.groups[groupId] = {
      nextUpdateTimeMS = 0
      ,timings={
        nextCheckMS=0
        ,startIntervalMS=15*1000
        ,convertTimeMS=60*1000
        ,pageSortOrder = 0 -- v0.58 - Not exactly a timing property.
      }
      ,inputs={ -- 'inputs' is a table where indexes is 'filltype'
        --  ...[fillType].requiredAmount=<integer>
        --  ...[fillType].currentAmount=<integer>
        --  ...[fillType].useFarmSilo=<boolean>
      }
      ,output={
        fillType=Fillable.FILLTYPE_UNKNOWN
        ,outputAmount=nil
        ,completionTimeMS=0
        ,destStationName=nil
      }
    };

    table.insert(FMCConversionController.idxToGroupId, groupId);
  end;

  return true;
end;

function FMCConversionController.getGroupStats(groupId)
  return FMCConversionController.groups[groupId];
  -- if FMCConversionController.groups[groupId] ~= nil then
    -- local grp = FMCConversionController.groups[groupId];
    -- local result = {};
    -- result.inputs = grp.inputs;
    -- result.output = grp.output;
  -- end;
  -- return nil, nil;
end;

function FMCConversionController.setGroupStats(groupId, inputs, completionTimeMS)
  --FMCConversionController.log(string.format("setGroupStats(%s, .., %u)", groupId, completionTimeMS));
  --
  local grp = FMCConversionController.groups[groupId];
  if grp ~= nil then
    for f,i in pairs(inputs) do
      grp.inputs[f].currentAmount = i.currentAmount
    end;
    grp.output.completionTimeMS = completionTimeMS;
  end;
end;


function FMCConversionController.addUnloadTrigger(unloadTrigger)
  local grpId = unloadTrigger.groupId;
  if (FMCConversionController.createGroup(grpId)) then
    --FMCConversionController.log(string.format("Updating settings for group '%s'", tostring(grpId))); -- DEBUG
    --
    if unloadTrigger.pageSortOrder~=nil then
        FMCConversionController.groups[grpId].timings.pageSortOrder   = unloadTrigger.pageSortOrder;
    end
    if unloadTrigger.startIntervalMS~=nil then
        FMCConversionController.groups[grpId].timings.startIntervalMS = unloadTrigger.startIntervalMS;
    end
    if unloadTrigger.convertTimeMS~=nil then
        FMCConversionController.groups[grpId].timings.convertTimeMS   = unloadTrigger.convertTimeMS;
    end
    for fruitType,accepted in pairs(unloadTrigger.acceptedFruitTypes) do
      if accepted then
        local fillType = FruitUtil.fruitTypeToFillType[fruitType];
        FMCConversionController.groups[grpId].inputs[fillType] = {};
        FMCConversionController.groups[grpId].inputs[fillType].requiredAmount  = Utils.getNoNil(unloadTrigger.inputAmounts[fruitType], 0);
        FMCConversionController.groups[grpId].inputs[fillType].currentAmount   = 0;
        FMCConversionController.groups[grpId].inputs[fillType].useFarmSilo     = false;
      end;
    end;
    if unloadTrigger.takeFromFarmSilo ~= nil then -- v0.56
      for fruitType,useFarmSilo in pairs(unloadTrigger.takeFromFarmSilo) do
        if useFarmSilo == true then
          local fillType = FruitUtil.fruitTypeToFillType[fruitType];
          FMCConversionController.groups[grpId].inputs[fillType] = {};
          FMCConversionController.groups[grpId].inputs[fillType].requiredAmount  = Utils.getNoNil(unloadTrigger.inputAmounts[fruitType], 0);
          FMCConversionController.groups[grpId].inputs[fillType].currentAmount   = Utils.getNoNil(g_currentMission:getSiloAmount(fillType), 0);
          FMCConversionController.groups[grpId].inputs[fillType].useFarmSilo     = true;
        end;
      end;
    end;
    if unloadTrigger.convertToFruitType~=nil then
        FMCConversionController.groups[grpId].output.fillType        = FruitUtil.fruitTypeToFillType[unloadTrigger.convertToFruitType];
    end
    if unloadTrigger.outputAmount~=nil then
        FMCConversionController.groups[grpId].output.outputAmount    = unloadTrigger.outputAmount;
    end
    if unloadTrigger.destStationName~=nil then
        FMCConversionController.groups[grpId].output.destStationName = unloadTrigger.destStationName;
    end
  end;
end;

function FMCConversionController.removeUnloadTrigger(unloadTrigger)
  -- Not needed
end;


addModEventListener(FMCConversionController);


--[[
BRAINSTORM

input buffers
  - fillType
  - currentAmount
  - maxSpace
  - inputEventCallback

preprocessing
  - fillType
  - minRequired
  - maxRequired
  - startingEventCallback

processing buffers
  - tickEventCallback

postprocessing
  - endingEventCallback

output buffers
  - fillType
  - currentAmount
  - maxSpace
  - outputEventCallback

]]--

----------------------------------------------------------
----------------------------------------------------------
----------------------------------------------------------
--
-- It would have been great, if the original PDA had some sort of page-plugin
-- ability, so it would allow additional pages to be added to it easily.
--

function renderTextShaded(x,y, fontsize, txt, shadeOffsetX, shadeOffsetY, frontColor, backColor)
    setTextColor(unpack(backColor));
    renderText(x+shadeOffsetX, y+shadeOffsetY, fontsize, txt);
    setTextColor(unpack(frontColor));
    renderText(x, y, fontsize, txt);
end;

FMCConversionController.txtFontSize = 0.02;
FMCConversionController.txtRowSpacing = FMCConversionController.txtFontSize * 1.01;
FMCConversionController.txtCol = {}
FMCConversionController.txtCol[1] = 0.046;
FMCConversionController.txtCol[2] = 0.2303;
FMCConversionController.txtCol[3] = 0.3074;

FMCConversionController.drawPDA = function(self, superFunc)
    if (g_currentMission.missionPDA.screen ~= 2) then
        -- Not on page 2, just call the normal PDA draw function
        superFunc(self)
    elseif (g_currentMission.missionPDA.showPDA and g_currentMission.missionPDA.screen == 2) then
        if (FMCConversionController.pdaSubpage == 0) then
            -- Draw the weather page...
            superFunc(self)
        else
            -- First render the background
            g_currentMission.missionPDA.hudPDABackgroundOverlay:render();

            -- Then render the text, which looks like
--[[
            <name of FMC tip-trigger>
                                           Storage : Required
            <fruit-name>          <current amount> : <required amount>
            <fruit-name>          <current amount> : <required amount>
            <fruit-name>          <current amount> : <required amount>
            
            Status:                         <idle / working, 99%>
            <Processing result>
]]
            local grpId = FMCConversionController.idxToGroupId[FMCConversionController.pdaSubpage];
            local grp = FMCConversionController.groups[grpId];

            --
            setTextColor(0.8, 0.9, 1.0, 1.0);
            setTextAlignment(RenderText.ALIGN_LEFT);
            setTextBold(true);
            local y = g_currentMission.missionPDA.pdaTitleY - (g_currentMission.missionPDA.pdaRowSpacing * 1.5);

            -- Draw station-name (group-id)
            renderText(g_currentMission.missionPDA.pdaUserCol[1], y, FMCConversionController.txtFontSize * 1.2, grpId);
            y = y - FMCConversionController.txtRowSpacing * 1.2;

            --
            setTextBold(false);
            setTextAlignment(RenderText.ALIGN_RIGHT);
            renderText(FMCConversionController.txtCol[2], y, FMCConversionController.txtFontSize, string.format("%s :", g_i18n:getText("Storage")));
            renderText(FMCConversionController.txtCol[3], y, FMCConversionController.txtFontSize, string.format("%s",   g_i18n:getText("Required")));
            y = y - FMCConversionController.txtRowSpacing;

            --
            for fillType,inputs in pairs(grp.inputs) do
                local fruitType = FruitUtil.fillTypeToFruitType[fillType];
                local fruitName = FruitUtil.fruitIndexToDesc[fruitType].name;
                if g_i18n:hasText(fruitName) then fruitName = g_i18n:getText(fruitName); end;

                setTextAlignment(RenderText.ALIGN_LEFT);
                renderText(FMCConversionController.txtCol[1], y, FMCConversionController.txtFontSize, fruitName);

                setTextAlignment(RenderText.ALIGN_RIGHT);
                renderText(FMCConversionController.txtCol[2], y, FMCConversionController.txtFontSize, string.format("%d :", inputs.currentAmount));
                renderText(FMCConversionController.txtCol[3], y, FMCConversionController.txtFontSize, string.format("%d",   inputs.requiredAmount));

                y = y - FMCConversionController.txtRowSpacing;
            end;
            y = y - FMCConversionController.txtRowSpacing * 0.8;

            -- Status:  Idle / Working, 45%
            setTextAlignment(RenderText.ALIGN_LEFT);
            renderText(FMCConversionController.txtCol[1], y, FMCConversionController.txtFontSize, string.format("%s:", g_i18n:getText("Status")));

            setTextAlignment(RenderText.ALIGN_RIGHT);
            local txt;
            if (grp.output.completionTimeMS ~= 0) then
                local pctCompleted = math.min(100, math.floor((1.0 - ((grp.output.completionTimeMS - g_currentMission.time) / grp.timings.convertTimeMS)) * 100));
                txt = string.format(g_i18n:getText("WorkingPct"), pctCompleted);
            else
                txt = g_i18n:getText("Idle");
            end;
            renderText(FMCConversionController.txtCol[3], y, FMCConversionController.txtFontSize, txt);
            y = y - FMCConversionController.txtRowSpacing;

            setTextAlignment(RenderText.ALIGN_LEFT);

            -- Display processing result for some seconds.
            -- TODO: Make this show on clients too.
            if grp.output.pdaDisplayTime ~= nil and (grp.output.pdaDisplayTime + 5000 > g_currentMission.time) then
                renderText(FMCConversionController.txtCol[1], y, FMCConversionController.txtFontSize, grp.output.pdaDisplayText);
            end;

            -- Render the PDA-frame
            g_currentMission.missionPDA.hudPDAFrameOverlay:render();

            -- Draw screen-title (with black shadow)
            setTextBold(true);
            renderTextShaded(
                g_currentMission.missionPDA.pdaTitleX,
                g_currentMission.missionPDA.pdaTitleY,
                g_currentMission.missionPDA.pdaTitleTextSize,
                g_i18n:getText("pdaTitleFMCTipTrigger"),
                -0.001, -0.002, {1,1,1,1}, {0,0,0,1}
            );
        end;

        -- Draw page#-of-pages... now also showing what key to press, to toggle through the sub-pages.
        setTextAlignment(RenderText.ALIGN_RIGHT);
        setTextBold(true);
        renderTextShaded(
            g_currentMission.missionPDA.pdaPricesCol[5] + 0.02,
            g_currentMission.missionPDA.hudPDABasePosY + g_currentMission.missionPDA.pdaTopSpacing + (g_currentMission.missionPDA.pdaFontSize * 0.7),
            g_currentMission.missionPDA.pdaFontSize - 0.002,
            string.format("(%s) %d/%d", 
                InputBinding.getKeyNamesOfDigitalAction(InputBinding.TOGGLE_PDA_ZOOM), 
                FMCConversionController.pdaSubpage+1, 
                table.getn(FMCConversionController.idxToGroupId)+1
            ),
            -0.001, -0.001, {1,1,1,1}, {0,0,0,1}
        );
        setTextAlignment(RenderText.ALIGN_LEFT);
    end;
end;
-- Overwrite the original function, so it calls FMCConversionController.drawPDA()
MissionPDA.draw = Utils.overwrittenFunction(MissionPDA.draw, FMCConversionController.drawPDA);

----------------------------------------------------------
----------------------------------------------------------
----------------------------------------------------------

FMCConversionControllerEvent = {};
FMCConversionControllerEvent_mt = Class(FMCConversionControllerEvent, Event);

InitEventClass(FMCConversionControllerEvent, "FMCConversionControllerEvent");

--function FMCConversionControllerEvent.log(txt)
--  print(string.format("%7ums FMCConversionControllerEvent ", g_currentMission.time) .. txt);
--end;

function FMCConversionControllerEvent:emptyNew()
  local self = Event:new(FMCConversionControllerEvent_mt);
  self.className="FMCConversionControllerEvent";
  return self;
end;

function FMCConversionControllerEvent:new(groupId)
  local self = FMCConversionControllerEvent:emptyNew()
  self.groupId = groupId;
  return self;
end;

function FMCConversionControllerEvent:readStream(streamId, connection)
    --FMCConversionControllerEvent.log(string.format("readStream(%s, %s)", tostring(streamId), tostring(connection)));
    --NetTrace:newSession();
    --
    local groupId       = NetTrace:streamReadString(streamId);
    local remainTimeMS  = NetTrace:streamReadInt32(streamId);
    local numInputs     = NetTrace:streamReadInt8(streamId);
    local inputs = {};
    for i=1, numInputs do
        local fillType = NetTrace:streamReadInt8(streamId);
        inputs[fillType] = {};
        inputs[fillType].currentAmount = NetTrace:streamReadInt32(streamId);
    end;
    --
    --FMCConversionControllerEvent.log(string.format("readStream(%s, %s):  %s", tostring(streamId), tostring(connection), NetTrace:endSession()));
    --
    local clientCompletionTimeMS = (remainTimeMS > 0) and (g_currentMission.time + remainTimeMS) or 0;
    FMCConversionController.setGroupStats(groupId, inputs, clientCompletionTimeMS);
end;

function FMCConversionControllerEvent:writeStream(streamId, connection)
    --FMCConversionControllerEvent.log(string.format("writeStream(%s, %s)", tostring(streamId), tostring(connection)));
    --NetTrace:newSession();
    --
    local grp           = FMCConversionController.getGroupStats(self.groupId);
    local remainTimeMS  = (grp.output.completionTimeMS > 0) and (grp.output.completionTimeMS - g_currentMission.time) or 0;
    --
    NetTrace:streamWriteString(streamId, self.groupId);
    NetTrace:streamWriteInt32(streamId, remainTimeMS);
    --NetTrace:streamWriteInt8(streamId, #grp.inputs); -- Why does these not work here?; table.getn(<table>) and #<table> 
    local numInputs = 0;
    for fillType,input in pairs(grp.inputs) do -- v0.57
        numInputs = numInputs + 1
    end
    NetTrace:streamWriteInt8(streamId, numInputs);
    --
    for fillType,input in pairs(grp.inputs) do
        NetTrace:streamWriteInt8(streamId, fillType); -- TODO maybe use streamWriteUIntN(streamId, <value>, <maxBits>)
        NetTrace:streamWriteInt32(streamId, math.floor(input.currentAmount));
    end;
    --
    --FMCConversionControllerEvent.log(string.format("writeStream(%s, %s):  %s", tostring(streamId), tostring(connection), NetTrace:endSession()));
end;

--[[
function FMCConversionControllerEvent:run(connection)
  if not connection:getIsServer() then
    --g_server:broadcastEvent(FMCConversionControllerEvent:new(), nil, connection, self);
  end;
end;
]]

function FMCConversionControllerEvent.sendEvent(groupId, noEventSend)
  --FMCConversionControllerEvent.log(string.format("sendEvent(%s)", groupId));
  --
  if noEventSend == nil or noEventSend == false then
    if g_server ~= nil then
      g_server:broadcastEvent(FMCConversionControllerEvent:new(groupId));
    else
      -- no need to send-to-server, as it is only the server that has the original information
      -- g_client:getServerConnection():sendEvent(FMCConversionControllerEvent:new());
    end;
  end;
end;


----------------------------------------------------------
----------------------------------------------------------
----------------------------------------------------------

FMCTipTrigger = {};

--local FMCTipTrigger_mt = Class(FMCTipTrigger, Object);
local FMCTipTrigger_mt = Class(FMCTipTrigger, TipTrigger);  -- inherit the functionality of TipTrigger


--function onCreate(self, id) -- DEPRECATED
--    print("DEPRECATED scriptCallback value 'FMCTipTrigger.onCreate'. Please use 'modOnCreate.onCreateFMCTipTrigger'");
--    FMCTipTrigger.onCreateObject(id);
--end;


function FMCTipTrigger.onCreateObject(id)
  local instance = FMCTipTrigger:new(g_server ~= nil, g_client ~= nil);
  local index = g_currentMission:addOnCreateLoadedObject(instance);
  instance:load(id);
  instance:register(true);
  --
  --print(string.format("Created FMCTipTrigger(id=%s)", id));
end;

--
--     http://gdn.giants-software.com/thread.php?categoryId=16&threadId=556
--
g_onCreateUtil.addOnCreateFunction("onCreateFMCTipTrigger", FMCTipTrigger.onCreateObject);



function FMCTipTrigger:new(isServer, isClient)
  local self = Object:new(isServer, isClient, FMCTipTrigger_mt);
  self.className = "FMCTipTrigger";
  --
  self.triggerId = 0;
  g_currentMission:addTipTrigger(self);
  --
  return self;
end;

--function FMCTipTrigger:log(txt)
--  print(string.format("%7ums B#%d: ", g_currentMission.time, self.triggerId) .. txt);
--end;

function FMCTipTrigger:load(id)
  self.triggerId = id;
  --self:log("FMCTipTrigger:load(".. tostring(id) ..")")

  FMCTipTrigger:superClass().load(self, id);
  --
  setCollisionMask(id, 8388608); -- Change/fix collision-mask to only allow "Fillables" objects.
  --
  self.fmcTrailerInTrigger = nil;
  --self.playerInRange = false;
  --
  self.groupId = Utils.getNoNil(getUserAttribute(id, "groupId"), self.stationName);
  self.pageSortOrder = Utils.getNoNil(getUserAttribute(id, "pageSortOrder"), 9999);
  self.inputAmounts = {};
  self.takeFromFarmSilo = {}; -- v0.56
  local fruitTypes         = getUserAttribute(id, "fruitTypes");
  local inputAmountsString = getUserAttribute(id, "inputAmounts");
  if fruitTypes ~= nil then
      local types = Utils.splitString(" ", fruitTypes);
      local amounts = Utils.splitString(" ", Utils.getNoNil(inputAmountsString,""));
      for k,v in pairs(types) do
          local useFarmSilo = false
          if v:sub(1,5) == "silo:" then -- v0.56
            v = v:sub(6)
            useFarmSilo = true
          end
          local desc = FruitUtil.fruitTypes[v];
          if desc ~= nil then
              self.inputAmounts[desc.index] = tonumber(amounts[k]);
              if self.inputAmounts[desc.index] == nil then
                self.inputAmounts[desc.index] = 0;
              end;
              self.takeFromFarmSilo[desc.index] = useFarmSilo; -- v0.56
          end;
      end;
  end;
  --
  local convertToFruit = getUserAttribute(id, "convertToFruit");
  if (convertToFruit ~= nil) then
    convertToFruit = FruitUtil.fruitTypes[convertToFruit];
  end
  if (convertToFruit ~= nil) then
    self.convertToFruitType = convertToFruit.index;

    self.startIntervalMS = getUserAttribute(id, "startIntervalSecs");
    self.startIntervalMS = self.startIntervalMS~=nil and math.max(5000, 1000 * self.startIntervalMS) or nil; -- Minimum interval is 5 seconds.

    self.convertTimeMS = getUserAttribute(id, "convertTimeSecs");
    self.convertTimeMS = self.convertTimeMS~=nil and math.max(5000, 1000 * self.convertTimeMS) or nil; -- Minimum convert-time is 5 seconds.

    self.outputAmount = getUserAttribute(id, "outputAmount");
    self.outputAmount = self.outputAmount~=nil and math.max(0, self.outputAmount) or nil; -- Minimum output amount is 0 units.
    
    self.destStationName = getUserAttribute(id, "destStationName");
  end;

  -- TODO - Check for missing user-attributes and stuff like that.
  FMCConversionController.addUnloadTrigger(self);

  return self;
end;

function FMCTipTrigger:delete()
  --self:log("delete()");
  FMCConversionController.removeUnloadTrigger(self);
  --
  FMCTipTrigger:superClass().delete(self);
end;

-- This function is apparently called from Trailer:updateTick()
function FMCTipTrigger:updateMoving(fillDelta, fillType)    -- v0.55: added 'fillType', luckly LUA is not strict about how many extra arguments there are (not) values for.
  if (self.groupId ~= nil) then
    if (fillType ~= nil) then
      FMCConversionController.addFillDelta(self.groupId, fillDelta, fillType);
    elseif (self.fmcTrailerInTrigger ~= nil) then
      -- TO DO: Figure out if there is an easier way to get what fruitType/fillType thats "unloading".
      FMCConversionController.addFillDelta(self.groupId, fillDelta, self.fmcTrailerInTrigger.currentFillType);
    --else
    --  self:log(string.format("ERROR. No trailer entered FMCTipTrigger(%s)!", tostring(self.triggerId)));
    end;
  end;
  --
  FMCTipTrigger:superClass().updateMoving(self, fillDelta);
end;

function FMCTipTrigger:triggerCallback(triggerId, otherId, onEnter, onLeave, onStay, otherShapeId)
    if self.isEnabled then
--if (not onStay) then
--  self:log(string.format("FMCTipTrigger:triggerCallback(triggerId=%s, otherId=%s, onEnter=%s, onLeave=%s, onStay=%s, otherShapeId=%s", tostring(triggerId), tostring(otherId), tostring(onEnter), tostring(onLeave), tostring(onStay), tostring(otherShapeId)));
--end
        if onEnter then
            local trailer = g_currentMission.objectToTrailer[otherShapeId];
            if trailer ~= nil and trailer.allowTipDischarge then
              -- Looks like its a Trailer that allows discharging/unloading
              if (self.fmcTrailerInTrigger == nil) then
                --self:log(string.format("Trailer accepted."));
                -- Only one "trailer" allowed inside at any time, else updateMoving() will misbehave.
                self.fmcTrailerInTrigger = trailer;
                -- vvv - Copied from TipTrigger:triggerCallback()
                if g_currentMission.trailerTipTriggers[trailer] == nil then
                  g_currentMission.trailerTipTriggers[trailer] = {};
                end;
                table.insert(g_currentMission.trailerTipTriggers[trailer], self);
                -- ^^^
                --self:log("Trailer in. Filltype="..Fillable.fillTypeIntToName[trailer.currentFillType]);
              --else
              --  self:log(string.format("Trailer rejected. FMCTipTrigger already occupied by other trailer(%s)!", tostring(self.fmcTrailerInTrigger)));
              end;
            --else
            --  self:log("Rejected. Not a Trailer or its empty!");
            end;
            ---- For this to work, the 'collisionMask' in map01.I3D, must have both bits for 'player' and 'fillable'.
            --if g_currentMission.player ~= nil then
            --  if otherId == g_currentMission.player.rootNode then
            --    --self:log("Player in");
            --    self.playerInRange = true;
            --  end;
            --end;
            --
        elseif onLeave then
            local trailer = g_currentMission.objectToTrailer[otherShapeId];
            if trailer ~= nil then
                -- vvv - Copied from TipTrigger:triggerCallback()
                local triggers = g_currentMission.trailerTipTriggers[trailer];
                if triggers ~= nil then
                  for i=1, table.getn(triggers) do
                    if triggers[i] == self then
                      --self:log(string.format("Trailer removed."));
                      table.remove(triggers, i);
                      if table.getn(triggers) == 0 then
                          g_currentMission.trailerTipTriggers[trailer] = nil;
                      end;
                      self.fmcTrailerInTrigger = nil;
                      --self:log(string.format("Trailer out(%s)", tostring(trailer)));
                      break;
                    end;
                  end;
                end;
                -- ^^^
            end;
            ---- For this to work, the 'collisionMask' in map01.I3D, must have both bits for 'player' and 'fillable'.
            --if g_currentMission.player ~= nil then
            --  if otherId == g_currentMission.player.rootNode then
            --    --self:log("Player out");
            --    self.playerInRange = false;
            --  end;
            --end;
            --
        end;
    end;
end;


print("Script loaded: FMCTipTrigger.LUA (v0.59 beta)");
