unit Ws.DataModel;

// Server-side WebSocket data model.
// Owns a VCL timer that fires every FIntervalMs milliseconds, queries the
// database for the five data sets used by the polling timers that existed in
// each connected browser client, and broadcasts the results to every
// handshaked WebSocket connection via the supplied broadcast callback.
//
// All five result shapes mirror what the XData API methods return so that the
// client-side parsing code can be shared between the initial HTTP load and the
// subsequent WebSocket pushes.

interface

uses
  System.SysUtils, System.Classes, System.JSON,
  System.Generics.Collections,
  Data.DB,
  Vcl.ExtCtrls,
  Api.Database,
  WsMessages,
  Common.Logging;

type
  TWsBroadcastProc = reference to procedure(const AMessage: string);

  TWsDataModel = class
  private
    FDb: TApiDatabaseModule;
    FTimer: TTimer;
    FBroadcast: TWsBroadcastProc;

    procedure TimerFire(Sender: TObject);
    procedure BroadcastAll;

    function BuildBadgeCountsJson: string;
    function BuildUnitMapJson: string;
    function BuildComplaintMapJson: string;
    function BuildUnitListJson: string;
    function BuildComplaintListJson: string;
  public
    constructor Create(ABroadcast: TWsBroadcastProc; AIntervalMs: Integer = 5000);
    destructor Destroy; override;
  end;

implementation

uses
  System.StrUtils, System.DateUtils;

{ TWsDataModel }

constructor TWsDataModel.Create(ABroadcast: TWsBroadcastProc; AIntervalMs: Integer);
begin
  inherited Create;
  FBroadcast := ABroadcast;
  FDb := TApiDatabaseModule.Create(nil);
  FTimer := TTimer.Create(nil);
  FTimer.Interval := AIntervalMs;
  FTimer.OnTimer  := TimerFire;
  FTimer.Enabled  := True;
end;

destructor TWsDataModel.Destroy;
begin
  FTimer.Enabled := False;
  FTimer.Free;
  FDb.Free;
  inherited;
end;

procedure TWsDataModel.TimerFire(Sender: TObject);
begin
  if not FDb.CADUpdate then
    Exit;

  FDb.CADUpdate := False;
  BroadcastAll;
end;

procedure TWsDataModel.BroadcastAll;
begin
  Logger.Log(3, 'WsDataModel: broadcasting all');
  try FBroadcast(BuildBadgeCountsJson);   except on E: Exception do Logger.Log(2, 'WsDataModel BADGE_COUNTS error: ' + E.Message); end;
  try FBroadcast(BuildUnitMapJson);       except on E: Exception do Logger.Log(2, 'WsDataModel UNIT_MAP error: '       + E.Message); end;
  try FBroadcast(BuildComplaintMapJson);  except on E: Exception do Logger.Log(2, 'WsDataModel COMPLAINT_MAP error: '  + E.Message); end;
  try FBroadcast(BuildUnitListJson);      except on E: Exception do Logger.Log(2, 'WsDataModel UNIT_LIST error: '      + E.Message); end;
  try FBroadcast(BuildComplaintListJson); except on E: Exception do Logger.Log(2, 'WsDataModel COMPLAINT_LIST error: ' + E.Message); end;
end;

function TWsDataModel.BuildBadgeCountsJson: string;
var
  msg: TWsBadgeCountsMessage;
begin
  msg := TWsBadgeCountsMessage.Create;
  try
    with FDb.uqBadgeCounts do
    begin
      Open;
      try
        msg.BadgeComplaints := FieldByName('COMPLAINTS').AsInteger;
        msg.BadgeUnits      := FieldByName('UNITS').AsInteger;
      finally
        Close;
      end;
    end;
    Result := msg.AsJson;
  finally
    msg.Free;
  end;
end;

function TWsDataModel.BuildUnitMapJson: string;
var
  obj: TJSONObject;
  data: TJSONArray;
  item: TJSONObject;
  unitStatus, updateTimeText: string;
