program ewsdrd;

{$IFDEF FPC}{$MODE objfpc}{$H+}{$ENDIF}
{$APPTYPE CONSOLE}

// ===========================================================================
//  EWSDR daemon — headless-режим (только Web-управление, без UI/LCL).
//
//  Доказывает, что рефакторинг достиг цели Фазы 5: TRadioController + адаптеры
//  (Web) работают БЕЗ TMainForm. Граф зависимостей контроллера LCL-free
//  (проверено), поэтому здесь нет ни Interfaces, ни Forms, ни виджетсета.
//
//  Маршалинг как в GUI: входящие web-команды приходят из потока веб-сервера и
//  выполняются в главном потоке через FCtrl.Invoke (=TThread.Synchronize). Чтобы
//  очередь Synchronize реально исполнялась без LCL-цикла сообщений, главный поток
//  крутит CheckSynchronize() в Run-цикле — там же периодический PushState.
//
//  Сборка (использует уже скомпилированные .ppu от GUI-сборки в lib/...):
//    fpc -Mdelphi -O3 -Fulib/$(fpc -iTP)-$(fpc -iTO) -k-L/usr/local/lib \
//        -obin/ewsdrd ewsdrd.lpr
//  (см. build-ewsdrd.sh). libwdsp.dylib должна быть в /usr/local/lib.
//
//  Запуск: ./bin/ewsdrd — если есть autostart-устройство (отмеченное в web/GUI),
//  подключается сразу; иначе ждёт connect из web-морды (порт из настроек).
// ===========================================================================

uses
  {$IFDEF UNIX}cthreads, BaseUnix,{$ENDIF}
  Classes, SysUtils,
  RadioController, WebServer, WebAdapter,
  HPSDRNetwork, Settings, WDSP, PlatformUtils, DeviceStore, BoardUtils;

const
  DAEMON_SPEC_WIDTH = 1024;   // ширина спектра для StartRunning (web-буфер = 1024)
  PUSH_INTERVAL_MS  = 50;     // период исходящего web-зеркала (как GUI-таймер)

type
  { THeadlessHost — связывает контроллер, веб-сервер и веб-адаптер без формы. }
  THeadlessHost = class
  private
    FCtrl:  TRadioController;
    FWeb:   TWebServer;
    FWebAd: TWebAdapter;
    FRunning: Boolean;
    // Параметры web-команд (заполняются в WS-потоке, читаются в Sync* главного потока).
    FsStr1, FsStr2: string;
    FsInt:  Integer;
    FsBool: Boolean;
    // Маршалинг метода в главный поток (очередь Synchronize крутит Run-цикл).
    procedure DoInvoke(M: TThreadMethod);
    // Контроллер → наружу: device-list/xvtr не входят в периодический PushState.
    procedure OnState(Sender: TObject; Field: TRadioField);
    // WDSP открываем лениво и синхронно (нет UI — блокировать главный поток ок).
    function  EnsureWDSP: Boolean;
    procedure ConnectTo(const Dev: THPSDRDevice);
    procedure PushDeviceList;
    procedure PushXvtr;
    procedure PushRatePresets;   // sample-rate пресеты бэкенда → web
    procedure PushBands;         // band-план бэкенда → web
    procedure PushFreqDigits;    // per-device freq_mhz_digits → web (формат VFO)
    // Входящие web-команды (из WS-потока): запоминаем параметр и маршалим в Sync*.
    procedure WebRun(On_: Boolean);
    procedure WebConnect(const IP: string);
    procedure WebDiscover;
    procedure WebDisconnect;
    procedure WebDevAdd(const AName, IP: string);
    procedure WebDevRemove(Idx: Integer);
    procedure WebDevAutoStart(Idx: Integer);
    // Sync* — выполняются в главном потоке через DoInvoke.
    procedure SyncConnect;
    procedure SyncDisconnect;
    procedure SyncDiscover;
    procedure SyncRun;
    procedure SyncDevAdd;
    procedure SyncDevRemove;
    procedure SyncDevAutoStart;
  public
    constructor Create;
    destructor  Destroy; override;
    procedure Run;
    procedure RequestStop;   // из обработчика сигнала (только флаг)
  end;

