<?php
//
// Copyright (c) 2025 Nagios Enterprises, LLC. All rights reserved.
//

////////////////////////////////////////////////////////////////////////////////
// SNMPWALK UTILITY FUNCTIONS
////////////////////////////////////////////////////////////////////////////////


/**
 * Returns information about completed and running SNMP Walk Jobs
 * Pulls hostname and job start date (unix)
 * Determines status by checking for corresponding .pid file
 * 
 * @return  mixed  JSON encoded array containing job data values     
 */
function get_all_snmpwalk_jobs()
{
    $job_file_dir = get_tmp_dir();
    $job_pattern = $job_file_dir . '/snmpwalk_*.log';
    $job_files = glob($job_pattern);
    $job_data = [];
    if (!empty($job_files)) {
        foreach ($job_files as $job) {            
            $file_contents = file_get_contents($job);
            $job_status = check_job_status($job, $file_contents);
            $oid_count = count_job_oids($job, $file_contents);
            $command_values = get_job_command_values($file_contents);

            $file_name_parts = explode("_", $job);
            $data = [
                'hostname'  => $file_name_parts[1],
                'date'      => substr($file_name_parts[2], 0, strlen($file_name_parts[2]) - 4),
                'status'    => $job_status,
                'oid_count' => $oid_count,
                'command_values' => $command_values,
            ];
            $job_data[] = $data;
        }
    }    
    return json_encode($job_data);
}

function check_job_status($job, $file_contents)
{
    if (!file_exists($job) || !is_readable($job)) {
        return "Error";
    }

    $pid_file = substr($job, 0, strlen($job) - 4) . ".pid";

    if (file_exists($pid_file)) {
        return "Pending";
    }

    if ($file_contents === false) {
        return "Error";
    }

    // only scan the file header for errors, we don't want to trigger false positives based on OID names
    $file_head = implode("\n", array_slice(explode("\n", $file_contents), 0, 3));

    $simple_error_patterns = [
        'No Such Object',
        'No Such Instance',
        'Authentication failure',
        'Connection refused',
        'Cannot find module',
        'Unknown Object Identifier',
        'snmpwalk: ',
        'passphrase chosen is below the length requirements',
        'Error generating a key',
        'The supplied password length is too short',
        'Invalid authentication',
    ];

    $word_boundary_patterns = [
        'Timeout',
        'Error',
    ];

    foreach ($simple_error_patterns as $pattern) {
        if (stripos($file_head, $pattern)) {
            return "Error";
        }
    }

    foreach ($word_boundary_patterns as $pattern) {
        $regex = '/\b' . preg_quote($pattern, '/') . '\b:?/i';
        if (preg_match($regex, $file_head)) {
            return "Error";
        }
    }

    return "Finished";
}

/**
 * Parse snmpwalk job log lines the same way as the SNMP Walk wizard (configwizard GETSTAGE2).
 * MIB-grouped map; duplicate mib+oid keys keep the last occurrence.
 *
 * @param string $fcontents full .log file contents (command header line + snmpwalk output)
 *
 * @return array<string, array<string, array<string, mixed>>> mib => symbolic oid => row
 */
function snmpwalk_wizard_parse_log_contents_to_mib_groups($fcontents)
{
    $rows = explode("\n", $fcontents);
    $all_oids = [];

    foreach ($rows as $row) {
        $parts = explode("::", $row);
        $mib = $parts[0];

        array_shift($parts);
        $newrow = implode("::", $parts);

        $parts = explode(" = ", $newrow);
        $theoid = $parts[0];

        array_shift($parts);
        $newrow = implode(" = ", $parts);

        $parts = explode(":", $newrow);
        $type = $parts[0];

        array_shift($parts);
        $newrow = implode(":", $parts);

        $val = $newrow;

        if (empty($val) && strpos($type, "No more variables left in this MIB View") !== false) {
            continue;
        }

        if ($mib == "" || $theoid == "" || $type == "") {
            continue;
        }

        $oid_name = substr($theoid, 0, strrpos($theoid, '.'));
        $oid_index = substr($theoid, strrpos($theoid, '.') + 1, strlen($theoid));

        if (preg_match('/^[a-zA-Z0-9._-]+$/', $oid_name) === 1) {
            $all_oids[] = array(
                "oid" => $theoid,
                "type" => $type,
                "val" => $val,
                "name" => "",
                "label" => "",
                "units" => "",
                "matchtype" => "",
                "warning" => "",
                "critical" => "",
                "string" => "",
                "mib" => $mib,
                "oid_index" => $oid_index,
                "oid_name" => $oid_name,
            );
        }
    }

    $grouped_by_mib = [];
    foreach ($all_oids as $entry) {
        $grouped_by_mib[$entry['mib']][$entry['oid']] = $entry;
    }

    return $grouped_by_mib;
}

