#!/usr/bin/perl

package FCC::listservice;

################################################
#                                              #
#     FCC ListService module & functions       #
#                                              #
#      (C) 2025 Domero                         #
#                                              #
################################################

use strict;
no strict 'refs';
use warnings;
use Exporter;
use vars qw($VERSION @ISA @EXPORT @EXPORT_OK);

$VERSION     = '1.0.0';
@ISA         = qw(Exporter);
@EXPORT      = qw(startlistservice);
@EXPORT_OK   = qw();

use gfio 1.10;
use Crypt::Ed25519;
use Time::HiRes qw(gettimeofday usleep);
use gerr;
use gserv 3.1.2 qw(localip wsmessage burst);
use gclient 7.4.1;
use FCC::global 2.3.2;
use FCC::miner 1.1.3;
use FCC::wallet 2.1.4;
use JSON;

################################################################################

my $DEBUG = 0;

my $SERVER;
my $COINBASE_CLIENT;
my $COINBASE_IP = $FCCSERVERIP; # From FCC::global
my $COINBASE_PORT = $FCCSERVERPORT; # From FCC::global
my $LISTSERVICE_PORT = 5151; # Public port for list service
my $LISTSERVICE_LIST = {}; # Other list services
my $NODELIST = {}; # Connected nodes, explorers, wallets, miners
my $FLOODLIST = {};
my $FLOODMAX = 10;
my $FLOODTIMEOUT = {};
my $FLOODTIME = 0;
my $CALLBACKLIST = [];
my $CALLBACKCLIENTS = [];
my $CALLBACKPOS = 0;
my $COIN = $COIN; # From FCC::global (FCC or PTTP)

$SIG{'INT'} = \&intquit;
$SIG{'TERM'} = \&termquit;
$SIG{'PIPE'} = \&sockquit;
$SIG{__DIE__} = \&fatal;
$SIG{__WARN__} = \&fatal;

$::EVALMODE = 0;

1;

############### Error Handling #################

sub killserver {
  my ($msg) = @_;
  if (!$SERVER) { return }
  $SERVER->{killing} = 1;
  if (!$msg) { $msg = "FCC ListService terminated" }
  print " !! Killing server .. $msg\n";
  $SERVER->quit();
}

sub killnode {
  my ($client, $msg) = @_;
  if ($client->{killafteroutput}) {
    $client->{killme} = 1;
  } else {
    wsmessage($client, $msg, 'close');
    $client->{killafteroutput} = 1;
  }
}

sub fatal {
  if ($::EVALMODE) { return }
  print "!!!! FATAL ERROR !!!!\n", @_ ,"\n";
  killserver("Fatal Error"); error(@_);
}

sub intquit {
  print "** INTERRUPT RECEIVED **\n".gerr::trace()."\n";
  killserver('130 Interrupt signal received');
}

sub termquit {
  killserver('108 Client forcibly killed connection');
}

sub sockquit {
  my $client = $SERVER->{activeclient};
  if ($client) {
    killnode($client, "32 TCP/IP Connection error");
  } else {
    print " *!* WARNING *!* Unexpected SIGPIPE in server-kernel. @_\n";
  }
}

################################################################################
#
# LISTSERVICE
#
################################################################################

sub startlistservice {
  my ($coin, $password) = @_;
  if ($coin eq 'PTTP') { setcoin('PTTP') }
  print <<EOT;

  L     IIIII SSSS TTTTT SSSS  EEEEE RRRR  V   V IIIIII  CCC  EEEEE
  L       I   S      T   S     E     R   R V   V   I    C     E
  L       I   SSSS   T   SSSS  EEE   RRRR  V   V   I    C     EEE
  L       I      S   T      S  E     R  R   V V    I    C     E
  LLLLL IIIII SSSS   T   SSSS  EEEEE R   R   V   IIIIII  CCC  EEEEE

  ListService $VERSION (C) 2025 Domero

EOT
  my $pass = $password;
  if (!$pass) {
    print "Please enter password for $COIN ListService: ";
    $pass = <STDIN>;
    if (!$pass) { exit }
    chomp $pass;
  }
  # Connect to Coinbase server
  $COINBASE_CLIENT = gclient::websocket($COINBASE_IP, $COINBASE_PORT, 0, \&handle_coinbase, 0, 5);
  if ($COINBASE_CLIENT->{error}) {
    print "Failed to connect to Coinbase server: $COINBASE_CLIENT->{error}\n";
    exit;
  }
  $COINBASE_CLIENT->{fcc} = { role => 'listservice', time => gettimeofday() };
  # Initialize server
  my $ssl = ''; if (localip() eq $FCCSERVERIP) { $ssl = 'factorialcoin.nl' }
  $SERVER = gserv::init(\&handle, \&clientloop, $ssl, \&servhandler);
  $SERVER->{name} = "$COIN ListService $VERSION (C) 2025 Domero";
  print "Starting $SERVER->{name} on port $LISTSERVICE_PORT\n";
  $SERVER->{server}{port} = $LISTSERVICE_PORT;
  $SERVER->{allowedip} = [ '*' ];
  $SERVER->{timeout} = 5;
  $SERVER->{verbose} = $DEBUG;
  $SERVER->start(1, \&loop);
  if ($SERVER->{error}) {
    print "Server error: $SERVER->{error}\n";
  }
  print "ListService terminated.\n\n";
}