begin
  obj  := TJSONObject.Create;
  data := TJSONArray.Create;
  try
    obj.AddPair('RequestId', WS_MSG_UNIT_MAP);
    obj.AddPair('data', data);

    with FDb.uqMapUnits do
    begin
      Close;
      Open;
      try
        First;
        while not Eof do
        begin
          if (not FDb.uqMapUnitsGPS_LATITUDE.IsNull) and (not FDb.uqMapUnitsGPS_LONGITUDE.IsNull) then
          begin
            item := TJSONObject.Create;
            data.AddElement(item);

            item.AddPair('UnitId',      FDb.uqMapUnitsUNITID.AsString);
            item.AddPair('UnitName',    FDb.uqMapUnitsUNITNAME.AsString);
            item.AddPair('UnitBadge',   FDb.uqMapUnitsUNITNAME.AsString);
            item.AddPair('Agency',      FDb.uqMapUnitsAGENCY.AsString);
            item.AddPair('AgencyName',  FDb.uqMapUnitsAGENCY_NAME.AsString);
            item.AddPair('AgencyType',  FDb.uqMapUnitsAGENCYTYPE.AsString);
            item.AddPair('Lat',         TJSONNumber.Create(FDb.uqMapUnitsGPS_LATITUDE.AsFloat));
            item.AddPair('Lng',         TJSONNumber.Create(FDb.uqMapUnitsGPS_LONGITUDE.AsFloat));
            item.AddPair('CallType',    FDb.uqMapUnitsCALL_TYPE.AsString);
            item.AddPair('Priority',    FDb.uqMapUnitsPRIORITY.AsString);

            unitStatus := FDb.uqMapUnitsUNIT_STATUS_DESC.AsString;
            if Trim(unitStatus) = '' then unitStatus := 'Available';
            item.AddPair('Status', unitStatus);

            updateTimeText := '';
            if not FDb.uqMapUnitsUPDATE_TIME.IsNull then
              updateTimeText := FormatDateTime('yyyy-mm-dd hh:nn:ss', FDb.uqMapUnitsUPDATE_TIME.AsDateTime);
            item.AddPair('UpdateTime', updateTimeText);

            item.AddPair('Officer1Lname',  FDb.uqMapUnitsOFFICER1_LNAME.AsString);
            item.AddPair('Officer1Fname',  FDb.uqMapUnitsOFFICER1_FNAME.AsString);
            item.AddPair('Officer1Empnum', FDb.uqMapUnitsOFFICER1_EMPNUM.AsString);
            item.AddPair('Officer2Lname',  FDb.uqMapUnitsOFFICER2_LNAME.AsString);
            item.AddPair('Officer2Fname',  FDb.uqMapUnitsOFFICER2_FNAME.AsString);
            item.AddPair('Officer2Empnum', FDb.uqMapUnitsOFFICER2_EMPNUM.AsString);
            item.AddPair('CanShowDetails', TJSONBool.Create(not FDb.uqMapUnitsDIS_UNITID.IsNull));
          end;
          Next;
        end;
      finally
        Close;
      end;
    end;

    obj.AddPair('count',    TJSONNumber.Create(data.Count));
    obj.AddPair('returned', TJSONNumber.Create(data.Count));
    Result := obj.ToJSON;
  finally
    obj.Free;
  end;
end;

function TWsDataModel.BuildComplaintMapJson: string;
var
  obj: TJSONObject;
  data: TJSONArray;
  item, unitObj: TJSONObject;
  unitArray: TJSONArray;
  UnitsByComplaintMap: TDictionary<string, TJSONArray>;
  complaintId, unitStatus: string;
  latestUpdate: TDateTime;