/**
 * Whether a wizard OID row appears in step 2 browse/pagination (matches step2.php shouldShowRow).
 *
 * @param array<string, mixed> $entry
 */
function snmpwalk_wizard_browse_row_is_visible($entry)
{
    if (!is_array($entry)) {
        return false;
    }
    if (!array_key_exists('val', $entry) || $entry['val'] === null) {
        return false;
    }
    return true;
}

/**
 * Count OID rows shown on wizard step 2 (after MIB grouping and browse visibility rules).
 *
 * @param array<string, array<string, array<string, mixed>>> $grouped_by_mib
 */
function snmpwalk_wizard_count_browsable_rows($grouped_by_mib)
{
    if (!is_array($grouped_by_mib)) {
        return 0;
    }
    $n = 0;
    foreach ($grouped_by_mib as $oids) {
        if (!is_array($oids)) {
            continue;
        }
        foreach ($oids as $entry) {
            if (snmpwalk_wizard_browse_row_is_visible($entry)) {
                $n++;
            }
        }
    }
    return $n;
}

/**
 * OID count for job listings: matches wizard step 2 browse total (not raw log line count).
 *
 * @param string $job          absolute path to snmpwalk_*.log
 * @param string|false $file_contents same as file_get_contents($job)
 *
 * @return int|string
 */
function count_job_oids($job, $file_contents)
{
    if (!file_exists($job) || !is_readable($job)) {
        return "Error";
    }
    if ($file_contents === false || $file_contents === '') {
        return 0;
    }

    $grouped = snmpwalk_wizard_parse_log_contents_to_mib_groups($file_contents);
    return snmpwalk_wizard_count_browsable_rows($grouped);
}

function get_job_command_values($file_contents) {
    $command = trim(explode("\n", $file_contents)[0]);
    $command_values = [
        "job_name"                  => "",
        "device_address"            => "",
        "device_port"               => "161",
        "allow_non_sequential_oids" => false,
        "force_numeric_oid_indexes" => false,
        "snmp_version"              => "",
        "snmp_community"            => "",
        "security_level"            => "",
        "username"                  => "",
        "auth_password"             => "",
        "auth_protocol"             => "",
        "privacy_password"          => "",
        "privacy_protocol"          => "",
        "oid"                       => "",
        "timeout"                   => 15,
        "max_results"               => null,
    ];

    preg_match('/^Running(?: job \'((?:[^\'\\\\]|\\\\\')*)\')?:\s*snmpwalk\s+(.*)$/i', $command, $matches);
    if (!empty($matches[0]) && !empty($matches[2])) {
        $job_name = $matches[1] ?? null;
        $command = $matches[2] ?? $command;

        if ($job_name !== null) {
            $command_values['job_name'] = str_replace(["\\'"], ["'"], $job_name);
        }

        // Check for Max Results string, capture value and slice it off if sent to preserve parsing
        if (preg_match('/\s+with\s+Max\s+Results\s+set\s+to:\s*(\d+)\s*$/i', $command, $m)) {
            $command_values['max_results'] = (int)$m[1];
            $command = preg_replace('/\s+with\s+Max\s+Results\s+set\s+to:\s*\d+\s*$/i', '', $command);
        }

        $tokens = preg_split('/\s+/', $command);

        for ($i = 0; $i < count($tokens); $i++) {
            switch ($tokens[$i]) {
                case '-v':
                    $command_values['snmp_version'] = $tokens[$i + 1] ?? null;
                    $i++;
                    break;
                case '-c':
                    $command_values['snmp_community'] = $tokens[$i + 1] ?? null;
                    $i++;
                    break;
                case '-l':
                    $command_values['security_level'] = $tokens[$i + 1] ?? null;
                    $i++;
                    break;
                case '-u':
                    $command_values['username'] = $tokens[$i + 1] ?? null;
                    $i++;
                    break;
                case '-A':
                    $command_values['auth_password'] = $tokens[$i + 1] ?? null;
                    $i++;
                    break;
                case '-a':
                    $command_values['auth_protocol'] = $tokens[$i + 1] ?? null;
                    $i++;
                    break;
                case '-X':
                    $command_values['privacy_password'] = $tokens[$i + 1] ?? null;
                    $i++;
                    break;
                case '-x':
                    $command_values['privacy_protocol'] = $tokens[$i + 1] ?? null;
                    $i++;
                    break;
                case '-p':
                    $command_values['device_port'] = $tokens[$i + 1] ?? "161";
                    $i++;
                    break;
                case '-Cc':
                    $command_values['allow_non_sequential_oids'] = true;
                    break;
                default:
                    // Net-SNMP output flags (-Oe, -Ob, -Oeb, …); must not match the host:port heuristic below
                    if (preg_match('/^-O[a-zA-Z0-9]+$/', $tokens[$i])) {
                        if (strpos($tokens[$i], 'b') !== false) {
                            $command_values['force_numeric_oid_indexes'] = true;
                        }
                        break;
                    }
                    // catch values without explicit flags
                    if (preg_match('/^([a-zA-Z0-9\.\-]+)(?::(\d+))?$/', $tokens[$i], $host_matches)) {
                        $command_values['device_address'] = $host_matches[1];
                        if (!empty($host_matches[2])) {
                            $command_values['device_port'] = $host_matches[2];
                        }
                        // check next token for OID
                        if (($i + 1) < count($tokens)) {
                            $candidate = $tokens[$i + 1];
                            if (
                                preg_match('/^\d+(\.\d+)+$/', $candidate) ||
                                preg_match('/^[a-zA-Z][a-zA-Z0-9\-]+$/', $candidate)
                            ) {
                                $command_values['oid'] = $candidate;
                                $i++;
                            }
                        }
                    }
                    break;
            }
        }
    }

    return $command_values;
}