var
  GHost: THeadlessHost = nil;   // для обработчика сигнала

procedure Log(const S: string);
begin
  WriteLn(FormatDateTime('hh:nn:ss', Now), '  ', S);
  Flush(Output);
end;

function DaemonAppName: string;
begin
  // КРИТИЧНО: тот же каталог конфигурации, что у GUI (~/.config/EWSDR/).
  // GetAppConfigDir берёт имя из ApplicationName, иначе бы вышло «ewsdrd» —
  // отдельный пустой конфиг (нет device-настроек/FFT-параметров/FFTW-wisdom →
  // белый водопад + WDSP открывается ~минуту). GUI задаёт Application.Title:=EWSDR.
  Result := 'EWSDR';
end;

{$IFDEF UNIX}
procedure HandleSignal(sig: cint); cdecl;
begin
  if Assigned(GHost) then GHost.RequestStop;
end;
{$ENDIF}

{ THeadlessHost }

constructor THeadlessHost.Create;
var
  StartupVfoA, StartupVfoB: Double;
  StartupRate: Integer;
  WebCfg: TWebSettings;
begin
  inherited Create;
  FCtrl := TRadioController.Create;       // создаёт FSettings + FDeviceStore (LoadSaved)
  FCtrl.LocalAudioEnabled := False;       // headless: звук только в web, не на карту хоста
  FCtrl.OnInvoke      := @DoInvoke;
  FCtrl.OnStateChanged := @OnState;

  // Настройки + startup-частота ДО CreateEngines (DSP рождается с верным rate).
  FCtrl.FSettings.Load;
  if FCtrl.FSettings.LoadStartupPreview(StartupVfoA, StartupVfoB, StartupRate)
     and (StartupRate > 0) then
  begin
    FCtrl.FVfoA := StartupVfoA;
    FCtrl.FVfoB := StartupVfoB;
    FCtrl.FSampleRate := StartupRate;
    FCtrl.FSpanHz     := StartupRate;
  end;

  FCtrl.CreateEngines(FCtrl.FSampleRate);
  // Data-path колбэки (сеть/DSP) — все на контроллер (backend-агностично).
  FCtrl.FNetwork.OnDeviceFound := @FCtrl.OnDeviceFound;
  FCtrl.FNetwork.OnHPStatus    := @FCtrl.OnHPStatus;
  FCtrl.FNetwork.OnDDCIQ       := @FCtrl.OnDDCIQ;
  FCtrl.FNetwork.OnMicPacket   := @FCtrl.OnMicPacket;
  // OnWideband пропущен — это дисплей-панорама, демону не нужна.
  FCtrl.FDSPEngine.OnAudio     := @FCtrl.OnAudioReady;
  FCtrl.FDSPEngine.OnSpectrum  := @FCtrl.OnSpectrumReady;
  FCtrl.FDSPEngine.OnWaterfall := @FCtrl.OnWaterfallReady;
  FCtrl.FDSPEngine.OnTXIQ      := @FCtrl.OnTXIQ;
  FCtrl.FDSPEngine.OnPullMicSamples := @FCtrl.PullSoundCardMic;
  // OnSpectrumData/OnWaterfallData НЕ навешиваем — рендера нет; web читает кадр
  // напрямую из FCtrl.FSpectrumBuf в PushState.

  // --- Веб-сервер + адаптер ---
  FCtrl.FSettings.LoadWebSettings(WebCfg);
  FWeb := TWebServer.Create(WebCfg.User, WebCfg.Pass, Word(WebCfg.Port), WebCfg.BindAddr);
  FWebAd := TWebAdapter.Create(FCtrl, FWeb, nil);
  // RX-аудио веб-клиенту — через route-хук контроллера (D1).
  FCtrl.OnAudioConsume := @FWebAd.AudioConsume;

  // Чистые контроллер-команды → адаптер (как в GUI).
  FWeb.OnMode   := @FWebAd.OnMode;     FWeb.OnFilter := @FWebAd.OnFilter;
  FWeb.OnAGC    := @FWebAd.OnAGC;      FWeb.OnAGCTop := @FWebAd.OnAGCTop;
  FWeb.OnVolume := @FWebAd.OnVolume;   FWeb.OnWfAGC  := @FWebAd.OnWfAGC;
  FWeb.OnWfNF   := @FWebAd.OnWfNF;     FWeb.OnMute   := @FWebAd.OnMute;
  FWeb.OnNR     := @FWebAd.OnNR;       FWeb.OnNB     := @FWebAd.OnNB;
  FWeb.OnSNB    := @FWebAd.OnSNB;      FWeb.OnANF    := @FWebAd.OnANF;
  FWeb.OnFreqB  := @FWebAd.OnFreqB;    FWeb.OnCenter := @FWebAd.OnCenter;
  FWeb.OnAttn   := @FWebAd.OnAttn;     FWeb.OnFreq   := @FWebAd.OnFreq;
  FWeb.OnFreqA  := @FWebAd.OnFreqA;    FWeb.OnBand   := @FWebAd.OnBand;
  FWeb.OnSpan   := @FWebAd.OnSpan;     FWeb.OnDrive  := @FWebAd.OnDrive;
  FWeb.OnCtun   := @FWebAd.OnCtun;     FWeb.OnActiveVfo := @FWebAd.OnActiveVfo;
  FWeb.OnMOX    := @FWebAd.OnMOX;      FWeb.OnTun    := @FWebAd.OnTun;
  FWeb.OnFMStep := @FWebAd.OnFMStep;   FWeb.OnXvtrBand := @FWebAd.OnXvtrBand;
  FWeb.OnBeacon := @FWebAd.OnBeacon;   FWeb.OnBeaconSeed := @FWebAd.OnBeaconSeed;
  FWeb.OnRxGainMode := @FWebAd.OnRxGainMode;
  FWeb.OnRxGain := @FWebAd.OnRxGain;   FWeb.OnRxMute := @FWebAd.OnRxMute;
  FWeb.OnWebMic := @FWebAd.OnMic;
  FWeb.OnClientActiveChanged := @FWebAd.OnClientActiveChanged;
  // Host-coupled (lifecycle) — на демон-хост (маршалит в главный поток).
  FWeb.OnRun          := @WebRun;
  FWeb.OnConnect      := @WebConnect;
  FWeb.OnDiscoverDev  := @WebDiscover;
  FWeb.OnDisconnectDev := @WebDisconnect;
  FWeb.OnDevAdd       := @WebDevAdd;
  FWeb.OnDevRemove    := @WebDevRemove;
  FWeb.OnDevAutoStart := @WebDevAutoStart;