begin
  obj  := TJSONObject.Create;
  data := TJSONArray.Create;
  UnitsByComplaintMap := TDictionary<string, TJSONArray>.Create;
  try
    obj.AddPair('RequestId', WS_MSG_COMPLAINT_MAP);
    obj.AddPair('data', data);

    // Build unit→complaint map first
    FDb.uqMapComplaintUnitsList.Close;
    FDb.uqMapComplaintUnitsList.Open;
    try
      while not FDb.uqMapComplaintUnitsList.Eof do
      begin
        complaintId := FDb.uqMapComplaintUnitsListCOMPLAINTID.AsString;

        if not UnitsByComplaintMap.TryGetValue(complaintId, unitArray) then
        begin
          unitArray := TJSONArray.Create;
          UnitsByComplaintMap.Add(complaintId, unitArray);
        end;

        unitStatus := 'Dispatched';
        if not FDb.uqMapComplaintUnitsListDATECLEARED.IsNull   then unitStatus := 'Cleared'
        else if not FDb.uqMapComplaintUnitsListDATEARRIVED.IsNull   then unitStatus := 'On Scene'
        else if not FDb.uqMapComplaintUnitsListDATERESPONDED.IsNull then unitStatus := 'Enroute';

        latestUpdate := 0;
        if not FDb.uqMapComplaintUnitsListDATEDISPATCHED.IsNull then
          latestUpdate := FDb.uqMapComplaintUnitsListDATEDISPATCHED.AsDateTime;
        if (not FDb.uqMapComplaintUnitsListDATERESPONDED.IsNull) and
           (FDb.uqMapComplaintUnitsListDATERESPONDED.AsDateTime > latestUpdate) then
          latestUpdate := FDb.uqMapComplaintUnitsListDATERESPONDED.AsDateTime;
        if (not FDb.uqMapComplaintUnitsListDATEARRIVED.IsNull) and
           (FDb.uqMapComplaintUnitsListDATEARRIVED.AsDateTime > latestUpdate) then
          latestUpdate := FDb.uqMapComplaintUnitsListDATEARRIVED.AsDateTime;
        if (not FDb.uqMapComplaintUnitsListDATECLEARED.IsNull) and
           (FDb.uqMapComplaintUnitsListDATECLEARED.AsDateTime > latestUpdate) then
          latestUpdate := FDb.uqMapComplaintUnitsListDATECLEARED.AsDateTime;

        unitObj := TJSONObject.Create;
        unitArray.AddElement(unitObj);
        unitObj.AddPair('Unit', FDb.uqMapComplaintUnitsListUNITNAME.AsString);
        unitObj.AddPair('Status', unitStatus);
        unitObj.AddPair('Updated', IfThen(latestUpdate <> 0, FormatDateTime('yyyy-mm-dd hh:nn:ss', latestUpdate), ''));

        FDb.uqMapComplaintUnitsList.Next;
      end;
    finally
      FDb.uqMapComplaintUnitsList.Close;
    end;

    // Build complaint rows
    FDb.uqMapComplaints.Close;
    FDb.uqMapComplaints.Open;
    try
      while not FDb.uqMapComplaints.Eof do
      begin
        item := TJSONObject.Create;
        data.AddElement(item);

        complaintId := FDb.uqMapComplaintsCOMPLAINTID.AsString;
        item.AddPair('ComplaintId',           complaintId);
        item.AddPair('Complaint',             FDb.uqMapComplaintsCOMPLAINT.AsString);
        item.AddPair('DispatchDistrict',      FDb.uqMapComplaintsDISPATCHDISTRICT.AsString);
        item.AddPair('Agency',                FDb.uqMapComplaintsAGENCY.AsString);
        item.AddPair('AgencyName',            FDb.uqMapComplaintsAGENCY_NAME.AsString);
        item.AddPair('DispatchCodeDesc',      FDb.uqMapComplaintsDISPATCH_CODE_DESC.AsString);
        item.AddPair('DispatchCodeCategory',  FDb.uqMapComplaintsDISPATCHCODECATEGORY.AsString);
        item.AddPair('Priority',              FDb.uqMapComplaintsPRIORITY.AsString);
        item.AddPair('PriorityBadge',         FDb.uqMapComplaintspriorityBadge.AsString);
        item.AddPair('ComplaintStatusKey',    FDb.uqMapComplaintscomplaintStatusKey.AsString);
        item.AddPair('pngName',               FDb.uqMapComplaintspngName.AsString);
        item.AddPair('Address',               FDb.uqMapComplaintsADDRESS.AsString);
        item.AddPair('Business',              FDb.uqMapComplaintsBUSINESS.AsString);
        item.AddPair('Lat',                   TJSONNumber.Create(FDb.uqMapComplaintsLAT.AsFloat));
        item.AddPair('Lng',                   TJSONNumber.Create(FDb.uqMapComplaintsLNG.AsFloat));

        if UnitsByComplaintMap.TryGetValue(complaintId, unitArray) then
          item.AddPair('Units', TJSONArray(unitArray.Clone))
        else
          item.AddPair('Units', TJSONArray.Create);

        FDb.uqMapComplaints.Next;
      end;
    finally
      FDb.uqMapComplaints.Close;
    end;

    obj.AddPair('count',    TJSONNumber.Create(data.Count));
    obj.AddPair('returned', TJSONNumber.Create(data.Count));
    Result := obj.ToJSON;
  finally
    // Free the per-complaint unit arrays (not cloned copies — those are now owned by item)
    for unitArray in UnitsByComplaintMap.Values do
      unitArray.Free;
    UnitsByComplaintMap.Free;
    obj.Free;
  end;
