/*
  Copyright 2024 Northern.tech AS

  This file is part of CFEngine 3 - written and maintained by Northern.tech AS.

  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; version 3.

  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

  To the extent this program is licensed as part of the Enterprise
  versions of CFEngine, the applicable Commercial Open Source License
  (COSL) may apply to this file if you as a licensee so wish it. See
  included file COSL.txt.
*/


#define CF_NET_VERSION "0.1.2"

#include <platform.h>
#include <getopt.h>
#include <libgen.h>
#include <client_code.h>        // ServerConnection
#include <logging.h>            // Log, LogSetGlobalLevel
#include <man.h>                // ManPageWrite
#include <crypto.h>             // CryptoInitialize
#include <addr_lib.h>           // ParseHostPort
#include <net.h>                // SocketConnect() SendTransaction()
#include <time.h>               // time_t, time, difftime
#include <string_lib.h>         // ToLowerStrInplace
#include <writer.h>
#include <policy_server.h>      // PolicyServerReadFile
#include <generic_agent.h>      // GenericAgentSetDefaultDigest TODO: rm dep
#include <cf-windows-functions.h> // TODO: move this out of libpromises
#include <known_dirs.h>           // TODO: move this 'out of libpromises
#include <cleanup.h>
#include <protocol.h>
#include <sequence.h>
#include <files_lib.h>

#define ARG_UNUSED __attribute__((unused))

typedef struct
{
    bool debug;
    bool verbose;
    bool inform;
    bool used_default;
    char *min_tls_version;
    char *allow_ciphers;
    char *use_protocol_version;
    bool print_stats;
} CFNetOptions;

//*******************************************************************
// DOCUMENTATION / GETOPT CONSTS:
//*******************************************************************

static const char *const CF_NET_SHORT_DESCRIPTION =
    "Command-Line Interface (CLI) for libcfnet.";

static const char *const CF_NET_MANPAGE_LONG_DESCRIPTION =
    "cf-net is a testing/debugging tool intended for developers as well "
    "as CFEngine users. cf-net connects to cf-serverd on a specified host "
    "and can issue arbitrary CFEngine protocol commands. Currently 4 "
    "commands are supported; connect, stat, opendir and get. This tool can "
    "be useful to check if a host is online, see if you can fetch policy or "
    "even stress test the host by running many instances of cf-net. "
    "cf-net is much more lightweight and easy to use than cf-agent, as it "
    "does not include any policy functionality. Please note that in order to "
    "connect to a host cf-net needs access to the key-pair generated by "
    "cf-key. For now, it is much easier to use cf-net on a bootstrapped "
    "host. This is due to ACLs and key-pairs and can be made easier in the "
    "future.";

static const Component COMPONENT =
{
    .name = "cf-net",
    .website = CF_WEBSITE,
    .copyright = CF_COPYRIGHT
};

static const Description COMMANDS[] =
{
    {"help",    "Prints general help or per topic",
                "cf-net help [command]"},
    {"connect", "Checks if host(s) is available by connecting",
                "cf-net -H 192.168.50.50,192.168.50.51 connect"},
    {"stat",    "Look at type of file",
                "cf-net stat masterfiles/update.cf"},
    {"get",     "Get file from server",
                "cf-net get masterfiles/update.cf -o download.cf [-jNTHREADS]\n"
                "\t\t\t(%d can be used in both the remote and output file paths when '-j' is used)"},
    {"opendir", "List files and folders in a directory",
                "cf-net opendir masterfiles"},
    {"getdir", "Recursively downloads files and folders in a directory",
                "cf-net getdir masterfiles/ -o /tmp/"},
    {NULL, NULL, NULL}
};

static const struct option OPTIONS[] =
{
    {"help",        no_argument,        0, 'h'},
    {"manpage",     no_argument,        0, 'M'},
    {"host",        required_argument,  0, 'H'},
    {"debug",       no_argument,        0, 'd'},
    {"verbose",     no_argument,        0, 'v'},
    {"log-level",   required_argument,  0, 'g'},
    {"inform",      no_argument,        0, 'I'},
    {"tls-version", required_argument,  0, 't'},
    {"ciphers",     required_argument,  0, 'c'},
    {"protocol",    required_argument,  0, 'p'},
    {"stats",       no_argument,        0, 's'},
    {NULL,          0,                  0, '\0'}
};

static const char *const HINTS[] =
{
    "Print the help message",
    "Print the man page",
    "Server hostnames or IPs, comma-separated (defaults to policy server)",
    "Enable debugging output",
    "Enable verbose output",
    "Specify how detailed logs should be. Possible values: 'error', 'warning', 'notice', 'info', 'verbose', 'debug'",
    "Enable basic information output",
    "Minimum TLS version to use",
    "TLS ciphers to use (comma-separated list)",
    "Specify CFEngine protocol to use. Possible values: 'classic', 'tls', 'cookie', 'filestream', 'leech2', 'latest' (default)",
    "Print rsync performance statistics to stderr",
    NULL
};

//*******************************************************************
// COMMAND ENUMS:
//*******************************************************************

