{
  Copyright (C)
  2026 - Uladzimir Karpenka, EW8BAK

  This program 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 2
  of the License, or (at your option) any later version.

  This program 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 this program; if not, write to the Free Software
  Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
}

unit DXClusterClient;

{
  DXClusterClient.pas — telnet-клиент DX-кластера (одно соединение).

  Один рабочий поток: резолв (в отдельном одноразовом потоке — см. ниже) →
  connect (неблокирующий + select, чтобы не висеть минуту на мёртвом хосте) →
  логин позывным → чтение строк. Разобранные споты
  кладутся ПРЯМО в TDXSpotStore (он потокобезопасен), лог соединения — во
  внутреннее кольцо. В главный поток ничего не маршалим: UI сам замечает
  изменения по Version стора и по LogVersion — как оверлеи замечают смену вида
  по ключам кэша. Никаких LCL-зависимостей.

  Разрыв → реконнект с backoff (RECONNECT_MIN..RECONNECT_MAX). Stop() делает
  SockShutdown, что разблокирует recv в потоке (на Linux одного close мало —
  см. WebUtils.SockShutdown).

  ★Резолв имени вынесен в ОТДЕЛЬНЫЙ поток. Системный резолвер (getaddrinfo на
  unix, gethostbyname на Windows) блокирующий и не прерывается ничем: пока он
  молчит, сокета ещё нет, рвать Stop()'у нечего, а Stop зовётся из UI-потока и
  делает WaitFor — окно висело бы весь DNS-таймаут. Поэтому сессия ждёт результат
  КВАНТАМИ, проверяя Terminated и FStopEvent.
  ★Реестр запросов GResolvePending: по одному резолверу НА ИМЯ и не больше
  DX_MAX_RESOLVERS одновременно. Не дождавшись, сессия НЕ бросает запрос, а
  оставляет его в реестре и на следующей попытке цепляется к тому же — иначе на
  зависшем системном резолвере каждая попытка реконнекта плодила бы новый вечный
  поток. Слотов несколько, а не один, чтобы смена адреса в SETUP давала попытку
  по новому имени даже поверх зависшего прежнего; литеральный IP разбирается
  вообще до реестра (TryLiteralIP) — ввод адреса руками обязан работать всегда.
  Запись запроса живёт по счётчику ссылок (Refs: резолвер + ожидающие); кто
  уронил счётчик в ноль, тот и Dispose.
  ★Имя разрешается через getaddrinfo, а НЕ через netdb.ResolveHostByName: тот
  ходит в DNS напрямую и /etc/hosts не читает вовсе (проверено: getent находит
  localhost, ResolveHostByName — нет), т.е. локальный алиас кластера не работал
  бы; вдобавок он держит глобальное состояние, а getaddrinfo реентерабелен.

  Формат спота (общий для DXSpider / AR-Cluster / CC Cluster):
    DX de UA3XYZ:    14025.0  DL1ABC       CW 599 tnx qso        1832Z
  Всё, что не начинается с 'DX de ', в стор не идёт — только в лог.
}

{$IFDEF FPC}
  {$MODE Delphi}
  {$LONGSTRINGS ON}
{$ENDIF}

interface

uses
  Classes, SysUtils, SyncObjs, StrUtils, DateUtils,
  WebUtils,        // SockClose/SockShutdown/SockRecv/SockSend/SOCK_INVALID
  DXSpotStore
  // ★ Windows.pas здесь НЕТ намеренно: он объявляет TCriticalSection как
  // ЗАПИСЬ (алиас TRTLCriticalSection) и, стоя в uses после SyncObjs,
  // перекрывал класс — под win64 сборка падала на TCriticalSection.Create
  // («identifier idents no member Create»). Всё, что нужно сокетам, даёт
  // WinSock2; так же сделано в WsClient и HPSDRNetwork.
  {$IFDEF MSWINDOWS}
    , WinSock2
  {$ELSE}
    , BaseUnix, Sockets, netdb, ctypes
  {$ENDIF};

const
  DX_DEFAULT_HOST  = 'cluster.dxfun.com';
  DX_DEFAULT_PORT  = 8000;
  DX_CONNECT_MS    = 8000;    // потолок ожидания connect
  DX_CONNECT_SLICE_MS = 200;  // квант ожидания connect (чтобы Stop не ждал 8 с)
  DX_DNS_MS        = 10000;   // потолок ожидания резолва имени
  DX_DNS_SLICE_MS  = 200;     // квант ожидания резолва (чтобы Stop не ждал DNS)
  DX_MAX_RESOLVERS = 4;       // одновременных резолвер-потоков на процесс
  DX_RECV_MS       = 1000;    // квант recv: просыпаемся проверить Terminated/очередь
  DX_RECONNECT_MIN = 5;       // с
  DX_RECONNECT_MAX = 60;      // с
  DX_LOGIN_GRACE_S = 6;       // не увидели приглашение — шлём позывной сами
  DX_POSTLOGIN_S   = 2;       // пауза после последнего шага логина
  DX_PASSWORD_WAIT_S = 8;     // ждём приглашение пароля, потом считаем, что его не будет
  DX_LOGIN_MAX_S   = 20;      // сервер молчит после позывного — идём дальше вслепую
  DX_LOG_LINES     = 300;     // кольцо лога соединения
  DX_SEND_RETRIES  = 20;      // повторов send по таймауту буфера, потом разрыв

type
  TDXClusterState = (dxsOff, dxsConnecting, dxsLogin, dxsOnline, dxsRetry, dxsError);

  TDXClusterClient = class
  private
    FThread:    TThread;
    FLock:      TCriticalSection;   // защищает конфиг, лог, очередь, состояние
    FStopEvent: TEvent;

    // конфиг (копия под FLock — поток читает её через GetConfig)
    FHost:      string;
    FPort:      Integer;
    FLogin:     string;
    FPassword:  string;
    FPostLogin: string;             // строки через LineEnding

    FStore:     TDXSpotStore;       // не владеет

    // Сокет живого соединения — зеркало под FLock. Нужен ровно для того, чтобы
    // Stop() мог сделать SockShutdown и вывести поток из блокирующего recv:
    // иначе выход из программы ждал бы до конца кванта recv (и до конца
    // таймаута send, если кластер перестал забирать данные). Поток снимает
    // зеркало ДО close, поэтому под локом хэндл гарантированно ещё открыт.
    FActiveSock: TSocket;

    FState:     TDXClusterState;
    FStatusMsg: string;
    FSpotCount: Int64;              // сколько спотов принято за сессию

    FLog:       array[0..DX_LOG_LINES-1] of string;
    FLogHead:   Integer;            // индекс следующей записи
    FLogCount:  Integer;
    FLogVer:    Int64;

    FOutQueue:  TStringList;        // строки на отправку в кластер

    procedure SetState(St: TDXClusterState; const Msg: string);
    procedure PublishSock(S: TSocket);   // зеркало хэндла для Stop (см. FActiveSock)
  public
    constructor Create(AStore: TDXSpotStore);
    destructor  Destroy; override;

    procedure Configure(const AHost: string; APort: Integer;
      const ALogin, APassword, APostLogin: string);
    procedure Start;
    // Неблокирующая половина Stop: разбудить поток и порвать сокет, не дожидаясь
    // его конца. Зовётся в начале выхода из программы, чтобы поток доживал
    // ПАРАЛЛЕЛЬНО с разборкой радио, а join в Stop уже никого не ждал.
    procedure RequestStop;
    procedure Stop;              // RequestStop + join
    function  Running: Boolean;

    // Отправить сырую строку в кластер (окно списка: sh/dx, set/filter, …).
    procedure SendCommand(const S: string);

    // Лог соединения — снимок в порядке приёма (старые → новые).
    procedure GetLog(Dst: TStrings);
    function  LogVersion: Int64;
    procedure AddLog(const S: string);

    function State: TDXClusterState;
    function StateText: string;
    function StatusMessage: string;
    function SpotsReceived: Int64;

    property Store: TDXSpotStore read FStore;
  end;