end;

function TWsDataModel.BuildUnitListJson: string;
var
  obj: TJSONObject;
  data: TJSONArray;
  item: TJSONObject;
  lastAgency, curAgency: string;
  o1, f1, m1, o2, f2, m2: string;
  statusDesc, complaintNumber: string;
  mapLat, mapLng: Double;
  canShowMap: Boolean;
begin
  obj      := TJSONObject.Create;
  data     := TJSONArray.Create;
  lastAgency := '';
  try
    obj.AddPair('RequestId', WS_MSG_UNIT_LIST);

    with FDb.uqUnitList do
    begin
      Open;
      First;
      while not Eof do
      begin
        item := TJSONObject.Create;

        curAgency := Trim(FDb.uqUnitListAGENCY_NAME.AsString);
        if curAgency = '' then curAgency := Trim(FDb.uqUnitListAGENCY.AsString);
        if curAgency = '' then curAgency := 'Unknown Agency';

        if not SameText(curAgency, lastAgency) then
        begin
          item.AddPair('AgencyHeader', curAgency);
          lastAgency := curAgency;
        end
        else
          item.AddPair('AgencyHeader', '');

        item.AddPair('UnitId',        FDb.uqUnitListUNITID.AsString);
        item.AddPair('UnitName',      FDb.uqUnitListUNITNAME.AsString);
        item.AddPair('Agency',        FDb.uqUnitListAGENCY.AsString);
        item.AddPair('AgencyName',    FDb.uqUnitListAGENCY_NAME.AsString);
        item.AddPair('AgencyType',    FDb.uqUnitListAGENCYTYPE.AsString);
        item.AddPair('CarNumberDesc', FDb.uqUnitListCARNUMBER_DESC.AsString);
        item.AddPair('District',      FDb.uqUnitListDISTRICT_DESC.AsString);
        item.AddPair('Sector',        FDb.uqUnitListSECTOR_DESC.AsString);
        item.AddPair('CallType',      FDb.uqUnitListCALL_TYPE.AsString);

        canShowMap := False;
        if (not FDb.uqUnitListGPS_LATITUDE.IsNull) and (not FDb.uqUnitListGPS_LONGITUDE.IsNull) then
        begin
          mapLat := FDb.uqUnitListGPS_LATITUDE.AsFloat;
          mapLng := FDb.uqUnitListGPS_LONGITUDE.AsFloat;
          canShowMap :=
            ((mapLat <> 0) or (mapLng <> 0)) and
            (Abs(mapLat) <= 90) and (Abs(mapLng) <= 180);
        end;

        item.AddPair('CanShowMap',         IfThen(canShowMap, 'true', 'false'));
        item.AddPair('MapButtonClass',     IfThen(canShowMap, 'btn-primary', 'btn-secondary disabled'));
        item.AddPair('MapButtonDisabled',  IfThen(canShowMap, '', 'disabled="disabled" aria-disabled="true"'));
        item.AddPair('MapButtonTitle',     IfThen(canShowMap, 'Show on map', 'No map location available'));

        complaintNumber := Trim(FDb.uqUnitListCOMPLAINT.AsString);
        item.AddPair('Location',        FDb.uqUnitListLOCATION.AsString);
        item.AddPair('Complaint',       complaintNumber);
        item.AddPair('ComplaintHeader', IfThen(complaintNumber <> '', ' - ' + complaintNumber, ''));

        statusDesc := FDb.uqUnitListUNIT_STATUS_DESC.AsString;
        if statusDesc = '' then statusDesc := 'Available';
        item.AddPair('Status', statusDesc);

        o1 := Trim(FDb.uqUnitListOFFICER1_LAST_NAME.AsString);
        f1 := Trim(FDb.uqUnitListOFFICER1_FIRST_NAME.AsString);
        m1 := Trim(FDb.uqUnitListOFFICER1_MI.AsString);
        if o1 <> '' then
        begin
          if f1 <> '' then o1 := o1 + ', ' + f1;
          if m1 <> '' then o1 := o1 + ' ' + m1;
          item.AddPair('Officer1', o1);
        end;

        o2 := Trim(FDb.uqUnitListOFFICER2_LAST_NAME.AsString);
        f2 := Trim(FDb.uqUnitListOFFICER2_FIRST_NAME.AsString);
        m2 := Trim(FDb.uqUnitListOFFICER2_MI.AsString);
        if o2 <> '' then
        begin
          if f2 <> '' then o2 := o2 + ', ' + f2;
          if m2 <> '' then o2 := o2 + ' ' + m2;
          item.AddPair('Officer2', o2);
        end;

        data.AddElement(item);
        Next;
      end;
    end;

    obj.AddPair('count',    TJSONNumber.Create(data.Count));
    obj.AddPair('returned', TJSONNumber.Create(data.Count));
    obj.AddPair('data', data);
    Result := obj.ToJSON;
  finally
    FDb.uqUnitList.Close;
    obj.Free;
  end;