#define CF_NET_COMMANDS(generator_macro) \
        generator_macro(CONNECT)         \
        generator_macro(STAT)            \
        generator_macro(GET)             \
        generator_macro(OPENDIR)         \
        generator_macro(GETDIR)          \
        generator_macro(MULTI)           \
        generator_macro(MULTITLS)        \
        generator_macro(HELP)            \
        generator_macro(INVALID)         \

#define GENERATE_ENUM(CMD_NAME) CFNET_CMD_##CMD_NAME,
#define GENERATE_STRING(CMD_NAME) #CMD_NAME,

enum command_enum
{
    CF_NET_COMMANDS(GENERATE_ENUM) command_enum_max
};

static const char *command_strings[] =
{
    CF_NET_COMMANDS(GENERATE_STRING) NULL
};


//*******************************************************************
// MACRO DECLARATIONS:
//*******************************************************************
#define RECURSIONLIMIT 50 // GetDir


//*******************************************************************
// FUNCTION DECLARATIONS:
//*******************************************************************


// INIT:
static void CFNetSetDefault(CFNetOptions *opts);
static void CFNetInit(const char *min_tls_version, const char *allow_ciphers);
static void CFNetOptionsClear(CFNetOptions *opts);

// MAIN LOGIC:
static int CFNetParse(int argc, char **argv,                        // INPUTS
               CFNetOptions *opts, char ***args, char **hostnames); // OUTPUTS
static int CFNetCommandSwitch(CFNetOptions *opts, const char *hostname,
                              char **args, enum command_enum cmd);
static int CFNetRun(CFNetOptions *opts, char **args, char *hostnames);
static char *RequireHostname(char *hostnames);

// PROTOCOL:
static AgentConnection *CFNetOpenConnection(const char *server, const char *use_protocol_version);
static void CFNetDisconnect(AgentConnection *conn);
static int JustConnect(const char *server, char *port);

// COMMANDS:
static int CFNetHelpTopic(const char *topic);
static int CFNetHelp(const char *topic);
static int CFNetConnectSingle(const char *server, const char *use_protocol_version, bool print);
static int CFNetConnect(const char *hostname, const char *use_protocol_version, char **args);
static void CFNetStatPrint(const char *file, int st_mode, const char *server);
static int CFNetStat(CFNetOptions *opts, const char *hostname, char **args);
static int CFNetGet(CFNetOptions *opts, const char *hostname, char **args);
static int CFNetOpenDir(CFNetOptions *opts, const char *hostname, char **args);
static int CFNetMulti(const char *server);
static int CFNetMultiTLS(const char *server, const char *use_protocol_version);
static int CFNetGetDir(CFNetOptions *opts, const char *hostname, char **args);


//*******************************************************************
// MAIN:
//*******************************************************************

int main(int argc, char **argv)
{
    CFNetOptions opts;
    CFNetSetDefault(&opts);
    char **args = NULL;
    char *hostnames = NULL;
    int ret = CFNetParse(argc, argv,                // Inputs
                         &opts, &args, &hostnames); // Outputs
    GenericAgentSetDefaultDigest(&CF_DEFAULT_DIGEST, &CF_DEFAULT_DIGEST_LEN);
    if (ret != 0)
    {
        DoCleanupAndExit(EXIT_FAILURE);
    }
    ret = CFNetRun(&opts, args, hostnames);  // Commands return exit code
    free(hostnames);
    CFNetOptionsClear(&opts);

    ret = (ret == 0) ? EXIT_SUCCESS : EXIT_FAILURE;
    DoCleanupAndExit(ret);
}

//*******************************************************************
// INIT:
//*******************************************************************

static void CFNetSetDefault(CFNetOptions *opts){
    assert(opts != NULL);
    opts->debug       = false;
    opts->verbose     = false;
    opts->inform      = false;
    opts->used_default= false;
    opts->min_tls_version = NULL;
    opts->allow_ciphers   = NULL;
    opts->use_protocol_version = NULL;
    opts->print_stats = false;
}

static void CFNetOptionsClear(CFNetOptions *opts)
{
    assert(opts != NULL);
    free(opts->min_tls_version);
    free(opts->allow_ciphers);
    free(opts->use_protocol_version);
}

static void CFNetInit(const char *min_tls_version, const char *allow_ciphers)
{
#ifdef __MINGW32__
    InitializeWindows();
    OpenNetwork();
#endif
    CryptoInitialize();
    LoadSecretKeys(NULL, NULL, NULL, NULL);
    cfnet_init(min_tls_version, allow_ciphers);
}

//*******************************************************************
// MAIN LOGIC:
//*******************************************************************

static char *RequireHostname(char *hostnames)
{
    if (hostnames == NULL)
    {
        char *policy_server = PolicyServerReadFile(GetWorkDir());
        if (policy_server == NULL)
        {
            printf("Error: no host name (and no policy_server.dat)\n");
            DoCleanupAndExit(EXIT_FAILURE);
        }
        return policy_server;
    }
    return xstrdup(hostnames);
}