################################################################################
#
# HANDLE COINBASE COMMUNICATION
#
################################################################################

sub handle_coinbase {
  my ($client, $command, $data) = @_;
  if ($command eq 'connect') {
    print " -> Connected to Coinbase server $client->{host}:$client->{fcc}{port}\n";
    outjson($client, { command => 'hello', role => 'listservice', ip => localip(), port => $LISTSERVICE_PORT });
  } elsif ($command eq 'input') {
    my $k; eval { $k = decode_json($data) };
    if ($@) {
      print prtm(), "Illegal data (no JSON) received from Coinbase: `$data`\n";
      $client->quit();
      return;
    }
    my $cmd = $k->{command};
    if (!$cmd) {
      print prtm(), "No command received from Coinbase\n";
      $client->quit();
      return;
    }
    if ($cmd eq 'listservices') {
      update_listservices($k->{listservices});
    } else {
      # Relay Coinbase messages to nodes
      bjson($k);
    }
  } elsif ($command eq 'quit' || $command eq 'error') {
    print "Coinbase connection closed: $data\n";
    $client->quit();
  }
}

sub update_listservices {
  my ($listservices) = @_;
  foreach my $ls (@$listservices) {
    my $key = "$ls->{ip}:$ls->{port}";
    if (!$LISTSERVICE_LIST->{$key}) {
      $LISTSERVICE_LIST->{$key} = { ip => $ls->{ip}, port => $ls->{port}, client => undef };
      connect_to_listservice($ls->{ip}, $ls->{port});
    }
  }
}

sub connect_to_listservice {
  my ($ip, $port) = @_;
  my $key = "$ip:$port";
  if ($ip eq localip() && $port eq $LISTSERVICE_PORT) { return } # Avoid self-connection
  my $client = gclient::websocket($ip, $port, 0, \&handle_listservice, 0, 5);
  if ($client->{error}) {
    print "Failed to connect to ListService $key: $client->{error}\n";
    return;
  }
  $client->{fcc} = { role => 'listservice', time => gettimeofday(), port => $port };
  $LISTSERVICE_LIST->{$key}{client} = $client;
  outjson($client, { command => 'hello', role => 'listservice', ip => localip(), port => $LISTSERVICE_PORT });
}

sub handle_listservice {
  my ($client, $command, $data) = @_;
  if ($command eq 'connect') {
    print " -> Connected to ListService $client->{host}:$client->{fcc}{port}\n";
  } elsif ($command eq 'input') {
    my $k; eval { $k = decode_json($data) };
    if ($@) {
      print prtm(), "Illegal data from ListService $client->{host}:$client->{fcc}{port}: `$data`\n";
      $client->quit();
      return;
    }
    my $cmd = $k->{command};
    if ($cmd eq 'hello' && $k->{role} eq 'listservice') {
      my $key = "$k->{ip}:$k->{port}";
      if (!$LISTSERVICE_LIST->{$key}) {
        $LISTSERVICE_LIST->{$key} = { ip => $k->{ip}, port => $k->{port}, client => $client };
      }
    } else {
      # Relay to Coinbase or other nodes
      outjson($COINBASE_CLIENT, $k);
      bjson($k, $client->{host}.":".$client->{fcc}{port});
    }
  } elsif ($command eq 'quit' || $command eq 'error') {
    my $key = "$client->{host}:$client->{fcc}{port}";
    delete $LISTSERVICE_LIST->{$key};
    print "ListService $key disconnected: $data\n";
  }
}