// Разбор строки кластера. True — это спот (Spot заполнен).
function ParseDXSpot(const Line: string; out Spot: TDXSpot): Boolean;
function DXStateName(St: TDXClusterState): string;

implementation

type
  // Запрос на резолв, общий для сессии и одноразового резолвер-потока. Живёт в
  // куче по счётчику ссылок: одна у резолвера, по одной у каждой ждущей сессии.
  // Кто уронил Refs в ноль под GResolveLock, тот и делает Dispose — так
  // брошенный DNS никого не переживает и ничего не течёт.
  PDXResolveReq = ^TDXResolveReq;
  TDXResolveReq = record
    Host: string;
    Addr: LongWord;     // сетевой порядок байт
    Ok:   Boolean;
    Done: Boolean;      // резолвер отработал
    Refs: Integer;
  end;

  // Чем кончилось ожидание резолва — от этого зависит, что писать в лог.
  TDXResolveOutcome = (droOk,        // имя разрешено
                       droFailed,    // резолвер ответил «нет такого имени»
                       droTimeout,   // не дождались (или нас остановили)
                       droBusy);     // в полёте резолв ДРУГОГО имени

  TDXResolverThread = class(TThread)
  private
    FReq: PDXResolveReq;
  protected
    procedure Execute; override;
  public
    constructor Create(AReq: PDXResolveReq);
  end;

  TDXClusterThread = class(TThread)
  private
    FOwner:  TDXClusterClient;
    FSocket: TSocket;
    FRxBuf:  string;             // хвост неполной строки

    // конфиг сессии (снимок на время соединения)
    FHost:     string;
    FPort:     Integer;
    FLogin:    string;
    FPassword: string;
    FPostLogin: string;

    FLoginSent:  Boolean;
    FLoginByPrompt: Boolean;  // позывной ушёл в ответ на приглашение, не вслепую
    FPassSent:   Boolean;
    FPostSent:   Boolean;
    FLoginAt:    TDateTime;   // когда ушёл позывной
    FLoginStepAt: TDateTime;  // когда ушла последняя порция учётки (позывной/пароль)
    FSawAfterLogin: Boolean;  // после учётки сервер что-то прислал
    FConnectAt:  TDateTime;
    FFatal:      Boolean;     // отказ авторизации — реконнект бессмыслен
    // Приглашение, на которое мы только что ответили, разобрав НЕЗАВЕРШЁННЫЙ
    // хвост. Оно остаётся в буфере и со следующим куском доедет целой строкой —
    // окно ровно на одну строку, чтобы не счесть собственное эхо новым вопросом.
    FAnsweredPrompt: string;

    procedure FailAuth(const Reason: string);
    function  LoginSettled: Boolean;
    function  SendPostLogin: Boolean;
    function  Resolve(const Host: string; out Addr: LongWord): TDXResolveOutcome;
    function  ConnectSock: Boolean;
    procedure CloseSock;
    function  SendAll(const Data: string): Boolean;
    function  SendLine(const S: string): Boolean;
    procedure FlushOutQueue;
    procedure HandleChunk(const Data: string);
    procedure HandleLine(const Line: string);
    procedure CheckPrompts(const Tail: string; IsTail: Boolean);
    function  Session: Boolean;    // одно соединение; False — выйти совсем
  protected
    procedure Execute; override;
  public
    constructor Create(AOwner: TDXClusterClient);
  end;

var
  // Один на все запросы резолва: они редки (одна попытка на соединение), а общий
  // лок избавляет запись запроса от собственной критической секции — иначе её
  // пришлось бы освобождать ровно тому, кто освобождает саму запись.
  GResolveLock: TCriticalSection;
  // Запросы «в полёте», по одному на имя. Слот занят ⇒ по этому имени уже
  // работает резолвер-поток, и второго по нему мы не заводим (см. шапку юнита).
  GResolvePending: array[0..DX_MAX_RESOLVERS - 1] of PDXResolveReq;
{$IFDEF MSWINDOWS}
  GWSAData: TWSAData;
{$ENDIF}

function DXStateName(St: TDXClusterState): string;
begin
  case St of
    dxsOff:        Result := 'OFF';
    dxsConnecting: Result := 'CONNECTING';
    dxsLogin:      Result := 'LOGIN';
    dxsOnline:     Result := 'ONLINE';
    dxsRetry:      Result := 'RETRY';
  else             Result := 'ERROR';
  end;
end;

function SockWouldBlock: Boolean;
// Последняя ошибка сокета означает «данных пока нет / истёк SO_RCVTIMEO /
// прерван сигналом» — соединение живо. Всё остальное (ECONNRESET, ENOTCONN,
// EPIPE…) — разрыв: без этой проверки поток крутил бы recv в пустом цикле,
// вместо того чтобы уйти на реконнект.
var E: Integer;
begin
{$IFDEF MSWINDOWS}
  E := WSAGetLastError;
  Result := (E = WSAEWOULDBLOCK) or (E = WSAETIMEDOUT) or (E = WSAEINTR);
{$ELSE}
  E := fpgeterrno;
  Result := (E = ESysEAGAIN) or (E = ESysEWOULDBLOCK) or (E = ESysEINTR);
{$ENDIF}
end;

{ ── Разбор строки спота ──────────────────────────────────────────────────── }

function ParseDXSpot(const Line: string; out Spot: TDXSpot): Boolean;
// 'DX de <spotter>: <кГц> <позывной> <комментарий> <HHMM>Z [грид]'
var
  S, Rest, FreqStr, TimeTok: string;
  P, i: Integer;
  KHz: Double;
  FS: TFormatSettings;