static int CFNetParse(int argc, char **argv,
                      CFNetOptions *opts, char ***args, char **hostnames)
{
    assert(opts != NULL);
    if (argc <= 1)
    {
        CFNetHelp(NULL);
        return 0;
    }
    extern int optind;
    extern char *optarg;
    *hostnames = NULL;
    int c = 0;
    int start_index = 1;
    const char *optstr = "+hMg:H:p:sdvI"; // + means stop for non opt arg. :)
    while ((c = getopt_long(argc, argv, optstr, OPTIONS, &start_index))
            != -1)
    {
        switch (c)
        {
            case 'h':
            {
                CFNetHelp(NULL);
                break;
            }
            case 'M':
            {
                // TODO: How do we actually add a man page
                Writer *out = FileWriter(stdout);
                ManPageWrite(out, "cf-net", time(NULL),
                             CF_NET_SHORT_DESCRIPTION,
                             CF_NET_MANPAGE_LONG_DESCRIPTION,
                             OPTIONS, HINTS,
                             COMMANDS, false,
                             true);
                FileWriterDetach(out);
                DoCleanupAndExit(EXIT_SUCCESS);
                break;
            }
            case 'H':
            {
                if (*hostnames != NULL)
                {
                    Log(LOG_LEVEL_INFO,
                        "Warning: multiple occurences of -H in command, "\
                        "only last one will be used.");
                    free(*hostnames);
                }
                *hostnames = xstrdup(optarg);
                break;
            }
            case 'd':
            {
                opts->debug = true;
                break;
            }
            case 'v':
            {
                opts->verbose = true;
                break;
            }
            case 'I':
            {
                opts->inform = true;
                break;
            }
            case 'g':
            {
                LogSetGlobalLevelArgOrExit(optarg);
                break;
            }
            case 't':
            {
                opts->min_tls_version = xstrdup(optarg);
                break;
            }
            case 'c':
            {
                opts->allow_ciphers = xstrdup(optarg);
                break;
            }
            case 'p':
            {
                opts->use_protocol_version = xstrdup(optarg);
                break;
            }
            case 's':
            {
                opts->print_stats = true;
                break;
            }
            default:
            {
                // printf("Default optarg = '%s', c = '%c' = %i\n",
                //        optarg, c, (int)c);
                DoCleanupAndExit(EXIT_FAILURE);
                break;
            }
        }
    }
    (*args) = &(argv[optind]);
    return 0;
}

// This converts a command string to an enum: "HELP" -> HELP
static int CFNetCommandNumber(char *command)
{
    for (int i = 0; i < command_enum_max; ++i)
    {
        if (strcmp(command, command_strings[i]) == 0)
        {
            return i;
        }
    }
    return CFNET_CMD_INVALID;
}

// ^ Macro returns on match(!)
static int CFNetCommandSwitch(CFNetOptions *opts, const char *hostname,
                              char **args, enum command_enum cmd)
{
    assert(opts != NULL);

    switch (cmd) {
        case CFNET_CMD_CONNECT:
            return CFNetConnect(hostname, opts->use_protocol_version, args);
        case CFNET_CMD_STAT:
            return CFNetStat(opts, hostname, args);
        case CFNET_CMD_GET:
            return CFNetGet(opts, hostname, args);
        case CFNET_CMD_OPENDIR:
            return CFNetOpenDir(opts, hostname, args);
        case CFNET_CMD_GETDIR:
            return CFNetGetDir(opts, hostname, args);
        case CFNET_CMD_MULTI:
            return CFNetMulti(hostname);
        case CFNET_CMD_MULTITLS:
            return CFNetMultiTLS(hostname, opts->use_protocol_version);
        default:
            break;
    }
    printf("Bug: CFNetCommandSwitch() is unable to pick a command\n");
    DoCleanupAndExit(EXIT_FAILURE);
    return -1;
}

static void CFNetSetVerbosity(CFNetOptions *opts)
{
    assert(opts);
    if(opts->debug)
    {
        LogSetGlobalLevel(LOG_LEVEL_DEBUG);
        Log(LOG_LEVEL_DEBUG, "Debug log level enabled");
    }
    else if(opts->verbose)
    {
        LogSetGlobalLevel(LOG_LEVEL_VERBOSE);
        Log(LOG_LEVEL_VERBOSE, "Verbose log level enabled");
    }
    else if(opts->inform)
    {
        LogSetGlobalLevel(LOG_LEVEL_INFO);
        Log(LOG_LEVEL_INFO, "Inform log level enabled");
    }
}