end;

function TWsDataModel.BuildComplaintListJson: string;
var
  obj: TJSONObject;
  data: TJSONArray;
  item: TJSONObject;
  lastDistrict, curAgency: string;
  status, statusColor, statusTextColor: string;
  colorVal: Integer;
  complaintNumber: string;
begin
  obj          := TJSONObject.Create;
  data         := TJSONArray.Create;
  lastDistrict := '';
  try
    obj.AddPair('RequestId', WS_MSG_COMPLAINT_LIST);

    with FDb.uqComplaintList do
    begin
      Open;
      (FieldByName('DATEREPORTED') as TDateTimeField).DisplayFormat := 'yyyy-mm-dd hh:nn:ss';
      First;
      while not Eof do
      begin
        if not FieldByName('DATEARRIVED').IsNull then
        begin
          status := 'On Scene'; statusColor := '#22C55E'; statusTextColor := '#000000';
        end
        else if not FieldByName('DATERESPONDED').IsNull then
        begin
          status := 'Enroute'; statusColor := '#FFFF00'; statusTextColor := '#000000';
        end
        else if not FieldByName('DATEDISPATCHED').IsNull then
        begin
          status := 'Dispatched'; statusColor := '#FFFF00'; statusTextColor := '#000000';
        end
        else
        begin
          status := 'Pending'; statusColor := '#FF8080'; statusTextColor := '#000000';
        end;

        item := TJSONObject.Create;

        curAgency := Trim(FDb.uqComplaintListAGENCY.AsString);
        if curAgency = '' then curAgency := 'Unknown Agency';

        if not SameText(curAgency, lastDistrict) then
          item.AddPair('DistrictHeader', curAgency);
        lastDistrict := curAgency;

        item.AddPair('AgencyLine', 'Agency: ' + curAgency);

        colorVal := FDb.uqComplaintListPRIORITY_COLOR.AsInteger;
        item.AddPair('PriorityColor',     '#' + IntToHex(colorVal and $FFFFFF, 6));
        item.AddPair('PriorityTextColor', '#000000');

        complaintNumber := FDb.uqComplaintListcomplaintNumber.AsString;
        item.AddPair('ComplaintId',      FDb.uqComplaintListCOMPLAINTID.AsString);
        item.AddPair('Complaint',        complaintNumber);
        item.AddPair('Agency',           FDb.uqComplaintListAGENCY.AsString);
        item.AddPair('Priority',         FDb.uqComplaintListPRIORITY.AsString);
        item.AddPair('DispatchCodeDesc', FDb.uqComplaintListDISPATCH_CODE_DESC.AsString);
        item.AddPair('Address',          FDb.uqComplaintListADDRESS.AsString);
        item.AddPair('Business',         FDb.uqComplaintListBUSINESS.AsString);
        item.AddPair('CFSId',            FDb.uqComplaintListCFSID.AsString);
        item.AddPair('Status',           status);
        item.AddPair('StatusColor',      statusColor);
        item.AddPair('StatusTextColor',  statusTextColor);
        item.AddPair('DispatchDistrict', FDb.uqComplaintListDISPATCHDISTRICT.AsString);
        item.AddPair('DateReported',     FDb.uqComplaintListDATEREPORTED.AsString);

        data.AddElement(item);
        Next;
      end;
    end;

    obj.AddPair('count',    TJSONNumber.Create(data.Count));
    obj.AddPair('returned', TJSONNumber.Create(data.Count));
    obj.AddPair('data', data);
    Result := obj.ToJSON;
  finally
    FDb.uqComplaintList.Close;
    obj.Free;
  end;
end;

end.