begin
  Result := False;
  FillChar(Spot, SizeOf(Spot), 0);
  Spot.Call := ''; Spot.Spotter := ''; Spot.Comment := ''; Spot.TimeUTC := '';

  S := Trim(Line);
  if Length(S) < 12 then Exit;
  if not SameText(Copy(S, 1, 6), 'DX de ') then Exit;

  Rest := Copy(S, 7, MaxInt);
  P := Pos(':', Rest);
  if P <= 1 then Exit;
  Spot.Spotter := Trim(Copy(Rest, 1, P - 1));
  Rest := Trim(Copy(Rest, P + 1, MaxInt));

  // частота (кГц) — первый токен
  P := Pos(' ', Rest);
  if P <= 1 then Exit;
  FreqStr := Copy(Rest, 1, P - 1);
  Rest := TrimLeft(Copy(Rest, P + 1, MaxInt));

  FS := DefaultFormatSettings;
  FS.DecimalSeparator := '.';
  FreqStr := StringReplace(FreqStr, ',', '.', [rfReplaceAll]);
  if not TryStrToFloat(FreqStr, KHz, FS) then Exit;
  if (KHz <= 0) or (KHz > 100000000.0) then Exit;   // мусор/битая строка
  Spot.FreqHz := KHz * 1000.0;

  // позывной DX — второй токен
  P := Pos(' ', Rest);
  if P > 0 then
  begin
    Spot.Call := Copy(Rest, 1, P - 1);
    Rest := TrimLeft(Copy(Rest, P + 1, MaxInt));
  end
  else
  begin
    Spot.Call := Rest;
    Rest := '';
  end;
  if Spot.Call = '' then Exit;

  // Время: последний токен вида HHMMZ. Всё до него — комментарий, всё после
  // (обычно грид спотера) приклеиваем к комментарию — терять жалко.
  Rest := TrimRight(Rest);
  P := 0;
  for i := Length(Rest) - 4 downto 1 do
    if (Rest[i] in ['0'..'9']) and (Rest[i+1] in ['0'..'9']) and
       (Rest[i+2] in ['0'..'9']) and (Rest[i+3] in ['0'..'9']) and
       (UpCase(Rest[i+4]) = 'Z') and
       ((i = 1) or (Rest[i-1] = ' ')) then
    begin
      P := i;
      Break;
    end;
  if P > 0 then
  begin
    TimeTok := Copy(Rest, P, 4);
    Spot.TimeUTC := TimeTok;
    Spot.Comment := Trim(Copy(Rest, 1, P - 1));
    Rest := Trim(Copy(Rest, P + 5, MaxInt));
    if Rest <> '' then Spot.Comment := Trim(Spot.Comment + ' ' + Rest);
  end
  else
    Spot.Comment := Trim(Rest);

  Spot.Mode  := DXModeFromComment(Spot.Comment);
  // Комментарий молчит — берём моду с участка бэндплана: в CW и SSB её сплошь
  // и рядом просто не пишут, а участок эти две моды разделяет надёжно.
  // Помечаем такой спот: угаданное не должно выглядеть как сказанное спотером.
  Spot.ModeGuessed := False;
  if Spot.Mode = dxmUnknown then
  begin
    Spot.Mode := DXModeFromFreq(Spot.FreqHz);
    Spot.ModeGuessed := Spot.Mode <> dxmUnknown;
  end;
  Spot.Stamp := Now;
  Result := True;
end;

{ ── TDXClusterClient ─────────────────────────────────────────────────────── }

constructor TDXClusterClient.Create(AStore: TDXSpotStore);
begin
  inherited Create;
  FStore     := AStore;
  FLock      := TCriticalSection.Create;
  FStopEvent := TEvent.Create(nil, True, False, '');
  FOutQueue  := TStringList.Create;
  FHost      := DX_DEFAULT_HOST;
  FPort      := DX_DEFAULT_PORT;
  FState     := dxsOff;
  FLogHead   := 0;
  FLogCount  := 0;
  FLogVer    := 0;
  FSpotCount := 0;
  FActiveSock := SOCK_INVALID;
  // WSAStartup/WSACleanup здесь БЫЛИ и убраны — см. initialization юнита.
end;

destructor TDXClusterClient.Destroy;
begin
  Stop;
  FOutQueue.Free;
  FStopEvent.Free;
  FLock.Free;
  inherited Destroy;
end;

procedure TDXClusterClient.Configure(const AHost: string; APort: Integer;
  const ALogin, APassword, APostLogin: string);
begin
  FLock.Enter;
  try
    // Смена адреса/учётки — команды, набранные для ПРЕЖНЕГО кластера, теряют
    // смысл (и ушли бы на новый). Чистим очередь.
    if (FHost <> Trim(AHost)) or (FPort <> APort) or (FLogin <> Trim(ALogin)) then
      FOutQueue.Clear;
    FHost      := Trim(AHost);
    FPort      := APort;
    FLogin     := Trim(ALogin);
    FPassword  := APassword;
    FPostLogin := APostLogin;
  finally
    FLock.Leave;
  end;
end;

procedure TDXClusterClient.Start;
begin
  // Поток прошлой сессии мог завершиться сам (кластер отверг логин) — прибираем
  // его, иначе Start молча ничего бы не сделал и кнопка CONNECT в окне
  // кластера перестала бы работать до перезапуска программы.
  if (FThread <> nil) and FThread.Finished then
  begin
    FThread.WaitFor;      // уже завершён — не ждёт
    FThread.Free;
    FThread := nil;
  end;
  if FThread <> nil then Exit;
  FLock.Enter;
  try
    if (FHost = '') or (FLogin = '') then
    begin
      FState := dxsError;
      FStatusMsg := 'host or callsign not set';
      Exit;
    end;
  finally
    FLock.Leave;
  end;
  FStopEvent.ResetEvent;
  SetState(dxsConnecting, '');
  FThread := TDXClusterThread.Create(Self);
end;

procedure TDXClusterClient.RequestStop;
begin
  // Всё, что не успели отправить, к следующему соединению уже неактуально.
  FLock.Enter;
  try
    FOutQueue.Clear;
  finally
    FLock.Leave;
  end;
  if FThread = nil then Exit;
  FThread.Terminate;
  FStopEvent.SetEvent;
  // Терминации мало: поток стоит в блокирующем recv и проснулся бы только по
  // SO_RCVTIMEO (до DX_RECV_MS), а на застрявшем send — до SO_SNDTIMEO. Всё это
  // время join держал бы главный поток, т.е. окно висело бы на выходе и на
  // переподключении после правки настроек. SockShutdown рвёт обе стороны
  // немедленно (на Linux одного close мало — см. WebUtils.SockShutdown).
  // Закрывает сокет по-прежнему сам поток: закрыть чужой хэндл значило бы
  // гонку с его же recv.
  FLock.Enter;
  try
    if FActiveSock <> SOCK_INVALID then SockShutdown(FActiveSock);
  finally
    FLock.Leave;
  end;
end;

procedure TDXClusterClient.Stop;
var T: TThread;
begin
  RequestStop;
  T := FThread;
  if T = nil then
  begin
    SetState(dxsOff, '');
    Exit;
  end;
  FThread := nil;
  T.WaitFor;
  T.Free;
  SetState(dxsOff, '');
end;

function TDXClusterClient.Running: Boolean;
// Завершившийся сам поток (отказ авторизации) — уже не «работаем»: иначе окно
// кластера показывало бы DISCONNECT у мёртвого соединения.
begin
  Result := (FThread <> nil) and (not FThread.Finished);
end;

procedure TDXClusterClient.PublishSock(S: TSocket);
// Зеркало хэндла для Stop. Поток зовёт с валидным хэндлом сразу после connect
// и с SOCK_INVALID ПЕРЕД close — так Stop под локом видит либо живой сокет,
// либо ничего, но не закрытый.
begin
  FLock.Enter;
  try
    FActiveSock := S;
  finally
    FLock.Leave;
  end;
end;

procedure TDXClusterClient.SetState(St: TDXClusterState; const Msg: string);
begin
  FLock.Enter;
  try
    FState := St;
    FStatusMsg := Msg;
    Inc(FLogVer);      // UI перечитает и статус тоже
  finally
    FLock.Leave;
  end;
end;

function TDXClusterClient.State: TDXClusterState;
begin
  FLock.Enter;
  try
    Result := FState;
  finally
    FLock.Leave;
  end;
end;

function TDXClusterClient.StateText: string;
begin
  Result := DXStateName(State);
end;

function TDXClusterClient.StatusMessage: string;
begin
  FLock.Enter;
  try
    Result := FStatusMsg;
  finally
    FLock.Leave;
  end;
end;

function TDXClusterClient.SpotsReceived: Int64;
begin
  FLock.Enter;
  try
    Result := FSpotCount;
  finally
    FLock.Leave;
  end;
end;

procedure TDXClusterClient.SendCommand(const S: string);
var Queued: Boolean;
begin
  if Trim(S) = '' then Exit;
  FLock.Enter;
  try
    FOutQueue.Add(S);
    Queued := FState <> dxsOnline;
  finally
    FLock.Leave;
  end;
  // До конца логина поток очередь не разбирает (иначе команда ушла бы вместо
  // позывного или пароля). Молча «проглоченная» команда выглядела бы как
  // потеря — говорим в лог, что она ждёт.
  if Queued then AddLog('*** queued until login completes: ' + S);