static int CFNetRun(CFNetOptions *opts, char **args, char *hostnames)
{
    assert(opts != NULL);
    assert(args != NULL);
    CFNetSetVerbosity(opts);

    char *command_name = args[0];
    if (NULL_OR_EMPTY(command_name))
    {
        printf("Error: Command missing, use cf-net --help for more info.\n");
        DoCleanupAndExit(EXIT_FAILURE);
    }
    ToUpperStrInplace(command_name);

    Log(LOG_LEVEL_VERBOSE, "Running command '%s' with argument(s):\n",
        command_name);
    for (int i = 1; args[i] != NULL; ++i)
    {
        Log(LOG_LEVEL_VERBOSE, "argv[%i]=%s\n", i, args[i]);
    }

    enum command_enum cmd = CFNetCommandNumber(command_name);

    if (cmd == CFNET_CMD_INVALID)
    {
        Log(LOG_LEVEL_ERR, "'%s' is not a valid cf-net command\n", args[0]);
        return -1;
    }
    else if (cmd == CFNET_CMD_HELP)
    {
        return CFNetHelp(args[1]);
    }

    CFNetInit(opts->min_tls_version, opts->allow_ciphers);
    char *hosts = RequireHostname(hostnames);
    int ret = 0;
    char *hostname = strtok(hosts, ",");
    while (hostname != NULL){
        if (CFNetCommandSwitch(opts, hostname, args, cmd) != 0) {
            ret = -1;
        }
        hostname = strtok(NULL, ",");
    }
    free(hosts);
    return ret;
}

//*******************************************************************
// PROTOCOL:
//*******************************************************************

static AgentConnection *CFNetOpenConnection(const char *server, const char *use_protocol_version)
{
    ProtocolVersion protocol_version = (use_protocol_version == NULL)
                                     ? CF_PROTOCOL_LATEST
                                     : ParseProtocolVersionPolicy(use_protocol_version);
    if (protocol_version == CF_PROTOCOL_UNDEFINED)
    {
        Log(LOG_LEVEL_ERR, "Unknown CFEngine protocol version '%s'",
            use_protocol_version);
        return NULL;
    }

    AgentConnection *conn = NULL;
    ConnectionFlags connflags =
    {
        .protocol_version = protocol_version,
        .trust_server = true,
        .off_the_record = true
    };
    int err;
    char *buf = xstrdup(server);
    char *host, *port;
    ParseHostPort(buf, &host, &port);
    if (port == NULL)
    {
        port = CFENGINE_PORT_STR;
    }
    conn = ServerConnection(host, port, NULL, 30, connflags, &err);
    free(buf);
    if (conn == NULL)
    {
        printf("Failed to connect to '%s'\n", server);
        return NULL;
    }

    ProtocolVersion negotiated_version = ConnectionInfoProtocolVersion(conn->conn_info);
    if ((use_protocol_version != NULL) && (negotiated_version != protocol_version))
    {
        Log(LOG_LEVEL_ERR,
            "Negotiated protocol version '%s' does not match specified protocol version '%s'. "
            "Maybe the server does not support it?",
            ProtocolVersionString(negotiated_version),
            use_protocol_version);
        DisconnectServer(conn);
        return NULL;
    }

    return conn;
}

static int JustConnect(const char *server, char *port)
{
    char txtaddr[CF_MAX_IP_LEN] = "";
    return SocketConnect(server, port, 100, true,
                         txtaddr, sizeof(txtaddr));
}

//*******************************************************************
// COMMANDS:
//*******************************************************************

static int CFNetHelpTopic(const char *topic)
{
    assert(topic != NULL);
    bool found = false;
    for (int i = 0; COMMANDS[i].name != NULL; ++i)
    {
        if (strcmp(COMMANDS[i].name, topic) == 0)
        {
            printf("Command:     %s\n",  COMMANDS[i].name);
            printf("Usage:       %s\n",  COMMANDS[i].usage);
            printf("Description: %s\n", COMMANDS[i].description);
            found = true;
            break;
        }
    }

    // Add more detailed explanation here if necessary:
    if (strcmp("help", topic) == 0)
    {
        printf("\nYou did it, you used the help command!\n");
    }
    else if (strcmp("stat", topic) == 0)
    {
        printf("\nFor security reasons the server doesn't give any additional"
               "\ninformation if unsuccessful. 'Could not stat' message can"
               "\nindicate wrong path, the file doesn't exist, permission "
               "\ndenied, or something else.\n");
    }
    else if (strcmp("get", topic) == 0)
    {
        printf("\ncf-net get is comprised of two requests, STAT and GET."
               "\nThe CFEngine GET protocol command requires a STAT first to"
               "\ndetermine file size. By default the file is saved as its"
               "\nbasename in current working directory (cwd). Override this"
               "\nusing the -o filename option (-o - for stdout).\n");
    }
    else if (strcmp("getdir", topic) == 0)
    {
        printf("\ncf-net getdir recursively downloads a directory from a remote host."
           "\nIt uses OPENDIR to list contents, STAT to check file types, and GET"
           "\nto download files. By default the directory is saved with its basename"
           "\nin the current working directory (cwd). Override the destination using"
           "\nthe -o path option."
           "\n\nUsage: cf-net getdir [-o output_path] <remote_directory>"
           "\n\nExample: cf-net getdir -o /tmp/backup masterfiles/\n");
    }
    else
    {
        if (found == false)
        {
            printf("Unknown help topic: '%s'\n", topic);
            return -1;
        }
    }
    return 0;
}