end;

destructor THeadlessHost.Destroy;
begin
  if Assigned(FWeb) then FWeb.Stop;
  FreeAndNil(FWebAd);
  FreeAndNil(FWeb);
  FreeAndNil(FCtrl);   // Destroy зовёт FreeEngines (Disconnect если Connected)
  inherited Destroy;
end;

procedure THeadlessHost.DoInvoke(M: TThreadMethod);
begin
  TThread.Synchronize(nil, M);
end;

procedure THeadlessHost.OnState(Sender: TObject; Field: TRadioField);
begin
  // Большинство rf* — это UI-рендер (демону не нужен). Сюда попадают только
  // выходные web-списки, которых нет в периодическом PushState.
  case Field of
    rfDeviceList: PushDeviceList;             // discovered обновился
    rfXvtr, rfDevice: begin PushXvtr; PushDeviceList; PushRatePresets; PushBands; PushFreqDigits; end;  // connect / xvtr / backend
  end;
end;

function THeadlessHost.EnsureWDSP: Boolean;
var
  WisdomDir: AnsiString;
begin
  if FCtrl.FWDSPReady then Exit(True);
  // FFTW-wisdom ДО Open: иначе XCreateAnalyzer строит планы с нуля (~минуту).
  // WDSPwisdom(dir) импортирует готовый wdspWisdom00 (обычно создан GUI) за мс;
  // если файла нет — строит и пишет его (одноразово, медленно). Как GUI
  // (MainForm.EnsureWDSPWisdom), но без потока/диалога — демону блокировать ок.
  if Assigned(@WDSPwisdom) then
  begin
    WisdomDir := AnsiString(GetAppCfgDir);
    if not FileExists(string(WisdomDir) + 'wdspWisdom00') then
      Log('Building WDSP wisdom (one-time, may take ~30-60s)...');
    WDSPwisdom(PAnsiChar(WisdomDir));
  end;
  Log('Opening WDSP...');
  Result := FCtrl.FDSPEngine.Open;
  FCtrl.FWDSPReady := Result;
  if not Result then
  begin
    Log('WDSP open FAILED: ' + FCtrl.FDSPEngine.LastError);
    Exit;
  end;
  // Ширина анализатора спектра. Без UI её никто не выставляет (GUI зовёт
  // SetSpectrumWidth(панель) каждый тик), и анализатор отдаёт дефолтные 64 пикселя
  // → web рисует лишь ~6% ширины, остальное белым. Ставим ровно под web-буфер
  // (SPECTRUM_PIXELS=1024). FLastSpectrumW переживает пересоздание канала.
  FCtrl.FDSPEngine.SetSpectrumWidth(DAEMON_SPEC_WIDTH);