end;

procedure TDXClusterClient.AddLog(const S: string);
begin
  FLock.Enter;
  try
    FLog[FLogHead] := S;
    FLogHead := (FLogHead + 1) mod DX_LOG_LINES;
    if FLogCount < DX_LOG_LINES then Inc(FLogCount);
    Inc(FLogVer);
  finally
    FLock.Leave;
  end;
end;

procedure TDXClusterClient.GetLog(Dst: TStrings);
var i, Start: Integer;
begin
  if Dst = nil then Exit;
  FLock.Enter;
  try
    Start := (FLogHead - FLogCount + DX_LOG_LINES) mod DX_LOG_LINES;
    for i := 0 to FLogCount - 1 do
      Dst.Add(FLog[(Start + i) mod DX_LOG_LINES]);
  finally
    FLock.Leave;
  end;
end;

function TDXClusterClient.LogVersion: Int64;
begin
  FLock.Enter;
  try
    Result := FLogVer;
  finally
    FLock.Leave;
  end;
end;

{ ── TDXClusterThread ─────────────────────────────────────────────────────── }

constructor TDXClusterThread.Create(AOwner: TDXClusterClient);
begin
  FOwner  := AOwner;
  FSocket := SOCK_INVALID;
  FreeOnTerminate := False;
  inherited Create(False);
end;

{ ── Резолв имени ─────────────────────────────────────────────────────────── }

{$IFNDEF MSWINDOWS}
// getaddrinfo(3) объявляем сами: в RTL его нет (netdb использует его только
// под FPC_USE_LIBC, а этой сборки у нас нет), а тащить ради него устаревший
// пакет libc не хочется. Порядок полей структуры фиксирован ABI; BSD/macOS
// меняет местами ai_canonname и ai_addr — единственное расхождение.
{$PACKRECORDS C}
type
  PCAddrInfo = ^TCAddrInfo;
  TCAddrInfo = record
    ai_flags:     cint;
    ai_family:    cint;
    ai_socktype:  cint;
    ai_protocol:  cint;
    ai_addrlen:   cuint32;
  {$IFDEF DARWIN}
    ai_canonname: PChar;
    ai_addr:      Pointer;
  {$ELSE}
    ai_addr:      Pointer;
    ai_canonname: PChar;
  {$ENDIF}
    ai_next:      PCAddrInfo;
  end;

  PSockAddrIn4 = ^TInetSockAddr;
{$PACKRECORDS DEFAULT}

function c_getaddrinfo(node, service: PChar; hints: PCAddrInfo;
  out res: PCAddrInfo): cint; cdecl; external 'c' name 'getaddrinfo';
procedure c_freeaddrinfo(res: PCAddrInfo); cdecl; external 'c' name 'freeaddrinfo';
{$ENDIF}

function TryLiteralIP(const Host: string; out Addr: LongWord): Boolean;
// Литеральный адрес разбирается БЕЗ резолвера — строкой, мгновенно. Отдельной
// функцией он нужен для того, чтобы вводом IP всегда можно было выбраться из
// повисшего системного резолвера: ждать очереди в реестре запросов ради
// «127.0.0.1» было бы издевательством. Возвращает СЕТЕВОЙ порядок байт.
{$IFDEF MSWINDOWS}
var L: LongWord;
begin
  Addr := 0;
  L := inet_addr(PChar(Host));
  Result := L <> INADDR_NONE;      // inet_addr уже в сетевом порядке
  if Result then Addr := L;
end;
{$ELSE}
var HA: THostAddr;
begin
  Addr := 0;
  // TryStrToHostAddr отдаёт ХОСТОВЫЙ порядок — разворачиваем.
  Result := TryStrToHostAddr(Host, HA);
  if Result then Addr := htonl(HA.s_addr);
end;
{$ENDIF}

function ResolveHostSync(const Host: string; out Addr: LongWord): Boolean;
// Блокирующий системный резолв. Возвращает адрес в СЕТЕВОМ порядке байт
// (готов для sin_addr). Зовётся ТОЛЬКО из TDXResolverThread — прервать его
// нечем, поэтому ждать его результат напрямую нельзя (см. шапку юнита).
{$IFNDEF MSWINDOWS}
var Hints: TCAddrInfo; Res, AI: PCAddrInfo;
{$ELSE}
var PH: PHostEnt;
{$ENDIF}
begin
  Result := False;
  Addr := 0;
  if Host = '' then Exit;
  if TryLiteralIP(Host, Addr) then Exit(True);
{$IFDEF MSWINDOWS}
  PH := gethostbyname(PChar(Host));
  if (PH = nil) or (PH^.h_addr_list = nil) or (PH^.h_addr_list^ = nil) then Exit;
  Move(PH^.h_addr_list^^, Addr, 4);  // hostent отдаёт сетевой порядок
  Result := True;
{$ELSE}
  // Системный путь: getaddrinfo идёт через NSS ⇒ видит /etc/hosts, mDNS,
  // systemd-resolved — всё, что настроено у пользователя. Адрес в ai_addr уже
  // в сетевом порядке, второй раз не вертим.
  FillChar(Hints, SizeOf(Hints), 0);
  Hints.ai_family   := AF_INET;        // сокет мы поднимаем IPv4
  Hints.ai_socktype := SOCK_STREAM;
  Res := nil;
  if (c_getaddrinfo(PChar(Host), nil, @Hints, Res) <> 0) or (Res = nil) then Exit;
  try
    AI := Res;
    while AI <> nil do
    begin
      if (AI^.ai_family = AF_INET) and (AI^.ai_addr <> nil) then
      begin
        Addr := PSockAddrIn4(AI^.ai_addr)^.sin_addr.s_addr;
        Exit(True);
      end;
      AI := AI^.ai_next;
    end;
  finally
    c_freeaddrinfo(Res);
  end;
{$ENDIF}
end;

procedure ReleaseResolveLocked(Req: PDXResolveReq);
// Зовётся ТОЛЬКО под GResolveLock. Последний ушедший гасит свет.
begin
  Dec(Req^.Refs);
  if Req^.Refs <= 0 then Dispose(Req);
end;

procedure UnlistResolveLocked(Req: PDXResolveReq);
// Снять запрос с реестра «в полёте». Только под GResolveLock.
var i: Integer;
begin
  for i := 0 to High(GResolvePending) do
    if GResolvePending[i] = Req then GResolvePending[i] := nil;
end;

function AcquireResolve(const Host: string;
  out Req: PDXResolveReq): TDXResolveOutcome;
// droOk — Req наш (ссылку обязательно вернуть через ReleaseResolveLocked).
// droBusy — все слоты заняты ЗАВИСШИМИ резолвами ЧУЖИХ имён.
// droFailed — поток не создался.
var i, Slot: Integer;
begin
  Result := droBusy;
  Req := nil;
  GResolveLock.Enter;
  try
    Slot := -1;
    for i := 0 to High(GResolvePending) do
      if GResolvePending[i] = nil then
      begin
        if Slot < 0 then Slot := i;
      end
      else if GResolvePending[i]^.Host = Host then
      begin
        // Тот же хост — цепляемся к уже летящему запросу вместо второго потока.
        // Именно это и держит число резолверов конечным, когда системный
        // резолвер завис: КАЖДАЯ следующая попытка ждёт ТОТ ЖЕ запрос.
        Req := GResolvePending[i];
        Inc(Req^.Refs);
        Exit(droOk);
      end;
    // Слотов несколько, а не один, ради простой вещи: сменив в SETUP адрес
    // кластера, пользователь обязан получить попытку по НОВОМУ имени даже если
    // предыдущее намертво зависло в системном резолвере. Потолок оставлен,
    // чтобы такие «вечные» потоки не копились без счёта.
    if Slot < 0 then Exit;
    New(Req);
    Req^.Host := Host;
    Req^.Addr := 0;
    Req^.Ok   := False;
    Req^.Done := False;
    Req^.Refs := 2;                    // резолвер + мы
    GResolvePending[Slot] := Req;
    try
      TDXResolverThread.Create(Req);
    except
      GResolvePending[Slot] := nil;    // поток не родился — запись ничья
      Dispose(Req);
      Req := nil;
      Exit(droFailed);
    end;
    Result := droOk;
  finally
    GResolveLock.Leave;
  end;