static int CFNetHelp(const char *topic)
{
    if (topic != NULL)
    {
        CFNetHelpTopic(topic);
    }
    else
    {
        Writer *w = FileWriter(stdout);
        WriterWriteHelp(w, &COMPONENT, OPTIONS, HINTS, COMMANDS, false, false);
        FileWriterDetach(w);
        DoCleanupAndExit(EXIT_SUCCESS);
    }
    return 0;
}

static int CFNetConnectSingle(const char *server, const char *use_protocol_version, bool print)
{
    AgentConnection *conn = CFNetOpenConnection(server, use_protocol_version);
    if (conn == NULL)
    {
        return -1;
    }
    if (print == true)
    {
        printf("Connected & authenticated successfully to '%s'\n", server);
    }
    CFNetDisconnect(conn);
    return 0;
}

static int CFNetConnect(const char *hostname, const char *use_protocol_version, char **args)
{
    assert(args != NULL);
    if (args[1] != NULL)
    {
        Log(LOG_LEVEL_ERR, "connect does not take any arguments\n"\
                           "(See cf-net --help)");
        return -1;
    }
    if (NULL_OR_EMPTY(hostname))
    {
        Log(LOG_LEVEL_ERR, "No hostname specified");
        return -1;
    }
    return CFNetConnectSingle(hostname, use_protocol_version, true);
}

static void CFNetDisconnect(AgentConnection *conn)
{
    DisconnectServer(conn);
}

static void CFNetStatPrint(const char *file, int st_mode, const char *server)
{
    printf("%s:", server);
    if (S_ISDIR(st_mode))
    {
        printf("'%s' is a directory\n", file);
    }
    else if (S_ISREG(st_mode))
    {
        printf("'%s' is a regular file\n", file);
    }
    else if (S_ISSOCK(st_mode))
    {
        printf("'%s' is a socket\n", file);
    }
    else if (S_ISCHR(st_mode))
    {
        printf("'%s' is a character device file\n", file);
    }
    else if (S_ISBLK(st_mode))
    {
        printf("'%s' is a block device file\n", file);
    }
    else if (S_ISFIFO(st_mode))
    {
        printf("'%s' is a named pipe (FIFO)\n", file);
    }
    else if (S_ISLNK(st_mode))
    {
        printf("'%s' is a symbolic link\n", file);
    }
    else
    {
        printf("'%s' has an unrecognized st_mode\n", file);
    }
}

static int CFNetStat(ARG_UNUSED CFNetOptions *opts, const char *hostname, char **args)
{
    assert(opts != NULL);

    char *file = args[1];
    AgentConnection *conn = CFNetOpenConnection(hostname, opts->use_protocol_version);
    if (conn == NULL)
    {
        return -1;
    }
    struct stat sb;
    bool ret = ProtocolStat(conn, file, &sb);
    if (!ret)
    {
        printf("Could not stat: '%s'\n", file);
    }
    else
    {
        Log(LOG_LEVEL_INFO, "Detailed stat output:\n"
                            "mode  = %jo, \tsize = %jd,\n"
                            "uid   = %ju, \tgid = %ju,\n"
                            "atime = %jd, \tmtime = %jd\n",
            (uintmax_t) sb.st_mode,  (intmax_t)  sb.st_size,
            (uintmax_t) sb.st_uid,   (uintmax_t) sb.st_gid,
            (intmax_t)  sb.st_atime, (intmax_t)  sb.st_mtime);
        CFNetStatPrint(file, sb.st_mode, hostname);
    }

    CFNetDisconnect(conn);
    return 0;
}

static int invalid_command(const char *cmd)
{
    Log(LOG_LEVEL_ERR, "Invalid, see: 'cf-net help %s' for more info\n", cmd);
    return -1;
}


typedef struct _GetFileData {
    const char *hostname;
    const char *use_protocol_version;
    bool print_stats;
    char remote_file[PATH_MAX];
    char local_file[PATH_MAX];
    bool ret;
} GetFileData;

static void *CFNetGetFile(void *arg)
{
    GetFileData *data = (GetFileData *) arg;
    AgentConnection *conn = CFNetOpenConnection(data->hostname, data->use_protocol_version);
    if (conn == NULL)
    {
        data->ret = false;
        return NULL;
    }

    data->ret = ProtocolStatGet(conn, data->remote_file,
                                data->local_file, 0644, data->print_stats);
    if (!data->ret)
    {
        printf("Could not stat: '%s'\n", data->remote_file);
    }
    CFNetDisconnect(conn);
    return NULL;
}

typedef struct _CFNetThreadData {
    pthread_t    id;
    GetFileData *data;
} CFNetThreadData;