/**
 * First line of job logs is written by start_snmpwalk_job.sh as:
 *   Running job '…': snmpwalk … [with Max Results…]
 * or Running: snmpwalk …
 * Remaining lines are raw snmpwalk stdout (MIB::name.index = TYPE: value, or numeric OID form).
 * The echoed command may include Net-SNMP output flags (-Oe, -Ob, …) after host:port; get_job_command_values() skips those for metadata extraction.
 *
 * @param string $line
 *
 * @return bool
 */
function snmpwalk_line_is_job_echo_header($line)
{
    $t = ltrim($line);

    return strpos($t, 'Running ') === 0;
}

/**
 * Parse one line of Net-SNMP snmpwalk output into a wizard OID row, or null if not usable.
 *
 * @param string $row
 *
 * @return array<string,mixed>|null
 */
function snmpwalk_parse_oid_output_line($row)
{
    $row = trim($row);
    if ($row === '') {
        return null;
    }
    if (stripos($row, 'End of MIB') !== false) {
        return null;
    }
    if (stripos($row, 'No more variables') !== false) {
        return null;
    }

    if (!preg_match('/^(.+?)\s*=\s*(.+)$/s', $row, $mm)) {
        return null;
    }
    $lhs = trim($mm[1]);
    $rhs = trim($mm[2]);
    if ($lhs === '' || $rhs === '') {
        return null;
    }

    if (!preg_match('/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/s', $rhs, $rm)) {
        return null;
    }
    $type = $rm[1];
    $val = $rm[2];

    $mib = '';
    $theoid = '';
    if (strpos($lhs, '::') !== false) {
        $parts = explode('::', $lhs, 2);
        $mib = $parts[0];
        $theoid = $parts[1];
    } else {
        // Numeric OID (-On) or other non-translated form (no MIB:: prefix)
        $mib = 'numeric';
        $theoid = $lhs;
    }

    if ($theoid === '' || $mib === '') {
        return null;
    }

    $last_dot = strrpos($theoid, '.');
    if ($last_dot === false) {
        $oid_name = $theoid;
        $oid_index = '';
    } else {
        $oid_name = substr($theoid, 0, $last_dot);
        $oid_index = substr($theoid, $last_dot + 1);
    }

    if (preg_match('/^[a-zA-Z0-9._-]+$/', $oid_name) !== 1) {
        return null;
    }

    return [
        'oid' => $theoid,
        'type' => $type,
        'val' => $val,
        'name' => '',
        'label' => '',
        'units' => '',
        'matchtype' => '',
        'warning' => '',
        'critical' => '',
        'string' => '',
        'mib' => $mib,
        'oid_index' => $oid_index,
        'oid_name' => $oid_name,
    ];
}

/**
 * Parse snmpwalk job log content into OID data grouped by MIB name,
 * matching $_SESSION['snmpwalk_oid_data'] shape used by the SNMP Walk wizard.
 *
 * @param string $file_contents full .log file (header line + snmpwalk lines)
 *
 * @return array<string, array<string, array<string,mixed>>> mib => symbolic oid key => row
 */
function snmpwalk_parse_job_log_to_oid_groups($file_contents)
{
    $lines = preg_split('/\R/', $file_contents);
    $by_mib = [];

    foreach ($lines as $line) {
        if (snmpwalk_line_is_job_echo_header($line)) {
            continue;
        }
        $entry = snmpwalk_parse_oid_output_line($line);
        if ($entry !== null) {
            $mib = isset($entry['mib']) ? (string) $entry['mib'] : '';
            $by_mib[$mib][$entry['oid']] = $entry;
        }
    }

    return $by_mib;
}