################################################################################
#
# CLIENT LOOP
#
################################################################################

sub clientloop {
  my ($client) = @_;
  if ($FLOODTIMEOUT->{$client->{ip}}) {
    $client->{killme} = 1;
  }
}

sub servhandler {
  my ($client, $cmd, @data) = @_; $cmd //= ''; @data = (@data);
  if (ref($cmd) =~ /^gserv\:\:client/) { my $c = $client; $client = $cmd; $cmd = $c }
  return if ($cmd eq 'connect');
  if (ref($client) =~ /^gserv\:\:client/) {
    print prtm(), 
      "ServHandler ($client->{ip}):[", 
      ($client->{httpheader}{uri} ? ":$client->{httpheader}{method} $client->{httpheader}{uri}".($client->{httpheader}{getdata} ? "?$client->{httpheader}{getdata}":'') : ''), 
      "]: ",
      "\n";
  }
}

sub handle {
  my ($client, $command, $data) = @_;
  if ($SERVER->{killing}) { return }
  if (!$data) { $data = "" } my @out;
  if ($DEBUG == 2) {
    if (($command ne 'sent') && ($command ne 'received')) {
      print " -> $command * $data\n";
    }
  }
  if ($command eq 'connect') {
    if ($FLOODLIST->{$client->{ip}}) {
      $FLOODLIST->{$client->{ip}}++;
      if ($FLOODLIST->{$client->{ip}} >= $FLOODMAX) {
        print prtm(), "Flood detected from IP $client->{ip}\n";
        gserv::out($client, "Flood detected");
        $client->{killafteroutput} = 1;
        $FLOODTIMEOUT->{$client->{ip}} = time + 3600;
        return;
      }
    } else {
      $FLOODLIST->{$client->{ip}} = 1;
    }
  }
  if ($command eq 'telnet') {
    $client->{killme} = 1;
    return;
  }
  if ($command eq 'ready') {
    if ($data ne 'get') {
      @out = (gserv::httpresponse(405));
    }
    $client->{httpdata} = ''; my $mime = "text/plain";
    if ($client->{post}->exists('nodelist') || $client->{post}->exists('challenge') || 
        $client->{post}->exists('ping') || $client->{post}->exists('time') || 
        $client->{post}->exists('fcctime') || $client->{post}->exists('wallet') || 
        $client->{post}->exists('update')) {
      # Relay all accepted Coinbase commands to Coinbase server
      outjson($COINBASE_CLIENT, { command => $client->{post}{key}[0], data => $client->{post}->get($client->{post}{key}[0]) });
      $client->{pending} = { type => 'http', command => $client->{post}{key}[0] };
    } else {
      @out = (gserv::httpresponse(200));
      $client->{httpdata} = "$COIN ListService $VERSION\r\nRelay for Coinbase Server\r\nGET Options = nodelist, ping, time, wallet, challenge, update\r\n\r\n(C) 2025 Domero, Groningen, NL";
    }
    push @out, "Host: ".$SERVER->{server}{host}.":".$SERVER->{server}{port};
    push @out, "Access-Control-Allow-Origin: *";
    push @out, "Content-Type: $mime";
    push @out, "Content-Length: ".length($client->{httpdata});
    push @out, "Server: $COIN-ListService 1.0";
    push @out, "Date: ".fcctimestring();
    if ($FLOODLIST->{$client->{ip}}) { $FLOODLIST->{$client->{ip}}-- }
    my $data = join("\r\n", @out)."\r\n\r\n".$client->{httpdata};
    gserv::burst($client, \$data);
  } elsif ($command eq 'input') {
    my $k; eval { $k = decode_json($data) };
    if ($@) {
      print prtm(), "Illegal data (no JSON) received from $client->{ip}:$client->{port}: `$data`\n";
      $client->{killme} = 1;
      return;
    }
    my $cmd = $k->{command};
    if (!$cmd) {
      print prtm(), "No command received from $client->{ip}:$client->{port}\n";
      outjson($client, { command => 'error', error => "No command given in input" });
      $client->{killafteroutput} = 1;
      return;
    }
    # Relay commands to Coinbase
    if ($cmd eq 'init' || $cmd eq 'challenge' || $cmd eq 'solution' || $cmd eq 'fcctime' || 
        $cmd eq 'nodelist' || $cmd eq 'updatelist' || $cmd eq 'updatefile' || $cmd eq 'callmeback') {
      $client->{fccinit} = $client->{ip}.":".$k->{port} if $cmd eq 'init';
      outjson($COINBASE_CLIENT, $k);
      $client->{pending} = { type => 'json', command => $cmd };
    } else {
      print prtm(), "Illegal command received from $client->{ip}:$client->{port}: $cmd\n";
      outjson($client, { command => 'error', error => "Unknown command given in input" });
      $client->{killafteroutput} = 1;
    }
  } elsif ($command eq 'quit' || $command eq 'error') {
    if ($FLOODLIST->{$client->{ip}}) {
      $FLOODLIST->{$client->{ip}}--;
      if (!$FLOODLIST->{$client->{ip}}) { delete $FLOODLIST->{$client->{ip}} }
    }
    if ($client->{fccinit}) {
      delete $NODELIST->{$client->{fccinit}};
      print " $command Node exited $client->{fccinit} ($data)\n";
    }
  }
}