static int CFNetGet(ARG_UNUSED CFNetOptions *opts, const char *hostname, char **args)
{
    assert(opts);
    assert(hostname);
    assert(args);
    char *local_file = NULL;

    // TODO: Propagate argv and argc from main()
    int argc = 0;
    while (args[argc] != NULL)
    {
        ++argc;
    }

    static struct option longopts[] = {
         { "output",     required_argument,      NULL,           'o' },
         { "jobs",       required_argument,      NULL,           'j' },
         { NULL,         0,                      NULL,           0   }
    };
    assert(opts != NULL);
    if (argc <= 1)
    {
        return invalid_command("get");
    }
    extern int optind;
    optind = 0;
    extern char *optarg;
    int c = 0;
    // TODO: Experiment with more user friendly leading - optstring
    const char *optstr = "o:j:";
    bool specified_path = false;
    long n_threads = 1;
    while ((c = getopt_long(argc, args, optstr, longopts, NULL))
            != -1)
    {
        switch (c)
        {
            case 'o':
            {
                if (local_file != NULL)
                {
                    Log(LOG_LEVEL_INFO,
                        "Warning: multiple occurences of -o in command, "\
                        "only last one will be used.");
                    free(local_file);
                }
                local_file = xstrdup(optarg);
                specified_path = true;
                break;
            }
            case 'j':
            {
                int ret = StringToLong(optarg, &n_threads);
                if (ret != 0)
                {
                    printf("Failed to parse number of threads/jobs from '%s'\n", optarg);
                    n_threads = 1;
                }
                break;
            }
            case ':':
            {
                return invalid_command("get");
                break;
            }
            case '?':
            {
                return invalid_command("get");
                break;
            }
            default:
            {
                printf("Default optarg = '%s', c = '%c' = %i\n",
                       optarg, c, (int)c);
                break;
            }
        }
    }
    args = &(args[optind]);
    argc -= optind;
    char *remote_file = args[0];

    if(local_file == NULL)
    {
        local_file = xstrdup(basename(remote_file));
    }

    if (specified_path && strcmp(local_file, "-") == 0)
    {
         // TODO: Should rewrite CopyRegularFileNet etc. to take fd argument
         // and a simple helper function to open a file as well.
        printf("Output to stdout not yet implemented (TODO)\n");
        free(local_file);
        return -1;
    }

    CFNetThreadData **threads = (CFNetThreadData**) xcalloc((size_t) n_threads, sizeof(CFNetThreadData*));
    for (int i = 0; i < n_threads; i++)
    {
        threads[i] = (CFNetThreadData*) xcalloc(1, sizeof(CFNetThreadData));
        threads[i]->data = (GetFileData*) xcalloc(1, sizeof(GetFileData));
        threads[i]->data->hostname = hostname;
        threads[i]->data->use_protocol_version = opts->use_protocol_version;
        threads[i]->data->print_stats = opts->print_stats;
        if (n_threads > 1)
        {
            if (strstr(local_file, "%d") != NULL)
            {
                snprintf(threads[i]->data->local_file, PATH_MAX, local_file, i);
            }
            else
            {
                snprintf(threads[i]->data->local_file, PATH_MAX, "%s.%d", local_file, i);
            }

            if (strstr(remote_file, "%d") != NULL)
            {
                snprintf(threads[i]->data->remote_file, PATH_MAX, remote_file, i);
            }
            else
            {
                snprintf(threads[i]->data->remote_file, PATH_MAX, "%s", remote_file);
            }
        }
        else
        {
            snprintf(threads[i]->data->local_file, PATH_MAX, "%s", local_file);
            snprintf(threads[i]->data->remote_file, PATH_MAX, "%s", remote_file);
        }
    }

    bool failure = false;
    for (int i = 0; !failure && (i < n_threads); i++)
    {
        int ret = pthread_create(&(threads[i]->id), NULL, CFNetGetFile, threads[i]->data);
        if (ret != 0)
        {
            printf("Failed to create a new thread to get the file in: %s\n", strerror(ret));
            failure = true;
        }
    }

    for (int i = 0; i < n_threads; i++)
    {
        int ret = pthread_join(threads[i]->id, NULL);
        if (ret != 0)
        {
            printf("Failed to join the thread: %s\n", strerror(ret));
        }
        else
        {
            failure = failure && !threads[i]->data->ret;
        }
    }

    for (int i = 0; i < n_threads; i++)
    {
        free(threads[i]->data);
        free(threads[i]);
    }
    free(threads);
    free(local_file);
    return failure ? -1 : 0;
}

static void PrintDirs(const Seq *list)
{
    for (size_t i = 0; i < SeqLength(list); i++)
    {
        char *dir_entry = SeqAt(list, i);
        printf("%s\n", dir_entry);
    }
}

static int CFNetOpenDir(ARG_UNUSED CFNetOptions *opts, const char *hostname, char **args)
{
    assert(opts != NULL);
    assert(hostname != NULL);
    assert(args != NULL);

    AgentConnection *conn = CFNetOpenConnection(hostname, opts->use_protocol_version);
    if (conn == NULL)
    {
        return -1;
    }

    // TODO: Propagate argv and argc from main()
    int argc = 1;
    while (args[argc] != NULL)
    {
        ++argc;
    }
    if (argc <= 1)
    {
        return invalid_command("opendir");
    }

    const char *remote_path = args[1];

    Seq *seq = ProtocolOpenDir(conn, remote_path);
    if (seq == NULL)
    {
        return -1;
    }

    PrintDirs(seq);
    SeqDestroy(seq);
    CFNetDisconnect(conn);
    return 0;
}