end;

constructor TDXResolverThread.Create(AReq: PDXResolveReq);
begin
  FReq := AReq;
  FreeOnTerminate := True;   // сессия его не ждёт и не освобождает
  inherited Create(False);
end;

procedure TDXResolverThread.Execute;
var
  A: LongWord;
  Ok: Boolean;
begin
  A  := 0;
  Ok := False;
  try
    // Host записан до старта потока и больше никем не трогается — без лока.
    Ok := ResolveHostSync(FReq^.Host, A);
  except
    // ★Любое исключение здесь обязано ЗАВЕРШИТЬСЯ снятием запроса с реестра.
    // Иначе слот остался бы занят навсегда: то же имя давало бы вечные
    // таймауты (Done никогда не выставится), а остальные — вечный DNS busy.
    // TThread исключение проглатывает (кладёт в FatalException), так что без
    // этого except мы бы даже не заметили.
    Ok := False;
  end;
  GResolveLock.Enter;
  try
    FReq^.Addr := A;
    FReq^.Ok   := Ok;
    FReq^.Done := True;
    // Снимаемся с «в полёте»: следующая попытка начнёт свежий резолв, а этот
    // результат достанется лишь тому, кто его уже ждёт.
    UnlistResolveLocked(FReq);
    ReleaseResolveLocked(FReq);        // наша ссылка
  finally
    GResolveLock.Leave;
  end;
  FReq := nil;
end;

function TDXClusterThread.Resolve(const Host: string;
  out Addr: LongWord): TDXResolveOutcome;
// Ждём резолвер квантами, чтобы Stop/Terminate не упирались в системный
// DNS-таймаут. Не дождались — ссылку отдаём, но сам запрос остаётся в реестре:
// следующая попытка подождёт его же, а не заведёт второй вечный поток.
var
  Req: PDXResolveReq;
  Waited: Integer;
  Ready: Boolean;
begin
  Addr := 0;
  Result := droFailed;
  if Host = '' then Exit;
  // ★Литеральный адрес разбираем ЗДЕСЬ, до всякого реестра: иначе, когда
  // системный резолвер завис, ввод IP руками — единственный способ выбраться —
  // упирался бы в чужой занятый слот ровно так же, как имя.
  if TryLiteralIP(Host, Addr) then Exit(droOk);

  Result := AcquireResolve(Host, Req);
  if Result <> droOk then Exit;      // все слоты заняты / поток не создался
  Result := droFailed;               // дальше исход решает ожидание ниже

  try
    Waited := 0;
    while not Terminated do
    begin
      GResolveLock.Enter;
      try
        Ready := Req^.Done;
      finally
        GResolveLock.Leave;
      end;
      if Ready or (Waited >= DX_DNS_MS) then Break;
      // Ждём на FStopEvent, а не Sleep: Stop будит нас немедленно.
      if FOwner.FStopEvent.WaitFor(DX_DNS_SLICE_MS) = wrSignaled then Break;
      Inc(Waited, DX_DNS_SLICE_MS);
    end;
  finally
    // Ссылку отдаём при любом исходе, включая исключение в ожидании: иначе
    // запись зависла бы с лишней ссылкой и не освободилась никогда.
    GResolveLock.Enter;
    try
      if not Req^.Done then
        Result := droTimeout
      else if Req^.Ok then
      begin
        Addr   := Req^.Addr;
        Result := droOk;
      end
      else
        Result := droFailed;
      ReleaseResolveLocked(Req);
    finally
      GResolveLock.Leave;
    end;
  end;

  if Terminated then Result := droTimeout;   // уходим — адрес уже не нужен
end;

function TDXClusterThread.ConnectSock: Boolean;
// Неблокирующий connect + select: мёртвый хост не держит поток минуту на
// системном таймауте TCP, и Stop() отрабатывает быстро.
var
  Addr: {$IFDEF MSWINDOWS}TSockAddrIn{$ELSE}TInetSockAddr{$ENDIF};
  IP: LongWord;
  R, SoErr: Integer;
  FDS: TFDSet;
  TV: TTimeVal;
  One, Waited: Integer;
  {$IFDEF MSWINDOWS}ErrLen: Integer;{$ELSE}ErrLen: TSockLen;{$ENDIF}
begin
  Result := False;
  case Resolve(FHost, IP) of
    droOk: ;
    droBusy:
      begin
        FOwner.SetState(dxsRetry, 'DNS busy');
        FOwner.AddLog('*** DNS: previous lookup still running — retrying later');
        Exit;
      end;
    droTimeout:
      begin
        if Terminated then Exit;   // это не отказ DNS, а наш же Stop
        FOwner.SetState(dxsRetry, 'DNS timeout: ' + FHost);
        FOwner.AddLog('*** DNS: no answer for ' + FHost);
        Exit;
      end;
  else
    if Terminated then Exit;
    FOwner.SetState(dxsRetry, 'cannot resolve ' + FHost);
    FOwner.AddLog('*** DNS: cannot resolve ' + FHost);
    Exit;
  end;
  if Terminated then Exit;

  {$IFDEF MSWINDOWS}
  FSocket := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  {$ELSE}
  FSocket := fpSocket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  {$ENDIF}
  if FSocket = SOCK_INVALID then
  begin
    FOwner.SetState(dxsRetry, 'cannot create socket');
    Exit;
  end;

  FillChar(Addr, SizeOf(Addr), 0);
  Addr.sin_family := AF_INET;
  Addr.sin_port   := htons(FPort);
  Addr.sin_addr.s_addr := IP;

  SockSetNonBlock(FSocket, True);
  {$IFDEF MSWINDOWS}
  R := WinSock2.connect(FSocket, @Addr, SizeOf(Addr));
  {$ELSE}
  R := fpConnect(FSocket, @Addr, SizeOf(Addr));
  {$ENDIF}
  if R <> 0 then
  begin
    // ожидаемо: EINPROGRESS / WSAEWOULDBLOCK — ждём готовности на запись.
    // Ждём НЕ одним select на 8 с, а квантами по DX_CONNECT_SLICE_MS: иначе
    // Stop() (закрытие программы) висел бы до конца таймаута соединения.
    Waited := 0;
    R := 0;
    while (Waited < DX_CONNECT_MS) and (not Terminated) do
    begin
      TV.tv_sec  := DX_CONNECT_SLICE_MS div 1000;
      TV.tv_usec := (DX_CONNECT_SLICE_MS mod 1000) * 1000;
      {$IFDEF MSWINDOWS}
      FD_ZERO(FDS);
      FD_SET(FSocket, FDS);
      R := select(0, nil, @FDS, nil, @TV);
      {$ELSE}
      fpFD_ZERO(FDS);
      fpFD_SET(FSocket, FDS);
      R := fpSelect(FSocket + 1, nil, @FDS, nil, @TV);
      {$ENDIF}
      if R <> 0 then Break;                    // готов или ошибка select
      Inc(Waited, DX_CONNECT_SLICE_MS);
    end;
    if Terminated then
    begin
      CloseSock;
      Exit;
    end;
    if R <= 0 then
    begin
      CloseSock;
      FOwner.SetState(dxsRetry, 'connect timeout: ' + FHost);
      FOwner.AddLog('*** no answer from ' + FHost + ':' + IntToStr(FPort));
      Exit;
    end;
    SoErr := 0; ErrLen := SizeOf(SoErr);
    {$IFDEF MSWINDOWS}
    getsockopt(FSocket, SOL_SOCKET, SO_ERROR, PChar(@SoErr), ErrLen);
    {$ELSE}
    fpGetSockOpt(FSocket, SOL_SOCKET, SO_ERROR, @SoErr, @ErrLen);
    {$ENDIF}
    if SoErr <> 0 then
    begin
      CloseSock;
      FOwner.SetState(dxsRetry, 'connection refused');
      FOwner.AddLog('*** refused: ' + FHost + ':' + IntToStr(FPort));
      Exit;
    end;
  end;

  SockSetNonBlock(FSocket, False);
  SockSetRcvTimeout(FSocket, DX_RECV_MS);
  SockSetSndTimeout(FSocket, 5000);
  One := 1;
  {$IFDEF MSWINDOWS}
  setsockopt(FSocket, SOL_SOCKET, SO_KEEPALIVE, PChar(@One), SizeOf(One));
  {$ELSE}
  fpSetSockOpt(FSocket, SOL_SOCKET, SO_KEEPALIVE, @One, SizeOf(One));
  {$ENDIF}
  FOwner.PublishSock(FSocket);   // с этого момента Stop может рвать нас shutdown'ом
  Result := True;