sub bjson {
  my ($data, $exclude_key) = @_;
  foreach my $node (keys %$NODELIST) {
    if (!$exclude_key || ($node ne $exclude_key)) {
      outjson($NODELIST->{$node}{client}, $data);
    }
  }
  foreach my $ls (keys %$LISTSERVICE_LIST) {
    if ($LISTSERVICE_LIST->{$ls}{client} && (!$exclude_key || ($ls ne $exclude_key))) {
      outjson($LISTSERVICE_LIST->{$ls}{client}, $data);
    }
  }
}

sub outjson {
  my ($client, $msg) = @_;
  if (!$msg) {
    error "FCC::listservice::outjson: Empty message";
  }
  if (!ref($msg)) { 
    error "FCC::listservice::outjson: Message has to be array or hash reference to be converted to JSON";
  }
  if ($client->{fcc} && $client->{fcc}{callback}) {
    gclient::wsout($client, encode_json($msg));
  } else {
    wsmessage($client, encode_json($msg));
  }
}

sub callback {
  my $ctm = gettimeofday();
  if ($#{$CALLBACKCLIENTS} >= 0) {
    if ($CALLBACKPOS > $#{$CALLBACKCLIENTS}) { $CALLBACKPOS = 0 }
    my $client = $CALLBACKCLIENTS->[$CALLBACKPOS];
    if ($ctm - $client->{fcc}{time} > 5) {
      $client->quit("callback timeout");
    }
    $client->takeloop();
    $CALLBACKPOS++;
  }
  if ($#{$CALLBACKLIST} < 0) { return }
  my $new = shift @$CALLBACKLIST;
  my $client = gclient::websocket($new->{ip}, $new->{port}, 0, \&handlecallback, 0, 5);
  $client->{fcc} = { callback => 1, time => $ctm, port => $new->{port} };
  if ($client->{error}) {
    print " xx Illegal callback from $new->{ip}:$new->{port}: $client->{error}\n";
  } else {
    push @$CALLBACKCLIENTS, $client;
  }
}

sub handlecallback {
  my ($client, $command, $data) = @_;
  if ($command eq 'connect') {
    print " -> Calling back $client->{host}:$client->{fcc}{port}\n";
    outjson($client, { command => 'callback', role => 'listservice' });
  } elsif ($command eq 'quit' || $command eq 'error') {
    splice(@$CALLBACKCLIENTS, $CALLBACKPOS, 1);
    $client->quit();
  }
}

sub loop {
  usleep(10000);
  callback();
  my $tm = time + $FCCTIME;
  if (int($tm / 60) != $FLOODTIME) {
    $FLOODTIME = int($tm / 60);
    my @fl = sort { $FLOODTIMEOUT->{$a} <=> $FLOODTIMEOUT->{$b} } (keys %$FLOODTIMEOUT);
    my $p = 0;
    while (($p <= $#fl) && ($FLOODTIMEOUT->{$fl[$p]} <= time)) {
      delete $FLOODTIMEOUT->{$fl[$p]}; $p++;
    }
  }
}

# EOF FCC::listservice (C) 2025 Domero