enum get_dir_error
{
    GET_DIR_OK = 0,
    GET_DIR_ERROR= 1,
    GET_DIR_RECURSION_LIMIT = 2,
};

static bool CFNetGetWithPerms(AgentConnection *conn, const char *remote_path,
                          const char *local_path, bool print_stats)
{
    assert(conn != NULL);
    assert(remote_path != NULL);
    assert(local_path != NULL);

    struct stat perms;
    if (!ProtocolStat(conn, remote_path, &perms))
    {
        Log(LOG_LEVEL_ERR, "Failed to stat remote file: %s:%s",
            conn->this_server, remote_path);
        return false;
    }

    if (!ProtocolGet(conn, remote_path, local_path, perms.st_size, perms.st_mode, print_stats))
    {
        Log(LOG_LEVEL_ERR, "Failed to get remote file: %s:%s",
            conn->this_server, remote_path);
        return false;
    }

    return true;
}

/**
 * @brief Creates a local directory path with specified permissions
 *
 * This helper function constructs and creates a directory path relative
 * to a provided base path. Handling path construction and creating 
 * parent directories as needed.
 *
 * @param local_base      Base directory path for the new directory
 * @param subdir          Subdirectory name to create
 * @param perms           Permission mode bits to apply to created directories
 *
 * @return true if the directory exists or was successfully created, false otherwise
 */
static bool create_local_dir(const char *local_base, const char *subdir, mode_t perms)
{
    char path[PATH_MAX];

    int written = snprintf(path, sizeof(path), "%s/%s/", local_base, subdir);

    if (written < 0 || (size_t) written >= sizeof(path))
    {
        Log(LOG_LEVEL_ERR, "Path too long for new directory: %s", subdir);
        return false;
    }

    bool force = false;
    return MakeParentDirectoryPerms(path, force, NULL, perms);
}

static int process_dir_recursive(AgentConnection *conn,
                                 const char *remote_path,
                                 const char *local_path,
                                 bool print_stats,
                                 int limit)
{
    int ret = 0;
    if (limit <= 0)
    {
        Log(LOG_LEVEL_ERR, "Recursion limit reached");
        return GET_DIR_RECURSION_LIMIT;
    }

    Seq *items = ProtocolOpenDir(conn, remote_path);
    if (items == NULL)
    {
        return GET_DIR_ERROR;
    }

    for (size_t i = 0; i < SeqLength(items); i++)
    {
        const char *item = SeqAt(items, i);

        if (strcmp(".", item) == 0 || strcmp("..", item) == 0)
        {
            continue;
        }

        /* A directory entry is a single path component. A separator here
         * (e.g. "../x" from a hostile server) would escape local_path once
         * joined below, see http://cwe.mitre.org/data/definitions/32.html */
        bool has_separator = false;
        for (const char *s = item; *s != '\0'; s++)
        {
            if (IsFileSep(*s))
            {
                has_separator = true;
                break;
            }
        }
        if (has_separator)
        {
            Log(LOG_LEVEL_ERR,
                "Skipping remote directory entry with path separator: '%s'",
                item);
            continue;
        }

        char remote_full[PATH_MAX];
        int written = snprintf(remote_full, sizeof(remote_full), "%s/%s", remote_path, item);
        if (written < 0 || (size_t) written >= sizeof(remote_full))
        {
            Log(LOG_LEVEL_ERR,
                "Path too long for building full remote path: %s/%s",
                remote_path, item);
            SeqDestroy(items);
            return GET_DIR_ERROR;
        }

        char local_full[PATH_MAX];
        written = snprintf(local_full, sizeof(local_full), "%s/%s", local_path, item);
        if (written < 0 || (size_t) written >= sizeof(local_full))
        {
            Log(LOG_LEVEL_ERR,
                "Path too long for building full local path: %s/%s",
                local_path, item);
            SeqDestroy(items);
            return GET_DIR_ERROR;
        }

        struct stat sb;
        if (!ProtocolStat(conn, remote_full, &sb))
        {
            Log(LOG_LEVEL_ERR, "Could not stat: %s", remote_full);
            SeqDestroy(items);
            return GET_DIR_ERROR;
        }

        if (S_ISDIR(sb.st_mode)) // Is directory
        {
            if (!create_local_dir(local_path, item, sb.st_mode))
            {
                // Error already logged
                SeqDestroy(items);
                return GET_DIR_ERROR;
            }
            ret = process_dir_recursive(conn, remote_full, local_full,
                                        print_stats, (limit - 1));

            if (ret != GET_DIR_OK)
            {
                SeqDestroy(items);
                return ret;
            }
        }
        else
        {
            if (!CFNetGetWithPerms(conn, remote_full, local_full, print_stats))
            {
                SeqDestroy(items);
                return GET_DIR_ERROR;
            }
        }
    }

    SeqDestroy(items);
    return ret;
}