/**
 * Normalize session / posted OID data to MIB-grouped form (mib => oid key => row).
 * Unwraps legacy grouped_by_* keys and re-buckets interface-grouped data using each row's mib field.
 *
 * @param array|null $data
 *
 * @return array<string, array<string, array<string,mixed>>>
 */
function snmpwalk_oid_data_as_mib_groups($data)
{
    if (!is_array($data)) {
        return [];
    }
    if (isset($data['grouped_by_mib']) && is_array($data['grouped_by_mib'])) {
        $data = $data['grouped_by_mib'];
    } elseif (isset($data['grouped_by_interface']) && is_array($data['grouped_by_interface'])) {
        $data = $data['grouped_by_interface'];
    }

    $out = [];
    foreach ($data as $_outerKey => $oids) {
        if (!is_array($oids)) {
            continue;
        }
        foreach ($oids as $oidKey => $entry) {
            if (!is_array($entry)) {
                continue;
            }
            $mib = isset($entry['mib']) ? (string) $entry['mib'] : '';
            if (!isset($out[$mib])) {
                $out[$mib] = [];
            }
            $out[$mib][$oidKey] = $entry;
        }
    }

    return $out;
}

/**
 * MIB-grouped OID map with only rows the user selected (checked === 'on').
 * Used for SNMP Walk wizard step 3 bootstrap so the page and wizardoutput POST field stay small on large walks.
 *
 * @param array|null $groups raw session oid_data or MIB-grouped map
 *
 * @return array<string, array<string, array<string,mixed>>>
 */
function snmpwalk_oid_data_checked_rows_only($groups)
{
    $groups = snmpwalk_oid_data_as_mib_groups($groups);
    $out = [];
    foreach ($groups as $mib => $oids) {
        if (!is_array($oids)) {
            continue;
        }
        foreach ($oids as $oidKey => $entry) {
            if (!is_array($entry)) {
                continue;
            }
            if (isset($entry['checked']) && $entry['checked'] === 'on') {
                if (!isset($out[$mib])) {
                    $out[$mib] = [];
                }
                $out[$mib][$oidKey] = $entry;
            }
        }
    }

    return $out;
}

/**
 * Decode oid_data_json from a wizard HTTP request into MIB-grouped OID maps (same shape as snmpwalk_oid_data_as_mib_groups).
 *
 * @param array<string,mixed>|null $inargs
 *
 * @return array<string, array<string, array<string,mixed>>>
 */
function snmpwalk_decode_oid_data_json_from_inargs($inargs)
{
    if (!is_array($inargs)) {
        return [];
    }
    $raw = grab_array_var($inargs, 'oid_data_json', '');
    if ($raw === '' || !is_string($raw)) {
        return [];
    }
    $decoded = json_decode($raw, true);
    if (!is_array($decoded)) {
        return [];
    }

    return snmpwalk_oid_data_as_mib_groups($decoded);
}

/**
 * JSON bootstrap for SNMP Walk wizard step 3 (selected OID detail/threshold UI).
 *
 * @return string JSON object mib => oid => row
 */
function snmpwalk_stage3_oid_bootstrap_json_from_session()
{
    $groups = snmpwalk_oid_data_checked_rows_only($_SESSION['snmpwalk_oid_data'] ?? null);
    $json_flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
    if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
        $json_flags |= JSON_INVALID_UTF8_SUBSTITUTE;
    }
    $payload = json_encode($groups, $json_flags);

    return $payload !== false ? $payload : '{}';
}

/**
 * After re-parsing the job file, restore user edits (checkboxes, thresholds, labels) from a prior session snapshot.
 *
 * @param array|null $previous $_SESSION['snmpwalk_oid_data'] before reload
 * @param array      $target   parsed MIB groups (modified in place)
 */
function snmpwalk_merge_oid_session_overlay($previous, &$target)
{
    if (!is_array($previous) || !is_array($target)) {
        return;
    }

    $previous_mib = snmpwalk_oid_data_as_mib_groups($previous);
    $user_fields = ['checked', 'name', 'label', 'units', 'matchtype', 'warning', 'critical', 'string'];

    foreach ($previous_mib as $group_key => $oids) {
        if (!isset($target[$group_key]) || !is_array($oids)) {
            continue;
        }
        foreach ($oids as $oid_key => $prev_rec) {
            if (!isset($target[$group_key][$oid_key]) || !is_array($prev_rec)) {
                continue;
            }
            foreach ($user_fields as $f) {
                if (array_key_exists($f, $prev_rec)) {
                    $target[$group_key][$oid_key][$f] = $prev_rec[$f];
                }
            }
        }
    }
}