end;

procedure TDXClusterThread.CloseSock;
begin
  if FSocket <> SOCK_INVALID then
  begin
    FOwner.PublishSock(SOCK_INVALID);   // до close: Stop не должен увидеть мёртвый хэндл
    SockShutdown(FSocket);
    SockClose(FSocket);
    FSocket := SOCK_INVALID;
  end;
end;

function TDXClusterThread.SendAll(const Data: string): Boolean;
// TCP вправе отправить меньше запрошенного — дописываем остаток. Таймаут
// (SO_SNDTIMEO) и EINTR — не ошибка соединения, пробуем ещё, но не бесконечно.
var
  Sent, N, Tries: Integer;
begin
  Result := False;
  if (FSocket = SOCK_INVALID) or (Data = '') then Exit;
  Sent  := 0;
  Tries := 0;
  while Sent < Length(Data) do
  begin
    if Terminated then Exit;
    N := SockSend(FSocket, @Data[Sent + 1], Length(Data) - Sent, 0);
    if N > 0 then
    begin
      Inc(Sent, N);
      Tries := 0;
    end
    else
    begin
      if not SockWouldBlock then Exit;      // настоящая ошибка сокета
      Inc(Tries);
      if Tries > DX_SEND_RETRIES then Exit; // не отдаёт буфер — считаем разрывом
    end;
  end;
  Result := True;
end;