static int CFNetGetDir(CFNetOptions *opts, const char *hostname, char **args)
{
    assert(opts != NULL);
    assert(hostname != NULL);
    assert(args != NULL);

    char *local_dir = NULL;
    bool specified_path = false;

    int argc = 0;
    while (args[argc] != NULL)
    {
        ++argc;
    }

    static struct option longopts[] = {
         { "output",     required_argument,      NULL,          'o' },
         { NULL,         0,                      NULL,           0  }
    };
    if (argc <= 1)
    {
        return invalid_command("getdir");
    }
    extern int optind;
    optind = 0;
    extern char *optarg;
    int c = 0;
    const char *optstr = "o:";
    while ((c = getopt_long(argc, args, optstr, longopts, NULL))
            != -1)
    {
        switch (c)
        {
            case 'o':
            {
                if (local_dir != NULL)
                {
                    Log(LOG_LEVEL_INFO,
                        "Warning: multiple occurrences of -o in command, "\
                        "only last one will be used.");
                    free(local_dir);
                }
                local_dir = xstrdup(optarg);
                specified_path = true;
                break;
            }
            case ':':
            case '?':
            {
                free(local_dir);
                return invalid_command("getdir");
            }
            default:
            {
                printf("Default optarg = '%s', c = '%c' = %i\n",
                       optarg, c, (int)c);
                break;
            }
        }
    }

    if (optind >= argc || args[optind] == NULL)
    {
        free(local_dir);
        return invalid_command("getdir");
    }
    args = &(args[optind]);
    argc -= optind;

    const char *remote_dir = args[0];
    char *tmp  = xstrdup(remote_dir);
    char *base = xstrdup(basename(tmp));
    free(tmp);

    char cwd[PATH_MAX];
    if (!specified_path && getcwd(cwd, sizeof(cwd)) == NULL)
    {
        Log(LOG_LEVEL_ERR, "Failed to get current working directory: %s", GetErrorStr());
        free(base);
        free(local_dir);
        return -1;
    }

    tmp = local_dir;
    local_dir = StringFormat("%s/%s/", (specified_path ? local_dir : cwd), base);
    free(tmp);

    if (strlen(local_dir) > PATH_MAX)
    {
        Log(LOG_LEVEL_ERR, "Path: %s greater than PATH_MAX", local_dir);
        free(base);
        free(local_dir);
        return -1;
    }

    AgentConnection *conn = CFNetOpenConnection(hostname, opts->use_protocol_version);
    if (conn == NULL)
    {
        free(local_dir);
        free(base);
        return -1;
    }
    struct stat sb;
    if (!ProtocolStat(conn, remote_dir, &sb))
    {
        printf("Could not stat: '%s'\n", remote_dir);
        free(local_dir);
        free(base);
        CFNetDisconnect(conn);
        return -1;
    }
    if (!S_ISDIR(sb.st_mode))
    {
        printf("'%s' is not a directory, use 'get' for single file download\n",
                remote_dir);
        free(local_dir);
        free(base);
        CFNetDisconnect(conn);
        return -1;
    }

    int ret = process_dir_recursive(conn,
                                    remote_dir,
                                    local_dir,
                                    opts->print_stats,
                                    RECURSIONLIMIT);
    if (ret == GET_DIR_RECURSION_LIMIT)
    {
        // Already logged
    }
    else if (ret == GET_DIR_ERROR)
    {
        Log(LOG_LEVEL_INFO, "Failed to copy contents of %s", remote_dir);
    }

    free(local_dir);
    free(base);
    CFNetDisconnect(conn);
    return (ret == GET_DIR_OK) ? 0 : -1;
}

static int CFNetMulti(const char *server)
{
    time_t start;
    time(&start);

    int ret = 0;
    int attempts = 0;

    printf("Connecting repeatedly to '%s' without handshakes\n",server);
    attempts = 0;

    char *buf = xstrdup(server);
    char *host, *port;
    ParseHostPort(buf, &host, &port);
    if (port == NULL)
    {
        port = CFENGINE_PORT_STR;
    }

    ret = 0;
    while(ret != 1)
    {
        ret = JustConnect(host, port);
        attempts++;
    }

    free(buf);
    printf("Server unavailable after %i attempts\n", attempts);

    time_t stop;
    time(&stop);
    double seconds = difftime(stop,start);
    printf("'%s' unavailable after %.f seconds (%i attempts)\n",
           server, seconds, attempts);
    return 0;
}

static int CFNetMultiTLS(const char *server, const char *use_protocol_version)
{
    time_t start;
    time(&start);

    printf("Multiple handshakes to '%s'\n", server);
    int ret = 0;
    int attempts = 0;
    while(ret == 0)
    {
        ret = CFNetConnectSingle(server, use_protocol_version, false);
        ++attempts;
    }
    time_t stop;
    time(&stop);

    double seconds = difftime(stop,start);
    printf("'%s' unavailable after %.f seconds (%i attempts)\n",
           server, seconds, attempts);
    CFNetMulti(server);
    return 0;
}