end;

procedure THeadlessHost.ConnectTo(const Dev: THPSDRDevice);
begin
  if FCtrl.FNetwork.Connected then Exit;
  if not EnsureWDSP then Exit;
  Log('Connecting to ' + Dev.IPAddress + '...');
  if FCtrl.ConnectAndRun(Dev, DAEMON_SPEC_WIDTH) then
    Log('Running. Board=' + IntToStr(FCtrl.FNetwork.Device.BoardType))
  else
    Log('Connect FAILED: ' + FCtrl.FNetwork.LastError);
end;

procedure THeadlessHost.PushDeviceList;
var
  Arr: TWebDeviceArray;
  i, k: Integer;
begin
  SetLength(Arr, FCtrl.FDeviceStore.SavedCount + FCtrl.FDeviceStore.DiscoveredCount);
  k := 0;
  // saved ПЕРВЫМИ — их позиция == saved-индекс (web шлёт его в dev_remove/autostart).
  for i := 0 to FCtrl.FDeviceStore.SavedCount - 1 do
  begin
    Arr[k].Name      := FCtrl.FDeviceStore.Saved(i).Name;
    Arr[k].IP        := FCtrl.FDeviceStore.Saved(i).IPAddress;
    Arr[k].BoardType := FCtrl.FDeviceStore.Saved(i).BoardType;
    Arr[k].IsSaved   := True;
    Arr[k].AutoStart := FCtrl.FDeviceStore.Saved(i).AutoStart;
    Inc(k);
  end;
  for i := 0 to FCtrl.FDeviceStore.DiscoveredCount - 1 do
  begin
    Arr[k].Name      := FCtrl.FDeviceStore.Discovered(i).DisplayName;
    Arr[k].IP        := FCtrl.FDeviceStore.Discovered(i).IPAddress;
    Arr[k].BoardType := FCtrl.FDeviceStore.Discovered(i).BoardType;
    Arr[k].IsSaved   := False;
    Arr[k].AutoStart := False;
    Inc(k);
  end;
  FWeb.SetDeviceList(Arr);
end;

procedure THeadlessHost.PushXvtr;
var
  Arr: TWebXvtrArray;
  i, Cnt: Integer;
begin
  Cnt := 0;
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if FCtrl.FXvtrSettings.Entries[i].Enabled then Inc(Cnt);
  SetLength(Arr, Cnt);
  Cnt := 0;
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if FCtrl.FXvtrSettings.Entries[i].Enabled then
    begin
      Arr[Cnt].Idx  := i;
      Arr[Cnt].Name := FCtrl.FXvtrSettings.Entries[i].ButtonText;
      Inc(Cnt);
    end;
  FWeb.SetXvtrBands(Arr, FCtrl.FCurrentXvtr);
end;