function TDXClusterThread.SendLine(const S: string): Boolean;
begin
  Result := SendAll(S + #13#10);
  if not Result then
    FOwner.AddLog('*** send failed, dropping connection');
end;

procedure TDXClusterThread.FlushOutQueue;
var
  Pending: TStringList;
  i, j: Integer;
begin
  Pending := nil;
  FOwner.FLock.Enter;
  try
    if FOwner.FOutQueue.Count > 0 then
    begin
      Pending := TStringList.Create;
      Pending.Assign(FOwner.FOutQueue);
      FOwner.FOutQueue.Clear;
    end;
  finally
    FOwner.FLock.Leave;
  end;
  if Pending = nil then Exit;
  try
    i := 0;
    while i < Pending.Count do
    begin
      if not SendLine(Pending[i]) then Break;   // сокет умер
      FOwner.AddLog('> ' + Pending[i]);
      Inc(i);
    end;
    // ★Не ушедшее (начиная с той самой команды) возвращаем в НАЧАЛО очереди:
    // очередь выгребается целиком до отправки, и без возврата всё, до чего
    // не дошли, просто пропадало бы — а обещано «отправим после реконнекта».
    // В начало, потому что порядок для кластера значим (set/filter до sh/dx).
    if i < Pending.Count then
    begin
      FOwner.FLock.Enter;
      try
        for j := Pending.Count - 1 downto i do
          FOwner.FOutQueue.Insert(0, Pending[j]);
      finally
        FOwner.FLock.Leave;
      end;
      FOwner.AddLog(Format('*** %d command(s) held for the next connection',
        [Pending.Count - i]));
    end;
  finally
    Pending.Free;
  end;
end;

function PromptEndsWith(const U, Key: string): Boolean;
// U (уже в нижнем регистре) заканчивается ПРИГЛАШЕНИЕМ вида '…password:'.
// Двоеточие в конце — то, что отличает вопрос кластера от упоминания слова в
// приветствии («you can set a password with set/password»): на таком упоминании
// вывод «пароль отвергнут» порвал бы живое соединение.
var S: string; P: Integer;
begin
  Result := False;
  S := TrimRight(U);
  if S = '' then Exit;
  if S[Length(S)] <> ':' then Exit;
  // ★Ищем ПОСЛЕДНЕЕ вхождение: в 'invalid password, enter password:' первое
  // стоит далеко от конца и по нему приглашение не опозналось бы вовсе.
  P := RPos(Key, S);
  // Ключ должен стоять у самого конца: '…password (again):' считаем тем же.
  Result := (P > 0) and (Length(S) - (P + Length(Key) - 1) <= 10);
end;

function IsLoginPrompt(const U: string): Boolean;
// Строгая форма вопроса о позывном: 'login:', 'call:', 'callsign:', 'your
// call:'. Свободный поиск слова здесь не годится — по нему приветствие с
// упоминанием позывного сошло бы за повторный вопрос, т.е. за отказ.
begin
  Result := PromptEndsWith(U, 'login') or PromptEndsWith(U, 'call');
end;

function LooksLikeAuthFailure(const U: string): Boolean;
// Типовые формулировки отказа. Список НАМЕРЕННО короткий и из точных фраз:
// проверяется он только в окне «учётка отправлена, логин ещё не завершён», но
// ложное срабатывание всё равно стоило бы рабочего соединения.
const
  MARKS: array[0..6] of string = (
    'invalid password', 'incorrect password', 'password incorrect',
    'wrong password', 'login incorrect', 'access denied',
    'authentication failed');
var i: Integer;
begin
  Result := True;
  for i := Low(MARKS) to High(MARKS) do
    if Pos(MARKS[i], U) > 0 then Exit;
  Result := False;
end;

procedure TDXClusterThread.FailAuth(const Reason: string);
// Отказ авторизации — не разрыв: реконнект с той же учёткой упрётся в то же
// самое. Гасим сессию совсем и говорим прямо; пользователь правит настройки, а
// это само поднимет клиент заново (ApplyDXConnection видит смену логина).
begin
  if FFatal then Exit;
  FFatal := True;
  FOwner.AddLog('*** ' + Reason + ' — giving up, check callsign/password');
  FOwner.SetState(dxsError, Reason);
end;

procedure TDXClusterThread.CheckPrompts(const Tail: string; IsTail: Boolean);
// Приглашения логина И пароля обычно приходят БЕЗ перевода строки, поэтому
// ищем их и в незавершённом хвосте буфера, а не только в целых строках.
// Флаги FLoginSent/FPassSent держат отправку однократной: хвост проверяется
// заново с каждым пришедшим куском.
// ★IsTail говорит лишь о том, ОТКУДА пришёл текст: незавершённый хвост или
// целая строка. Отвеченный хвост остаётся в буфере и со следующим куском доедет
// целой строкой — чтобы не счесть это эхо повторным вопросом (т.е. отказом),
// ответив по хвосту, мы запоминаем его в FAnsweredPrompt, и HandleLine гасит
// ровно ОДНУ следующую строку, если она совпала. Запрещать проверки для целых
// строк нельзя: приглашения вида 'Password:\r\n' — совершенно обычное дело.
var U: string;
begin
  if (Tail = '') or FFatal then Exit;
  U := LowerCase(Tail);

  if not FLoginSent then
  begin
    if (Pos('login:', U) > 0) or (Pos('call:', U) > 0) or
       (Pos('callsign', U) > 0) or (Pos('your call', U) > 0) or
       (Pos('enter your', U) > 0) then
    begin
      if not SendLine(FLogin) then Exit;
      FOwner.AddLog('> ' + FLogin);
      if IsTail then FAnsweredPrompt := TrimRight(U);
      FLoginSent  := True;
      FLoginByPrompt := True;    // ответили на вопрос, а не выстрелили вслепую
      FLoginAt    := Now;
      FLoginStepAt := FLoginAt;
      FSawAfterLogin := False;   // ждём ответ именно на позывной
      FOwner.SetState(dxsLogin, '');
    end;
    Exit;
  end;

  // Кластер снова просит позывной. Если наш ушёл ПО ПРИГЛАШЕНИЮ — его не
  // приняли, второй раз слать тот же нечего. Если же мы стреляли вслепую
  // (приглашения не дождались за DX_LOGIN_GRACE_S, а кластер спросил только
  // сейчас), то это не отказ, а опоздавший вопрос — отвечаем ещё раз, ровно
  // один: после этого FLoginByPrompt уже True.
  if IsLoginPrompt(U) then
  begin
    if FLoginByPrompt then
    begin
      FailAuth('callsign rejected');
      Exit;
    end;
    if not SendLine(FLogin) then Exit;
    FOwner.AddLog('> ' + FLogin + '   (prompt arrived late)');
    if IsTail then FAnsweredPrompt := TrimRight(U);
    FLoginByPrompt := True;
    // ★FLoginAt тоже заново: от него считается ожидание приглашения ПАРОЛЯ, а
    // ждать его надо от позывного, на который кластер реально отвечает. Оставив
    // старую отметку (слепой выстрел), при поздно пришедшем приглашении мы бы
    // пароля уже не ждали вовсе — и по первой же приветственной строке
    // высыпали бы post-login прямо перед вопросом о пароле.
    FLoginAt       := Now;
    FLoginStepAt   := FLoginAt;
    FSawAfterLogin := False;
    Exit;
  end;

  // Приглашение пароля. И отправка, и вывод «не приняли» идут по ОДНОМУ И ТОМУ
  // ЖЕ строгому детектору: по вхождению слова (как было) пароль улетел бы
  // командой в кластер от одной лишь строки приветствия вида «you can set a
  // password with set/password».
  if PromptEndsWith(U, 'password') then
  begin
    // Спрашивают снова после того, как мы уже ответили, — не приняли.
    if FPassSent then
    begin
      FailAuth('password rejected');
      Exit;
    end;
    if FPassword = '' then
    begin
      FailAuth('cluster asks for a password, none configured');
      Exit;
    end;
    if not SendLine(FPassword) then Exit;
    FOwner.AddLog('> ********');
    if IsTail then FAnsweredPrompt := TrimRight(U);
    FPassSent    := True;
    FLoginStepAt := Now;
    // Ответ на пароль ещё впереди: вопрос о пароле ответом не считается.
    FSawAfterLogin := False;
  end;
end;

function TDXClusterThread.LoginSettled: Boolean;
// Логин завершён, когда: позывной ушёл; пароль либо не нужен, либо отправлен,
// либо приглашения так и не было; сервер ОТВЕТИЛ хоть чем-то после нашей
// учётки (или молчит уже неприлично долго); и прошла пауза, за которую кластер
// успевает дожевать регистрацию. Раньше здесь стояли просто «две секунды после
// позывного» — при этом post-login команды и команды пользователя улетали в
// ещё не пройденный логин, ломая авторизацию (sh/dx вместо пароля).
var SinceLogin, SinceStep: Integer;
begin
  Result := False;
  if not FLoginSent then Exit;
  SinceLogin := SecondsBetween(Now, FLoginAt);
  SinceStep  := SecondsBetween(Now, FLoginStepAt);
  // Приглашение пароля ждём от отправки позывного…
  if (FPassword <> '') and (not FPassSent) and (SinceLogin < DX_PASSWORD_WAIT_S) then Exit;
  // …а молчание сервера считаем от ПОСЛЕДНЕЙ отправленной учётки: после пароля
  // ждать ответ надо заново, иначе «принят ли пароль» никто не проверит.
  if (not FSawAfterLogin) and (SinceStep < DX_LOGIN_MAX_S) then Exit;
  Result := SinceStep >= DX_POSTLOGIN_S;
end;

function TDXClusterThread.SendPostLogin: Boolean;
// False — сокет умер на отправке. Это разрыв ВСЕЙ сессии: продолжать на мёртвом
// соединении значило бы объявить ONLINE и вычерпать очередь команд в никуда.
var
  PL: TStringList;
  i: Integer;
begin
  Result := True;
  if Trim(FPostLogin) = '' then Exit;
  PL := TStringList.Create;
  try
    PL.Text := FPostLogin;
    for i := 0 to PL.Count - 1 do
      if Trim(PL[i]) <> '' then
      begin
        if not SendLine(Trim(PL[i])) then Exit(False);
        FOwner.AddLog('> ' + Trim(PL[i]));
      end;
  finally
    PL.Free;
  end;
end;

procedure TDXClusterThread.HandleLine(const Line: string);
var
  Spot: TDXSpot;
  Echo: Boolean;
begin
  if Line = '' then Exit;
  FOwner.AddLog(Line);

  // Эхо приглашения, на которое мы уже ответили по хвосту (см. CheckPrompts).
  // Окно ровно на одну строку: следующая строка — уже настоящая новость, и
  // такое же приглашение в ней будет означать, что нас переспрашивают.
  if FAnsweredPrompt <> '' then
  begin
    Echo := TrimRight(LowerCase(Line)) = FAnsweredPrompt;
    FAnsweredPrompt := '';
    if Echo then Exit;      // ответом сервера это НЕ считается
  end;

  // Отказ ищем до всего прочего и, главное, до отметки «сервер ответил»: иначе
  // «Invalid password» само же и подтверждало бы успешный логин. Окно узкое —
  // учётка ушла, логин не завершён; дальше в строках обычный трафик кластера,
  // где такие слова бывают в чьём угодно комментарии к споту.
  if FLoginSent and (not FPostSent) and LooksLikeAuthFailure(LowerCase(Line)) then
  begin
    FailAuth('cluster rejected login: ' + Trim(Line));
    Exit;
  end;

  // Вот теперь это действительно ответ сервера по существу: не эхо нашего же
  // приглашения и не отказ. Только такое и подтверждает, что логин продвинулся.
  if FLoginSent then FSawAfterLogin := True;

  if ParseDXSpot(Line, Spot) then
  begin
    FOwner.FStore.Add(Spot);
    FOwner.FLock.Enter;
    try
      Inc(FOwner.FSpotCount);
    finally
      FOwner.FLock.Leave;
    end;
    if FOwner.State <> dxsOnline then FOwner.SetState(dxsOnline, '');
    Exit;
  end;

  CheckPrompts(Line, False);
end;

procedure TDXClusterThread.HandleChunk(const Data: string);
var
  P: Integer;
  Line: string;
begin
  FRxBuf := FRxBuf + Data;
  while True do
  begin
    P := 1;
    while (P <= Length(FRxBuf)) and not (FRxBuf[P] in [#10, #13]) do Inc(P);
    if P > Length(FRxBuf) then Break;          // перевода строки ещё нет
    Line := Copy(FRxBuf, 1, P - 1);
    // съедаем весь конец строки (CR, LF или CRLF)
    while (P <= Length(FRxBuf)) and (FRxBuf[P] in [#10, #13]) do Inc(P);
    Delete(FRxBuf, 1, P - 1);
    HandleLine(TrimRight(Line));
  end;

  // Хвост без перевода строки — возможно, это приглашение логина или пароля…
  if FRxBuf <> '' then
  begin
    // …а возможно, и сообщение об отказе: его тоже присылают без перевода
    // строки, и без этой проверки оно ушло бы в «сервер ответил», т.е. в
    // подтверждение логина.
    if FLoginSent and (not FPostSent) and LooksLikeAuthFailure(LowerCase(FRxBuf)) then
      FailAuth('cluster rejected login: ' + Trim(FRxBuf))
    else
    begin
      CheckPrompts(FRxBuf, True);
      // Хвост подтверждает логин, только если пережил разбор: не отказ, не
      // приглашение, которое мы только что отвечали (там флаг сбрасывается), и
      // не остаток уже отвеченного приглашения. Считать его ответом нужно —
      // многие кластеры шлют приглашение сессии БЕЗ перевода строки, и без
      // этого post-login ждал бы потолка молчания на ровном месте.
      if FLoginSent and (not FFatal) and (FAnsweredPrompt = '') then
        FSawAfterLogin := True;
    end;
  end;
  // Защита от мусора без переводов строк.
  if Length(FRxBuf) > 8192 then FRxBuf := '';
end;

function TDXClusterThread.Session: Boolean;
var
  Buf: array[0..4095] of Byte;
  N: Integer;
  Chunk: string;
begin
  Result := True;
  FRxBuf := '';
  FLoginSent := False;
  FLoginByPrompt := False;
  FPassSent  := False;
  FPostSent  := False;
  FSawAfterLogin := False;
  FFatal     := False;
  FAnsweredPrompt := '';
  FLoginAt     := Now;
  FLoginStepAt := Now;
  FConnectAt := Now;

  FOwner.SetState(dxsConnecting, FHost + ':' + IntToStr(FPort));
  FOwner.AddLog('*** connecting to ' + FHost + ':' + IntToStr(FPort));
  if not ConnectSock then Exit;

  FOwner.AddLog('*** connected');
  FOwner.SetState(dxsLogin, '');

  while not Terminated do
  begin
    N := SockRecv(FSocket, @Buf[0], SizeOf(Buf), 0);
    if N > 0 then
    begin
      SetLength(Chunk, N);
      Move(Buf[0], Chunk[1], N);
      // ★FSawAfterLogin здесь НЕ ставится. Приход байтов — факт транспортный, а
      // не протокольный: отдельным пакетом может доехать один лишь '\r\n',
      // закрывающий приглашение, на которое мы уже ответили, и «подтверждение
      // логина» зависело бы от того, как TCP порезал поток. Флаг ставит
      // HandleChunk/HandleLine — после разбора, по существу пришедшего.
      HandleChunk(Chunk);
      if FFatal then Break;      // логин отвергнут — реконнект не поможет
    end
    else if N = 0 then
    begin
      FOwner.AddLog('*** connection closed by peer');
      Break;
    end
    else if not SockWouldBlock then
    begin
      // Не таймаут кванта recv, а настоящая ошибка сокета — уходим на реконнект.
      FOwner.AddLog('*** connection lost (socket error)');
      Break;
    end;

    if Terminated then Break;

    // Приглашения не дождались — шлём позывной сами (часть кластеров молчит).
    if (not FLoginSent) and (SecondsBetween(Now, FConnectAt) >= DX_LOGIN_GRACE_S) then
    begin
      if not SendLine(FLogin) then Break;   // сокет умер — на реконнект
      FOwner.AddLog('> ' + FLogin + '   (no prompt seen)');
      FLoginSent   := True;
      FLoginByPrompt := False;   // вслепую: опоздавшее приглашение = не отказ
      FLoginAt     := Now;
      FLoginStepAt := FLoginAt;
      FSawAfterLogin := False;   // ждём ответ именно на позывной
    end;

    // Post-login команды — один раз, когда логин действительно пройден.
    if (not FPostSent) and LoginSettled then
    begin
      FPostSent := True;
      if not SendPostLogin then Break;   // сокет умер — вся сессия на реконнект
      if FOwner.State <> dxsOnline then FOwner.SetState(dxsOnline, '');
    end;

    // Команды пользователя — только после логина: до него кластер ждёт позывной
    // и пароль, и любая наша строка ушла бы вместо них. Очередь никуда не
    // девается, отправим следом.
    if FPostSent then FlushOutQueue;
  end;

  CloseSock;
  Result := (not Terminated) and (not FFatal);
end;

procedure TDXClusterThread.Execute;
var
  Backoff: Integer;
begin
  Backoff := DX_RECONNECT_MIN;
  // Снимок конфига на всю жизнь потока: Configure во время работы применяется
  // следующим Start (так же ведёт себя веб-сервер при смене порта).
  FOwner.FLock.Enter;
  try
    FHost      := FOwner.FHost;
    FPort      := FOwner.FPort;
    FLogin     := FOwner.FLogin;
    FPassword  := FOwner.FPassword;
    FPostLogin := FOwner.FPostLogin;
  finally
    FOwner.FLock.Leave;
  end;

  while not Terminated do
  begin
    if not Session then Break;
    if Terminated then Break;

    FOwner.SetState(dxsRetry, 'reconnecting in ' + IntToStr(Backoff) + ' s');
    FOwner.AddLog('*** reconnecting in ' + IntToStr(Backoff) + ' s');
    if FOwner.FStopEvent.WaitFor(Backoff * 1000) = wrSignaled then Break;
    Backoff := Backoff * 2;
    if Backoff > DX_RECONNECT_MAX then Backoff := DX_RECONNECT_MAX;
  end;
  CloseSock;
end;

initialization
  GResolveLock := TCriticalSection.Create;
  FillChar(GResolvePending, SizeOf(GResolvePending), 0);
{$IFDEF MSWINDOWS}
  // ★Winsock поднимается ОДИН раз на процесс и НЕ выгружается. Раньше это
  // делали конструктор и деструктор клиента — но Stop не ждёт (и не может
  // ждать) резолвер-поток, а тот может в этот момент сидеть внутри
  // gethostbyname: WSACleanup из-под него = обращение к выгруженному Winsock,
  // то есть падение на выходе или при пересоздании клиента. Winsock освободит
  // сам процесс.
  WSAStartup($0202, GWSAData);
{$ENDIF}

// ★finalization НЕТ намеренно, по той же причине: брошенный резолвер может
// стоять в системном резолвере и после выхода из программы, а прервать его
// нечем. Освободив GResolveLock, мы дали бы ему упасть на Enter уже
// освобождённой секции; один живущий до конца процесса объект — не утечка.

end.