procedure THeadlessHost.PushRatePresets;
begin
  FWeb.SetRatePresets(FCtrl.SampleRatePresets);
end;

procedure THeadlessHost.PushBands;
var
  Plan: TBandPlanArray;
  Names: array of string;
  Freqs: array of Double;
  i: Integer;
begin
  Plan := FCtrl.BandPlan;
  SetLength(Names, Length(Plan));
  SetLength(Freqs, Length(Plan));
  for i := 0 to High(Plan) do
  begin
    Names[i] := Plan[i].Name;
    Freqs[i] := Plan[i].FreqHz;
  end;
  FWeb.SetBands(Names, Freqs);
  with FCtrl.BackendCaps do FWeb.SetFreqRange(MinFreqHz, MaxFreqHz);
end;

procedure THeadlessHost.PushFreqDigits;
// Зеркалим per-device настройку формата VFO в web (как GUI ApplyFreqMhzDigits).
// Без этого демон всегда слал бы дефолтные 3 цифры (999 МГц), и VHF/UHF/SHF не влезал.
var d: Integer;
begin
  d := FCtrl.FLoadedGlobal.FreqMhzDigits;
  if d < 3 then d := 3 else if d > 5 then d := 5;
  FWeb.FreqMhzDigits := d;
end;

// --- Входящие web-команды (WS-поток) → маршалинг в главный поток ---

procedure THeadlessHost.WebRun(On_: Boolean);
begin FsBool := On_; FCtrl.Invoke(@SyncRun); end;

procedure THeadlessHost.WebConnect(const IP: string);
begin FsStr1 := IP; FCtrl.Invoke(@SyncConnect); end;

procedure THeadlessHost.WebDiscover;
begin FCtrl.Invoke(@SyncDiscover); end;

procedure THeadlessHost.WebDisconnect;
begin FCtrl.Invoke(@SyncDisconnect); end;

procedure THeadlessHost.WebDevAdd(const AName, IP: string);
begin FsStr1 := AName; FsStr2 := IP; FCtrl.Invoke(@SyncDevAdd); end;

procedure THeadlessHost.WebDevRemove(Idx: Integer);
begin FsInt := Idx; FCtrl.Invoke(@SyncDevRemove); end;

procedure THeadlessHost.WebDevAutoStart(Idx: Integer);
begin FsInt := Idx; FCtrl.Invoke(@SyncDevAutoStart); end;

// --- Sync* (главный поток) ---

procedure THeadlessHost.SyncConnect;
begin
  if FCtrl.FNetwork.Connected then Exit;
  // Kind/URI/Serial/BoardType при Connect не приходят — восстанавливаем из store
  // (discovered→saved). Без Kind/URI бэкенд бы выбрался HPSDR, и Pluto не открылся.
  ConnectTo(FCtrl.ResolveDevice(FsStr1));
end;

procedure THeadlessHost.SyncDisconnect;
begin
  if FCtrl.FNetwork.Connected then
  begin
    FCtrl.StopAndDisconnect;
    Log('Disconnected (web).');
  end;
end;

procedure THeadlessHost.SyncDiscover;
begin
  // Сетевой Pluto без mDNS находится только прямым probe URI — сидируем список из
  // сохранённых Pluto (в GUI это делает BuildProbeURIs). Без этого Discover видит
  // лишь HPSDR-broadcast и USB-Pluto, а сетевые Pluto — никогда.
  FCtrl.SeedPlutoProbeFromSaved;
  FCtrl.Discover;
end;

procedure THeadlessHost.SyncRun;
begin
  // set_run:true бессмыслен без выбранного устройства (web стартует через connect);
  // здесь только STOP-путь, симметрично web-disconnect.
  if (not FsBool) and FCtrl.FNetwork.Connected then
  begin
    FCtrl.StopAndDisconnect;
    Log('Stopped (web set_run:false).');
  end;
end;

procedure THeadlessHost.SyncDevAdd;
begin FCtrl.AddSavedDevice(FsStr1, FsStr2); PushDeviceList; end;

procedure THeadlessHost.SyncDevRemove;
begin FCtrl.FDeviceStore.RemoveSaved(FsInt); PushDeviceList; end;

procedure THeadlessHost.SyncDevAutoStart;
begin FCtrl.FDeviceStore.SetAutoStart(FsInt); PushDeviceList; end;

procedure THeadlessHost.RequestStop;
begin
  FRunning := False;
end;

procedure THeadlessHost.Run;
var
  WebCfg: TWebSettings;
  Dev: THPSDRDevice;
  AutoDev: TSavedDevice;
  lastPush, lastBeacon, nowMs: QWord;
begin
  FCtrl.FSettings.LoadWebSettings(WebCfg);
  if not FWeb.Start then
  begin
    Log('Web server FAILED to start on ' + WebCfg.BindAddr + ':' + IntToStr(WebCfg.Port));
    Exit;
  end;
  Log('Web control on http://' + WebCfg.BindAddr + ':' + IntToStr(WebCfg.Port));
  PushDeviceList;
  PushRatePresets;   // дефолтный бэкенд (HPSDR) до connect; перезапишется на смене
  PushBands;

  // FRunning ставим ДО autostart: первый WDSP-open может занять время, и если за
  // него прилетит SIGINT (RequestStop→FRunning:=False), цикл сразу завершится, а
  // не перетрётся обратно в True.
  FRunning := True;

  // Autostart — подключаемся сразу к отмеченному устройству. Берём полную запись
  // (Kind/URI/Serial), иначе Pluto не подключить: бэкенд выбирается по Kind, USB-
  // Pluto открывается по URI ('usb:..') и не имеет IPAddress.
  if FCtrl.FDeviceStore.AutoStartDevice(AutoDev) then
  begin
    FillChar(Dev, SizeOf(Dev), 0);
    Dev.Kind      := AutoDev.Kind;
    Dev.IPAddress := AutoDev.IPAddress;
    Dev.URI       := AutoDev.URI;
    Dev.Serial    := AutoDev.Serial;
    Dev.BoardType := AutoDev.BoardType;
    ConnectTo(Dev);
  end
  else
    Log('No autostart device — waiting for web connect.');

  lastPush := GetTickCount64;
  lastBeacon := lastPush;
  while FRunning do
  begin
    CheckSynchronize(PUSH_INTERVAL_MS);   // исполняет очередь Invoke + ждёт до 50мс
    nowMs := GetTickCount64;
    // QO-100 beacon lock — одна итерация контура @~10 Гц (как GUI MeterTimer). No-op
    // если лок выключен. Без этого лок маяка не работал бы в headless-демоне.
    if nowMs - lastBeacon >= 100 then
    begin
      if FCtrl.FRunning and FCtrl.FWDSPReady then
        FCtrl.ServiceBeaconLock;
      lastBeacon := nowMs;
    end;
    if nowMs - lastPush >= PUSH_INTERVAL_MS then
    begin
      // S-метр приходит из WDSP (не из сетевого HP-статуса), поэтому его никто
      // не обновляет в headless — освежаем здесь, как GUI-таймер (MainForm). Без
      // этого PushState слал бы клиентам залипший -130 dBm.
      if FCtrl.FRunning and FCtrl.FWDSPReady then
        FCtrl.FLastSMeter := FCtrl.FDSPEngine.GetSMeterDBm;
      FWebAd.PushState;                   // исходящее зеркало состояния/спектра
      lastPush := nowMs;
    end;
  end;

  Log('Shutting down...');
  if FCtrl.FNetwork.Connected then
    FCtrl.StopAndDisconnect;              // персист настроек + graceful Run=0
end;

begin
  OnGetApplicationName := @DaemonAppName;   // ДО создания контроллера/настроек!
  Log('EWSDR daemon starting (headless).');
  GHost := THeadlessHost.Create;
  {$IFDEF UNIX}
  fpSignal(SIGINT,  @HandleSignal);
  fpSignal(SIGTERM, @HandleSignal);
  {$ENDIF}
  try
    GHost.Run;
  finally
    FreeAndNil(GHost);
  end;
  Log('EWSDR daemon stopped.');
end.
